A Go client for the omnigent server's session API: client construction and auth, the session lifecycle, posting input, and consuming a session's server-sent event stream as typed events.
Seven session calls and two listings — a working subset rather than the whole
API. openapi.json publishes dozens of further session operations (items,
resources, policies, permissions, comments, fork, agent swap) that this package
does not call.
Releases are driven by version.json through the org's shared workflows in
sei-protocol/uci, the same ones the other
Go repositories here use. Bump the version, open a pull request, label it
release, and merge — the tag and the GitHub Release follow from the merge.
{ "version": "v0.1.0" }
Two things happen, in this order.
On the pull request, release-check validates the bump and — the part that
matters for a library — runs gorelease and gocompat against the previous
version. Those are the Go project's own API-compatibility checkers: they compare
the exported surface to the last tag and say whether the version increment is
semver-honest. A breaking change under a patch bump is caught before it ships,
which is worth more here than any artifact would be. The check only proposes a
tag when the pull request carries a release label, so an ordinary bump in a
feature branch cannot release by accident.
On merge to main, release-publish reads version.json, creates the tag and
the GitHub Release, and no-ops if the tag already exists. A tag pushed by hand
converges with the automatic path rather than conflicting with it.
There is deliberately no GoReleaser job. This module is a library, so the tag
is the release — go get resolves a version from the module proxy, which reads
it from the tag, and there is no binary to build or attach. The repositories here
that do follow release-publish with goreleaser-release are shipping a command.
If this ever ships one, that job belongs in
.github/workflows/uci-release-publish.yml.
This module is maintained by sei-protocol and is not an official Omnigent client. It began as a contribution to the Omnigent repository and now lives here at the Omnigent maintainers' suggestion, which means it can have CI that actually runs and releases we can tag.
models.gen.go and events.gen.go are generated by scripts/gen_go_client.py
from spec/openapi.json, a vendored copy of the Omnigent OpenAPI specification
(Apache-2.0, © Databricks, Inc.). They reproduce that specification's schema
descriptions as Go doc comments, so the generated half is a derivative of it —
hence Apache-2.0 here too, and the attribution in NOTICE.
The vendored spec is a snapshot, and that matters. Several defects in the first consumer of this SDK came from reasoning about server behaviour off a stale or wrong copy of that spec. Refresh it deliberately, regenerate, and read the diff — the spec is the contract, and a stale one is worse than no documentation because it reads as authoritative.
go get github.com/sei-protocol/omnigent-go-sdk@main@main, not @latest. This module lives in a subdirectory, so the module proxy
resolves released versions only from tags prefixed with that subdirectory
(v0.1.0). The repository's release tags are bare vX.Y.Z, which
the proxy will never match to a nested module, so there is nothing for @latest
to find until a maintainer pushes the first prefixed tag.
The module is also outside the four-package version lockstep the Python and
TypeScript packages share: a Go module has no version file to keep in step, so
scripts/update_versions.py and the version-lockstep check do not cover it.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
omnigent "github.com/sei-protocol/omnigent-go-sdk"
)
func main() {
ctx := context.Background()
client, err := omnigent.New(omnigent.DefaultBaseURL)
if err != nil {
log.Fatal(err)
}
session, err := client.CreateSession(ctx, omnigent.SessionCreateRequest{AgentID: "ag_abc123"})
if err != nil {
log.Fatal(err)
}
// Open the stream before posting: the server buffers nothing for absent
// subscribers, so input sent first can have its early events dropped.
// OnSubscribed runs once the subscription is live, and only once.
opts := omnigent.StreamOptions{
OnSubscribed: func(ctx context.Context, sub omnigent.Subscription) error {
_, err := client.SendMessage(ctx, sub.SessionID, "hello")
return err
},
}
// Deltas are a preview — some harnesses send none — so this loop reads to
// the end of the turn and stops there. It does not treat the turn's
// terminal event as the reply; "Harnesses, deltas, and where the reply is"
// below is why.
var preview strings.Builder
var responseID string
for event, err := range client.Stream(ctx, session.ID, opts) {
if err != nil {
log.Fatal(err)
}
var done bool
switch ev := event.(type) {
case omnigent.OutputTextDeltaEvent:
preview.WriteString(ev.Delta)
case omnigent.ResponseCompletedEvent:
responseID, done = ev.Response.ID, true
case omnigent.ResponseFailedEvent:
responseID, done = ev.Response.ID, true
case omnigent.IncompleteEvent:
responseID, done = ev.Response.ID, true
case omnigent.ResponseCancelledEvent:
responseID, done = ev.Response.ID, true
}
if done {
break
}
}
// Deltas, where a harness sends them, are this turn's by definition, so a
// non-empty preview is already the reply. Otherwise read the item.
text := preview.String()
if strings.TrimSpace(text) == "" {
if text, err = reply(ctx, client, session.ID, responseID); err != nil {
log.Fatal(err)
}
}
fmt.Println(text)
}
// reply waits for this turn's assistant message to appear on the session.
//
// Polling rather than reading once, because the commit is not ordered against
// the terminal event: an immediate read can beat the message onto the session,
// and a single read then reports a turn that worked as one that produced
// nothing. How long to wait is a policy; that there is a bound is not.
func reply(ctx context.Context, client *omnigent.Client, sessionID, responseID string) (string, error) {
pollCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for {
snapshot, err := client.GetSession(pollCtx, sessionID, omnigent.GetSessionOptions{
IncludeItems: omnigent.Ptr(true),
// The liveness lookup is a separate server-side step this has no
// use for, and the poll repeats often enough to decline it.
IncludeLiveness: omnigent.Ptr(false),
})
if err != nil {
return "", err
}
if text := assistantText(snapshot.Items, responseID); text != "" {
return text, nil
}
select {
case <-time.After(250 * time.Millisecond):
case <-pollCtx.Done():
return "", pollCtx.Err()
}
}
}assistantText is the item read.
Authentication is a deployment choice, so the client guesses nothing. Pass
WithAuthHeader for the trusted-proxy identity header (whose name is
configurable — X-Forwarded-Email is only the default), WithBearerToken for
the OIDC and accounts modes' CLI fallback, or WithSessionCookie for a cookie
minted by an interactive login. Applying WithSessionCookie twice appends to the
one Cookie header rather than emitting a second one.
Option is a sealed interface, not func(*Client) error. Sealed so the option
set stays something this package can reason about, and decoupled from *Client so
option state can move into an unexported config later without changing an exported
signature. Third parties compose the With* constructors rather than writing
their own.
A turn's authoritative output is the committed conversation item. The text deltas on the stream are a preview of one, and both halves of that sentence change what a caller has to write.
Deltas are not promised. The platform declares a streaming flag per harness
in the capability table in omnigent/harness_plugins.py, served over
GET /v1/harnesses — a route this package does not call. Read the flag as a
declaration and not a guarantee:
- Verified live. The comment above that table records four harnesses whose
interruptandstreamingclaims the harness bench checks against a real run:claude-sdk,codex,piandopenai-agents. The rest are declared from their integration mode. - Declared false after a live run.
cursor-native,kiro-nativeandqwen-nativedeclare no streaming because a bench run recorded zero text deltas. Onkiro-nativethe whole reply arrives as oneresponse.output_item.done. - Declared true, and still silent in a deployment.
claude-native's deltas come from a Claude CodeMessageDisplayhook appending to a file its forwarder tails, so a host where that hook has not fired streams nothing while the declaration still saystrue.
The item can arrive after the turn ends. On a harness that runs a resident
vendor TUI — the -native family, integration mode native_tui — the reply
reaches the session through a forwarder that polls the vendor's transcript every
0.25s and deliberately holds an assistant message back, for up to 2.0s, until its
forwarded deltas have gone out ahead of it
(omnigent/claude_native_forwarder.py). Nothing orders that post against the
turn's terminal event. case ResponseCompletedEvent: return therefore returns
before the reply exists, and a consumer that does it reads nothing at all and has
no way to tell that from an agent that answered with silence.
Nor is the reply on the terminal event. The server builds that response
object from id, status, model, created_at, error and usage, and does
not set output — although ResponseObject.Output's own description ("empty for
non-completed responses") reads as a promise that a completed one is populated.
Reading it costs a loop over a nil slice and is the right source if that ever
changes; relying on it today is not.
SessionResponse.Harness and AgentObject.Harness name the harness behind a
session or an agent, for a program that has to branch on the family. The loop in
the previous section needs no branch, which is why it does not have one: read the
deltas if they come, and read the item either way.
ConversationItem.Data is a union of eleven payloads and the spec gives it no
discriminator. The value that says which payload it holds is the sibling
ConversationItem.Type, which the generated accessors cannot see: each is
declared on the union field alone and is a plain json.Unmarshal into one
variant's struct.
So a successful AsX() is not evidence the payload was an X. MessageData and
FunctionCallData share no field names, so AsMessageData on a function_call
returns a zero-valued MessageData and a nil error. Gate on the type first, and
treat the accessor as a decoder rather than as a check:
// Newest first: the agent's last message is its answer, and
// SessionResponse.Items is chronological.
func assistantText(items []omnigent.ConversationItem, responseID string) string {
for i := len(items) - 1; i >= 0; i-- {
item := items[i]
if item.Type != "message" || item.Status != "completed" {
continue
}
// A stamp is the server attesting which turn the item belongs to. An
// unstamped item is admitted, because a harness may leave it empty;
// another turn's stamp is not.
if item.ResponseID != "" && responseID != "" && item.ResponseID != responseID {
continue
}
msg, err := item.Data.AsMessageData()
if err != nil || !strings.EqualFold(string(msg.Role), "assistant") {
continue
}
if text := contentText(msg.Content); text != "" {
return text
}
}
return ""
}
// contentText joins a message's text blocks. Content is a list of untyped maps —
// `{"type": "output_text", "text": "..."}` — joined without a separator because
// the server already split one logical message across them.
//
// An allowlist of block types rather than "every block carrying a text key",
// because several block shapes carry text without being the reply: reasoning and
// refusals among them. The failure mode is then "found no text" rather than
// "published the model's private reasoning", and a harness using an unlisted type
// is worth logging rather than silently reading as empty.
func contentText(blocks []map[string]any) string {
var b strings.Builder
for _, block := range blocks {
text, ok := block["text"].(string)
if !ok {
continue
}
switch block["type"] {
case "output_text", "text":
b.WriteString(text)
}
}
return b.String()
}Keying on the decoded value alone — "the role is not assistant, so skip it" —
happens to filter a function_call out today, because the zero value of
MessageData.Role matches nothing. That is an accident, not a check: it stops
holding the moment two variants share a field name, and until then it reads a
payload of the wrong kind without saying so.
Two things the snippet leaves to the caller. Admitting an unstamped item is what
a session outliving one turn has to pay for: on a second invocation the loop can
return the previous turn's reply, so a program that reuses a session has to
record the assistant item ids it had already seen before the turn began and skip
those as well. And three further fields are worth honouring before text is
published anywhere — ConversationItem.CreatedBy is non-nil only for a human
author, MessageData.IsMeta marks context meant for the agent rather than for a
transcript, and MessageData.Interrupted marks a partial reply from a turn that
was cut short. Each is stamped by the server and cannot be forged by a client,
which is what makes them worth reading.
ValidationError.Loc is the other union in the generated surface and does not
have this problem: its two variants are a string and an int, so there a failed
unmarshal is a real answer about which one arrived.
Four rules, each fail-closed rather than best-effort. The package doc's Security section is the long form; this is what changes a caller's code.
- A credential does not go over plain http off this machine.
Newrefuses a plaintext base URL once an auth option is supplied, unless the host is loopback — soDefaultBaseURLkeeps working, andhttp://api.example.testwith a token does not. Where the plaintext hop genuinely is not a network (a sidecar, a port-forward, a mesh terminating TLS ahead of you), say so withWithInsecureCredentialTransport. There is no warn-and-continue: a library has no logger to warn into. - Redirects do not carry it anywhere. Neither
http.Clientfollows a hop off the base URL's host, a step down from https to http, or a method rewrite; all three areErrUnsafeRedirect. Go's own rule strips onlyAuthorization,Cookie,Www-AuthenticateandCookie2on a cross-host hop, which leavesWithAuthHeader's header travelling; it compares hostnames and not schemes; a cross-host 307/308 replays the request body; and a 302 on a POST becomes a GET, so a dropped write would otherwise return 200. Supply anhttp.Clientwith your ownCheckRedirectand you keep yours. - A base URL carries no userinfo.
net/httpwould turn it intoAuthorization: Basicon every request.Newrejects it, and no error fromNewechoes the base URL's password. APIErroris not a way to log a credential.Error()renders the status, the server's code and message, and the request id — never the body, which on a non-2xx may be a proxy's login page rather than this API's.HeaderhasSet-Cookieand theAuthorizationpair removed.
TLS configuration is untouched anywhere in this package: no InsecureSkipVerify,
no RootCAs, no reachable tls.Config. A private CA belongs in the system trust
store, or in a transport you build and pass to WithHTTPClient.
session.heartbeat is two things with one payload: the acknowledgement the
server yields the instant the subscriber slot is registered, and the keepalive
it emits every 15 seconds while a stream sits idle between turns. Nothing on the
wire distinguishes them. "Send when I see a heartbeat" therefore re-sends the
message for as long as the stream stays open — which is why the minimal
invocation above uses StreamOptions.OnSubscribed, called exactly once per
stream regardless of what the frames look like. When the input is known in advance,
SessionCreateRequest.InitialItems is better still: the server queues it at
create time and there is no ordering to get right.
CreateSession takes an agent id, and there is no lookup-by-name route, so a
program that knows a name pages ListAgents until it matches. An id is stable, so
cache it rather than resolving on every run.
func agentID(ctx context.Context, c *omnigent.Client, name string) (string, error) {
var opts omnigent.ListAgentsOptions
for {
page, err := c.ListAgents(ctx, opts)
if err != nil {
return "", err
}
for _, a := range page.Data {
if a.Name == name {
return a.ID, nil
}
}
if !page.HasMore || len(page.Data) == 0 {
return "", fmt.Errorf("no agent named %q", name)
}
opts.After = page.LastID
}
}The loop breaks on two conditions. HasMore is the server's answer; the empty
check is what makes termination the caller's own property, since an empty page
carries an empty LastID and continuing would re-request the first page.
The listing is not only the agents that ship with the server, despite the route
being named for them. It is every agent not scoped to a single session, so
operator-installed ones are included; AgentObject.Builtin is what tells the two
apart.
ListSessions answers the other question. A program that runs repeatedly — one
review per pull request, say — must not create a second session when it already
made one, and a create is the one call this package will not retry, because a
retry after a committed-but-lost response orphans a session. So set a label the
program controls at create time, and find it again by filtering on the agent and
matching the label:
// Returns a matching live session's id, or "" if none is found — in which case
// the caller creates one. An empty result is not the same as "this is the first
// run": the default listing omits archived sessions, so an earlier run whose
// session has since been archived also finds nothing, which is usually what a
// caller wants.
func adopt(ctx context.Context, c *omnigent.Client, agentID, runKey string) (string, error) {
opts := omnigent.ListSessionsOptions{AgentID: agentID}
for {
page, err := c.ListSessions(ctx, opts)
if err != nil {
return "", err
}
for _, s := range page.Data {
if s.Labels["run-key"] == runKey {
return s.ID, nil // adopt it instead of creating another
}
}
if !page.HasMore || len(page.Data) == 0 {
return "", nil
}
opts.After = page.LastID
}
}This has to page, and the loop above is the whole point of the section. There
is no server-side label filter, so the label is matched client-side, and a page
holds 20 of that agent's newest sessions by default. A program on its tenth run
will not find its own earlier session on the first page — it would conclude none
exists and create the duplicate the label was there to prevent. Stopping at
page.Data is the mistake this recipe exists to avoid.
Filter on AgentID rather than AgentName: an agent can be renamed, and a
program holding the old name silently starts matching a different agent or none.
Setting both is not a fallback — the server applies them as two conditions on the
same column, so naming two different agents matches nothing.
An agent that needs a decision parks its turn and publishes
ElicitationRequestEvent. Nothing advances until a verdict arrives, so an
unattended program has to answer these. The only thing bounding the stall is the
agent spec's own ask_timeout, which the deciding policy may override and which a
client neither sets nor sees; when it expires the server treats the prompt as
refused.
for ev, err := range c.Stream(ctx, sessionID, omnigent.StreamOptions{}) {
if err != nil {
return err
}
req, ok := ev.(omnigent.ElicitationRequestEvent)
if !ok {
continue
}
// What the prompt is asking for is in req.Params. Deciding from it is the
// program's job — approving unconditionally is not a default, it is a policy,
// and the paragraph below is why. Declining is the safe direction.
verdict := omnigent.ElicitationDecline
if allowed(req.Params) {
verdict = omnigent.ElicitationAccept
}
if _, err := c.ResolveElicitation(ctx, sessionID, req.ElicitationID,
omnigent.ElicitationResult{Action: verdict}); err != nil {
return err
}
}One case of a loop, not a whole one: a real consumer still ends its turn on the terminal event and reads the reply from the item, as the minimal invocation does.
Accepting is privileged differently from refusing, which is why it is worth a
policy rather than a constant. It authorises the pending tool to run with the
session owner's execution identity, so the server requires approval access for
accept specifically and answers ErrForbidden to a caller that only holds edit
access — which can still decline or cancel. A program that can stop an action
therefore cannot necessarily permit one, and an unattended program that accepts
every prompt has granted the agent whatever it thought to ask for.
After a reconnect, prompts raised while nobody was subscribed are not replayed on
the stream; they are on the snapshot, as SessionResponse.PendingElicitations.
The server also has a dedicated resolve route, which this package does not call.
It is registered include_in_schema=False for an internal flow where the prompt
carries that route's own URL for the client to hit directly, and its own
documentation names the approval event as the equivalent path with identical
semantics — both reach the same server-side resolver. One write route is enough.
doc.go # package documentation: quickstart, harnesses, items, security, timeouts
client.go # Client, New, options, redirect policy, the shared request path
errors.go # *APIError and the errors.Is sentinels
session.go # create / get / delete / send / approve / interrupt, and the 4 hand-written types
list.go # the two cursor-paginated listings and the shared Page[T]
event.go # the sealed Event interface and UnknownEvent
stream.go # the SSE reader
models.gen.go # GENERATED from openapi.json — do not edit
events.gen.go # GENERATED from openapi.json — do not edit
oapi-codegen.yaml # generator config
LICENSE # Apache-2.0, verbatim from the repository root
NOTICE # ditto
bin/check.sh # gofmt, build, vet, race tests, go mod tidy -diff
LICENSE and NOTICE are copies rather than an oversight. A nested Go module is
distributed as a zip of the files under its own directory — golang.org/x/mod/zip
is what cmd/go builds one with, and it takes nothing from a parent — so the
repository root's copies do not travel with the module. Without them the module is
not redistributable and pkg.go.dev renders no licence, which is how it decides
whether to publish documentation at all. Keep both byte-identical to the root's;
TestModuleShipsItsOwnLicence fails if they drift.
One flat package, and deliberately no internal/. The sealed Event interface
needs its marker method declared alongside the generated event structs, and Go
forbids declaring a method on a type imported from another package — so splitting
models out would make the sealed interface impossible and degrade the 52 event
types to any.
models.gen.go and events.gen.go come from the repository's openapi.json:
just go-sdk-gen # or: python scripts/gen_go_client.py
just go-sdk-testIn a temp directory that is never committed, the generator narrows the document
to the schemas this package's API is built from, downgrades what is left into the
OAS 3.0 dialect oapi-codegen's loader accepts, and then checks every surviving
schema keyword against an allowlist of what the downgrade handles. An unlisted
keyword is a build failure, so a new JSON-Schema construct stops the build rather
than quietly degrading a Go type. The allowlist constrains keywords, not values:
a new format, or a genuinely heterogeneous anyOf, still generates whatever
oapi-codegen makes of it.
Three more transformations run after that check, so the allowlist keeps
constraining what openapi.json says and not what the generator adds:
-
Event names are disambiguated. Five trailing names are published under two
typeprefixes each —response.heartbeatandsession.heartbeat,response.createdandsession.created, andresponse.*againstturn.*for the three terminal states — and the spec prefixes only one member of each pair. The bare name therefore belonged to whichever the spec left unprefixed, which for the heartbeat is the wrong one: the heartbeat a caller actually sees issession.heartbeat. Every member of a colliding group is now namespaced from its own wire type (ResponseHeartbeatEvent,SessionHeartbeatEvent), the rule is collision-driven rather than a list, and each event's doc states its wire type verbatim. Unambiguous names are left alone, including deliberately nicer ones likeToolOutputDeltaEvent. -
Descriptions are sanitised.
openapi.json's prose is written for the people who maintain the server, so it cites emit sites, private attribute names and — in four example paths — a real home directory, andoapi-codegencopies it into Go doc comments that pkg.go.dev publishes. Those references are redacted and the prose around them is kept; an unfamiliar variant of one of those three shapes fails the build rather than reaching the docs. Public wire-level names that merely contain an underscore (last_event_seq, theomnigent.codex_native.*label keys) are untouched.What the sanitiser does not cover is a dotted lowercase module path, and the reason is that it cannot be recognised by shape:
omnigent.runtime.pending_inputsand the wire-visible label keyomnigent.codex_native.collaboration_modeare the same shape, so redacting the first without knowing which names the server publishes would also redact the second. Five such paths survive, in ten doc comments —omnigent.server.schemas.ResponseObject(3),omnigent.runtime.pending_elicitations(3),omnigent.runtime.pending_inputs(2),omnigent.server.schemas.SessionEventInputand.ElicitationResult. Three name a server-side schema class that models a payload this package already exposes as a Go type, so they are noise; the twoomnigent.runtime.*ones name in-process server state a client cannot reach at all. None affects the wire contract, and the fix is a substitution table mapping each path to what it denotes rather than a wider pattern — deleting these references leaves sentences whose subject was the path. -
Optional collections are nil, not pointers. Every array- and map-valued schema opts out of
oapi-codegen's optional-field pointer, soSessionResponse.Itemsis[]ConversationItemrather than*[]ConversationItem. 23 fields had the pointer. Struct-valued fields keep theirs, where nil genuinely distinguishes absent from zeroed.
Generating all 262 schema types exported an API nobody chose: 124 enum constants
at package scope with names like URL, File and Input, 46 types named after
FastAPI's path-mangled operationIds
(UpdateMcpServerV1SessionsSessionIDAgentMcpServersServerNamePutJSONRequestBody),
and a ServerStreamEvent union that was a second public representation of what
Event already models. What is generated now is the $ref closure of the
schemas this package's own signatures name — SessionResponse,
ConversationDeleted, SessionGitOptions, ValidationError — plus every member
of the event union, with always-prefix-enum-values so no enum constant occupies
a bare word at package scope. That is 322 exported package-scope identifiers
down from 424, and models.gen.go from 6,894 lines to 4,118.
The github.com/oapi-codegen/runtime dependency survives the narrowing: two
schemas inside the kept closure are genuine JSON-Schema unions —
ConversationItem.data (one of eleven item payloads) and ValidationError.loc
(a list of string-or-int) — and the generated accessors for those call
runtime.JSONMerge. Dropping ServerStreamEvent removes 52 accessor methods
over a json.RawMessage, not the module's only external dependency.
Narrowing does not weaken the drift gate. The roots are looked up by name and a
missing one fails the build; the event roots come from the union's own
discriminator.mapping, so a new variant is picked up automatically; and the
closure follows $ref, so a schema gaining a field or a reference to a new
schema still moves the generated bytes. What a spec change can no longer do is
force a Go type on this module for a route it does not implement.
Generated code is committed because go get fetches a module as source with no
build step: an ungenerated module simply does not compile for consumers. The
go-client-fresh pre-commit hook pins both files byte-for-byte to
openapi.json, which tests/server/test_openapi_drift.py in turn pins to the
live server. A route change that is not propagated fails at one of those two
links. The hook fails when oapi-codegen is absent instead of skipping;
OMNIGENT_SKIP_GO_CLIENT_CHECK=1 downgrades that to a warning on a machine with
no Go toolchain, and CI ignores the variable.
Four types are hand-written because the server documents neither their routes
nor their schemas: SessionCreateRequest (the create route takes a raw request
and dispatches on Content-Type, so no requestBody is emitted), and
SessionEventInput, EventAccepted and ElicitationResult (the send route is
registered with include_in_schema=False, so neither its body nor its responses
appear in the spec). None of the four is covered by the drift gate, so a
server-side change to any of them breaks this client silently.
bin/check.sh — gofmt, go build, go vet, go test -race,
go mod tidy -diff — is what just go-sdk-test runs and what the required
Pre-commit checks job runs, so the two cannot drift. It lives inside that job
rather than a workflow of its own because
.github/scripts/merge-ready/required.sh is a generated file: a new workflow's
check name cannot be added to the required set from a PR, and an ungated check
rots. .github/workflows/go-sdk.yml adds golangci-lint and a two-version test
matrix; it is advisory until a maintainer adds its check names to whatever
generates required.sh.
- Cancelling a stream does not stop the agent. The stream is a subscriber;
the turn runs server-side regardless of who is listening. Real cancellation is
Client.Interrupt. - A stream that ends is not a turn that finished. Turn completion is a
ResponseCompletedEventand its siblings. A clean stream end means the server closed the subscription, which in practice means it is shutting down. - A turn that finished is not a reply you have. The terminal event carries no output text and the committed item may not exist yet — see "Harnesses, deltas, and where the reply is".
- Unary calls and the stream have separate timeouts. A whole-exchange
deadline would sever a healthy stream, so the streaming client carries none and
its liveness comes from the idle watchdog instead.
WithUnaryTimeoutmoves the non-streaming one; the package doc's Timeouts section says what it defaults to and why it has to be that large. - In-stream errors are events, not errors.
ErrorEventis non-terminal andRetryEventis informational; neither ends the subscription. - There is no resume. On
ErrStreamInterruptedorErrStreamIdle, fetch the snapshot withGetSession, open a fresh stream, and dedupe persisted items by id. Some deployments cap stream duration at a few minutes, so this is routine. - The idle timeout bounds transport silence, not your handler. The watchdog is suspended while the loop body runs, so a slow event handler cannot be mistaken for a dead server.
SequenceNumberis not a cursor. It is nil on everysession.*event and restarts each turn on the others. Order by arrival.- A frame is bounded, not just a line.
bufio.Scannercaps one line; the accumulateddata:payload is capped too, so a server that never sends the blank line ending a frame fails withErrStreamFrameTooLargeinstead of growing the heap. - The idle watchdog reads the monotonic clock only. A clock step or an NTP correction cannot cancel a healthy stream or hide a dead one.
Stream returns an iter.Seq2[Event, error], which keeps errors and
cancellation on the caller's goroutine and makes a leak structurally impossible —
nothing is spawned, so there is nothing to leak. A <-chan Event plus an Err()
accessor is the pre-Go-1.23 equivalent and remains the fallback if the module's
floor ever has to drop below 1.23; it is recorded here so the choice is not
relitigated.