feat(sdks): add a Go client for the session API - #4010
Conversation
2ac9303 to
1ba1a15
Compare
CI stateEverything that can go green is green, including the three jobs this PR adds: Two checks are red and both are maintainer gates rather than problems with the change: Security Scan fails only at the Sensitive-path guard. Secret scan and exfil scan both pass; the guard fires because the PR adds Maintainer Approval is the standard external-contributor gate. One thing worth flagging from the scanThe exfil scan initially blocked this PR on a false positive, which I fixed in the change rather than by asking for a policy change. Recording the mechanism because it will recur:
Paired in one file, that is the blocking shape. The fix here was to rename the literals to The |
|
@bdchatham This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder. These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:
Use |
1ba1a15 to
194e5f4
Compare
Scope update: the two lookups and approvals are now inPushed before any review started, so nothing here invalidates a read in progress. The description is updated in full; summarising what moved: The client could drive a session it was handed the id of, and little else. Three things were missing and each is load-bearing for an unattended Go caller:
Two decisions worth a reviewer's attention, both taken conservatively: Approvals ride the existing write route, not the dedicated one. The listing enums are typed but not validated client-side.
CI is where it was: everything green except the Sensitive-path guard (this PR adds |
sdks/ has a Python client and a TypeScript UI package. A Go caller has no
supported way to talk to the session API and has to hand-roll an HTTP client and
an SSE parser, and the SSE half is where the sharp edges are: 52 event types
behind one `type` discriminator, a 15s keepalive that is not progress, and a
stream whose framing has to be read incrementally.
This adds sdks/go-client, a module with a hand-written surface over generated
types.
What is hand-written and what is generated
------------------------------------------
The verbs, the stream, the errors and the option type are hand-written, about
2,100 lines: a Go caller expects a Go-shaped API, and a fully generated client
would expose FastAPI's path-mangled operationIds as method names. The schema
types are generated from the checked-in openapi.json by
scripts/gen_go_client.py, so a spec change moves the Go types and the drift test
already pinning openapi.json to the live app keeps them honest.
Generated code is checked in because `go get` fetches a module as source with no
build step, so an ungenerated module does not compile for a consumer.
The stream is a range-over-func iterator
----------------------------------------
Events(ctx, id) returns iter.Seq2[Event, error], so a caller writes
for ev, err := range client.Events(ctx, sessionID) { ... }
and `break` cleans up. There are no goroutines in the stream path: an iterator
that owns no background work cannot leak one, and the caller's `break` is the
only teardown signal needed. The Event interface is sealed by an unexported
method, so the type switch over a server event is exhaustive by construction and
adding a variant upstream cannot silently fall through a caller's switch.
Events carries two http.Clients. Timeout on an http.Client covers reading the
body, which for a stream means it is a deadline on the whole conversation rather
than on getting a response, so the streaming client sets no Timeout and relies on
an idle watchdog instead. Unary calls keep the timeout.
A heartbeat is not progress. The server emits session.heartbeat every 15s to hold
the connection open, and it is also the ready signal on a different path, so the
doc and the README both say a caller must not treat it as a turn boundary.
The two lookups a program needs first
-------------------------------------
CreateSession takes an agent id and there is no lookup-by-name route, so
ListAgents is how a caller holding a name finds the id. The route is named for
built-in agents and returns more than those: it filters session_id IS NULL, so
operator-installed agents are included and AgentObject.Builtin distinguishes
them.
ListSessions is how a program that runs repeatedly finds a session it created
earlier instead of creating a second one. That matters because create is the one
call this package will not retry: a retry after a committed-but-lost response
orphans a session. Set a label at create time and filter by AgentID to find it
again.
Both page by opaque cursor into a shared Page[T], because the server shares the
envelope. Every option is omitted when unset rather than sent as a zero value,
and that is load-bearing: the server validates order, sort_by and kind against
patterns and limit against 1..1000, so forwarding Go's "" or 0 would 422 a caller
who expressed no preference. The enums are typed so the values are discoverable,
but membership is not checked client-side -- the server's pattern is the
authority, and a hardcoded copy would reject a value a newer server accepts.
Answering an approval prompt
----------------------------
An agent that needs a decision parks its turn and publishes an elicitation.
Nothing advances until a verdict arrives, so an unattended caller has to answer
or the session stalls until the server times the prompt out.
The verdict is an input on the route this package already writes to, not a new
endpoint. The server does have a dedicated resolve route and this client does not
call it: that route is registered include_in_schema=False as an internal flow for
prompts published in url mode, and its own documentation names the
type == "approval" event as the equivalent path with identical semantics. One
write route is enough.
Accepting is privileged differently from refusing: it authorises the pending tool
to run with the session owner's execution identity, so the server demands
approval access for accept specifically and answers 403 to a caller holding only
edit access, which can still decline or cancel.
Credentials and redirects
-------------------------
The client refuses to carry a credential where it should not go. Go's own
redirect rule strips Authorization, Cookie, Www-Authenticate and Cookie2 on a
cross-host hop, which leaves a custom identity header travelling to whatever host
the response named; it compares hostnames and not schemes, so an https-to-http
hop keeps the credential and loses the encryption; a cross-host 307 or 308
replays the body, which here is the caller's prompt; and a 302 on a POST becomes
a GET, so a dropped write would return 200. All of those, plus a chain that never
terminates, are ErrUnsafeRedirect. A caller supplying its own CheckRedirect keeps
it and owns that.
Plain http with a credential is rejected unless the host is loopback or the
caller passes WithInsecureCredentialTransport, and a base URL carrying userinfo
is rejected rather than quietly becoming Basic auth on every request. No error
from New echoes the base URL's password.
APIError.Error renders the status, code, message and request id, never the body:
a non-2xx body need not come from this API at all, and an auth proxy can put a
CSRF token or an echoed request header in it. Body stays on the struct for the
caller who wants it.
Four types are hand-written because the server documents neither their routes nor
their schemas, so nothing exists to generate them from and no drift gate covers
them: SessionCreateRequest, SessionEventInput, EventAccepted and
ElicitationResult. The README and doc.go both say so.
CI
--
.github/workflows/go-sdk.yml builds, vets, lints and race-tests the module.
lint.yml gains a go-client-fresh check that regenerates the bindings and
compares, so editing openapi.json without regenerating fails rather than shipping
stale types. It fails closed when oapi-codegen is absent. dependabot watches the
nested module for security updates only.
255 test cases, race-tested. The README's three lookup-and-approval recipes were
compiled verbatim in a fresh module, as its quickstart was.
The listings went through an independent four-lens review before this landed --
systems, security, idiom and prose, briefed blinded with one assigned to argue
the design was wrong. It found two defects in the documented recipes, both since
fixed and both now covered by a regression test: the paging loop could not
terminate independently of the server's good behaviour, and the session-reconcile
recipe read only the first page, so a program with more sessions than one page
would create the duplicate that recipe exists to prevent. Neither was a defect in
the code the recipes drive. Nothing outside
sdks/, scripts/, tests/scripts/ and those CI files changes; sdks/README.md
loses one line that said the directory holds Python packages.
Signed-off-by: bdchatham <bdchatham@gmail.com>
194e5f4 to
0417b51
Compare
Correction: two defects in the documented recipes, found by review and fixedI put the listings through an independent multi-lens review after pushing them, which was the wrong order — the two defects below were live on this PR for a few hours. No review had started, so nothing is wasted, but I'd rather say that plainly than quietly force-push. Four reviewers, briefed independently on the same diff, one of them explicitly assigned to argue the change was wrong. Two blocking findings, both in prose rather than code: 1. The paging loop in The root cause was a sentence I had written on 2. The session-reconcile recipe in the README read only the first page. Both fixes carry a regression test, and both tests are mutation-proven rather than merely green: reverting the guard makes the first fail, reverting to a single-page scan makes the second fail. Smaller items from the same review, all fixed:
Four other findings were raised and I did not act on them, because each turned out to be wrong when checked against the server rather than reasoned about — recorded here so the checks are visible: Two items are deferred rather than dropped, both pre-existing or generated:
Verification on the pushed state: |
…/robustness defects A parity audit against sdks/python-client, with the server source and the spec as ground truth, found six things. Every fix below was justified by reading the server rather than by reasoning from the SDK's own description of it — because three of the six findings were that the SDK's description is wrong. The documentation defects are first because they are the consequential ones. A consumer followed this SDK's guidance into misdiagnosing a deliberate server-side filter as a platform defect, and lost days to it. - The documented turn lifecycle began with response.created. That event never reaches a subscriber: it is filtered at the publish chokepoint in runner/app.py. The lifecycle now describes what a subscriber actually sees. - The replay-prologue description was wrong for a terminal-pane harness, which carries deltas rather than an envelope. - The canonical event loop in doc.go and the README returned on the terminal event. For a terminal-pane harness the authoritative transcript item is posted by a poller and can arrive afterwards, so that loop gets nothing. The example now works for both harness families, and the relationship between an agent's harness and whether it streams at all is documented where a reader meets it before writing a loop. Also documented: the item-data union is an anyOf of eleven variants with no discriminator, so a generated AsXxxData accessor succeeds on the wrong variant and returns a zero value. Callers must gate on the sibling type field. That is a spec-level defect every generated client inherits; documenting it is the only fix available here. The behavioural defects: - The default whole-exchange timeout was 30s against a server that legitimately takes up to ~65s on a message forward and ~40s on a create. Now 90s, with an exported option. Two things about this were not obvious: the audit's own estimate of ~35s was low, because the generic runner forward carries a 60s read budget the audit had not cited; and raising the whole-exchange bound alone would have been a no-op, since the transport's response-header timeout was a fixed 30s and binds first on exactly these routes. - Elicitation resolution ignored the target session id, so a mirrored sub-agent prompt was accepted and never resolved. It redirects now. Stated honestly as "can silently fail" rather than "always fails", because the server still forwards to the bound runner on a mismatch. - The stream idle watchdog reset per complete line, contradicting its own doc. Since one frame is one line, that bounded a whole frame's transfer by the idle timeout, so a healthy stream delivering a large snapshot frame slowly could be torn down. It now measures silence, which is what the doc claimed. - A single undecodable frame ended the subscription. It is skippable now, with the raw payload surfaced. Reachability is nil against this server and the comment says so rather than implying a live bug — every event is validated through the same union before serialization. A guard keeps this from reintroducing the reference client's silent-empty-stream mode: skipped frames with no delivered event is an error, not a clean end. New capability: the paginated items route. The session snapshot is capped at the newest 100 items server-side, and that cap truncates the recovery path this package's own documentation prescribes. Deliberately unchanged: the strict 200-only stream gate, 2xx-only unary gate, [DONE] handling, multi-line data rejoin, frame size bounds, credential redaction, plaintext-credential refusal and the redirect policy. The audit found several of these stricter than the reference, and one of them is why a proxy answering 302 cannot give this client the silent zero-event stream it would give the reference. Signed-off-by: bdchatham <bdchatham@gmail.com>
ghoshp83
left a comment
There was a problem hiding this comment.
Read through the hand-written surface (client.go, stream.go, event.go) at 04c9e71 — not the generated files. This is unusually careful work: the monotonic-clock idle watchdog that re-reads a timestamp instead of trusting Timer.Reset, bounding the frame and not just the line so a server that never closes a frame can't grow the heap, dispatching on the payload's own type rather than the event: line, and the UnknownEvent forward-compat path are all the right calls. A few things I'd raise, one of them security-relevant.
-
checkRedirect doesn't reject userinfo in the redirect target, though New carefully rejects it in the base URL. New refuses a base URL with userinfo precisely because "net/http turns userinfo into an Authorization: Basic header on every request" — but on a redirect, req.URL is parsed from Location, and a same-host Location:
https://evil:pw@host/…passes all three checkRedirect gates (sameEndpoint compares only hostname/port; scheme unchanged; method unchanged). net/http then synthesizes Authorization: Basic … from that userinfo on the replayed request — but only when no Authorization header is already set, so WithBearerToken callers are safe while WithAuthHeader (trusted-proxy) and WithSessionCookie callers would have an attacker-chosen Basic credential attached. It's the same silent-Basic-auth vector New exists to close, reintroduced one layer down, and it fits this PR's own threat model (an untrusted Location) exactly as much as the off-host and downgrade cases you do guard. redirect_test.go doesn't seem to cover it. Suggest a case req.URL.User != nil: arm in checkRedirect returning ErrUnsafeRedirect, mirroring New. -
The method-rewrite gate also rejects a spec-legitimate 303. case req.Method != origin.Method treats any method change as unsafe — correct for a 307/308 that would silently drop the body (nicely tested), but a 303 See Other defines a POST→GET change and isn't a dropped-write bug. If the server never legitimately answers a POST /events or session-create with a 303, this is the right fail-closed default and worth a one-line comment saying so; if it might, this would refuse a valid response. Which is it?
-
Minor: sameEndpoint hostname comparison is case-sensitive. origin.Hostname() != next.Hostname() would treat a same-host redirect that differs only in DNS case (Host vs host) as off-host and block it. Fail-closed, so not a risk — just a possible surprise on an otherwise-legitimate redirect; a strings.EqualFold would match the case-insensitivity you already apply in isLoopback.
Nothing here is a blocker — the first comment is the only one I'd call load-bearing. Happy to send a PR against your branch for the userinfo case with a test if that's useful.
Review feedback from @ghoshp83 on omnigent-ai#4010. All three points taken. The load-bearing one: checkRedirect did not apply the rule New applies. New refuses a base URL carrying userinfo because net/http turns it into an Authorization: Basic header — and a server-controlled Location was not held to the same standard. A same-host https://user:pw@host/… cleared every existing gate, since sameEndpoint compares hostname and port, the scheme was unchanged and the method was unchanged. net/http then synthesizes Basic from that userinfo onto the replayed request, but only when no Authorization header is already set: so a bearer-token caller was incidentally safe while WithAuthHeader and WithSessionCookie callers would have carried an attacker-chosen credential to the real server. Same vector New exists to close, one layer down, and squarely inside this package's stated threat model of an untrusted Location. Rejected rather than stripped, on the same principle as the off-host case: a response is not entitled to name a credential any more than it is entitled to name a different host. The test uses WithAuthHeader deliberately. A bearer caller is safe by accident here, so a test written that way would pass with the fix reverted and prove nothing. On the 303 question: this server never answers 303. There is no See Other anywhere in it, and its only redirects are 302s on the browser login routes, which an API client does not call. So the method gate stays fail-closed and now says why, rather than leaving a reader to wonder whether a legitimate POST→GET was being refused. sameEndpoint now folds case when comparing host names, matching what isLoopback already did. DNS is case-insensitive, so the old comparison would have refused a legitimate same-host redirect differing only in case — fail-closed, so a refused redirect rather than a leak, but still wrong. Both new guards are mutation-proven: reverting each fails its own test and nothing else. Signed-off-by: bdchatham <bdchatham@gmail.com>
|
Thanks — all three taken, pushed as 1. userinfo in the redirect target — fixed, and you're right that it was the load-bearing oneYour read is exactly right, including the part that makes it subtle: net/http only synthesizes Added the The test is worth a look because the obvious version of it is useless: it uses 2. The 303 question — the server never answers oneChecked rather than assumed. There is no Worth noting the gate earns its keep beyond 307/308: a 3. Host case — fixed
On your offerAppreciated, and please do send PRs against the branch whenever it's faster for you than a comment — you clearly have context on this code that I don't. This one was small enough that a round trip seemed worse than just doing it. One thing you may want to know, since it came out of the same audit that produced this PR's other commit: I compared this client against |
ghoshp83
left a comment
There was a problem hiding this comment.
Verified the fix at 8324a5e, not just the writeup — all three are in and correct.
The userinfo arm lands where it should (after the scheme-downgrade gate, before the method check), and the test is the right version of the test, which is the part I care about most: WithAuthHeader, not WithBearerToken, and it asserts the moved endpoint was never reached — so it fails if the arm is reverted and would prove nothing written the naive way. That's the whole finding, closed properly.
On the 303: agreed, and the comment is the right resolution — the value was never in changing behaviour, only in a future reader not having to re-derive whether a legitimate POST→GET was being refused. Same for the EqualFold host-case fix.
Approving. Scope: I read the hand-written surface (client.go, stream.go, event.go) and this fix; I haven't audited the generated types or the e2e harness, so this is an approval of the credential-redirect policy and the SSE/dispatch path, not a line-by-line of all 34 files.
On the python-client differences you found — yes, worth writing up, and I'm happy to take them since they're out of scope here. The unreachable [DONE] check, and the follow_redirects=False + status<400 path that turns a proxy's 302 into a silent zero-event stream, are both real silent-failure bugs; I'll open them separately against main with tests so this PR stays focused.
|
Hi @bdchatham, Thanks for the contribution but I think we probably don't want to take this at the moment. We do understand the need of having a Go SDK but the team lacks expertises in Go so we can't be maintaining this code effectively to ensure its quality. Do you think you can start an Thank you! |
|
Thanks for the feedback @fanzeyi - here is the repo where I'll be managing our Go SDK implementation: https://github.com/sei-protocol/omnigent-go-sdk |
|
Closing this in favour of https://github.com/sei-protocol/omnigent-go-sdk, per the suggestion that it live in our organisation rather than here. The code is seeded there at @ghoshp83 — thank you for that review; it was the most useful one this code got, and all three points landed:
One thing you may want regardless of this PR closing, since it is your code and not ours. Comparing this client against
Happy to write those up as an issue here if useful — they are not this PR's business, and they are worth someone's attention independently of where the Go client lives. |
|
Congrats on the new home for it — nice that it landed at On the python-client differences you flagged: since they're independent of where the Go client lives, I'll take the redirect one — opening a PR against omnigent shortly that makes the stream open fail loud on a 3xx instead of decoding it as an empty stream, with a regression test. |
The SDK's httpx.AsyncClient uses httpx's default follow_redirects=False, and the stream-open guard only rejected status >= 400. A proxy or gateway answering a 3xx (e.g. 302) on the stream request therefore fell through to the SSE parser, which found no data: frames in the redirect body and yielded nothing — handing the caller a silent, error-free, zero-event stream instead of surfacing the connection that never reached the server. _stream_session_events already documents ":raises OmnigentError: If the server returns a non-2xx status", so this is the redirect half of a contract the rest of the sessions namespace keeps. Reject a 3xx on the stream open at both sites — SessionsNamespace.stream (via _stream_session_events) and the deprecated /v1/responses stream — raising OmnigentError rather than decoding an empty stream. Adds a regression test that returns a 302 from an httpx.MockTransport and asserts the open raises instead of completing with zero events; it fails against the old >= 400 guard. Spotted by @bdchatham while comparing this client against the Go client during review of omnigent-ai#4010. Signed-off-by: ghoshp83 <pralay.ghosh@gmail.com>
Related issue
Closes #4009
Summary
sdks/has a Python client and a TypeScript UI package, so a Go caller hand-rolls an HTTP client and an SSE parser. The SSE half is where the sharp edges are, and this module puts them in one place.openapi.jsonviascripts/gen_go_client.py, so a spec change moves the Go types and the drift test already pinningopenapi.jsonto the live app keeps them honest.iter.Seq2[Event, error]with no goroutines in the stream path, andEventis sealed by an unexported method so a caller's type switch is exhaustive by construction.ErrUnsafeRedirect.ListAgentsturns an agent name into the idCreateSessionwants, andListSessionsfinds a session an earlier run created instead of creating a second one. Both page by cursor into a sharedPage[T].ELI5
The server can talk to you in a long stream, like a phone call where it keeps saying things until the work is done. Writing the listener for that call is fiddly. There are 52 different things it can say, it says "still here" every 15 seconds which does not mean it finished, and the obvious way to set a time limit in Go accidentally hangs up mid-sentence because the limit covers the whole call rather than just waiting for someone to pick up.
This adds a listener that already knows all of that, so a Go program writes an ordinary
forloop and gets the events. It also refuses to shout your password across the street: if the server answers "go ask that other host instead", or "same host but drop down to unencrypted", the client stops instead of taking your credential along.How it works
Unary calls use a second
http.Clientthat does setTimeout. That split is the point:Timeoutcovers reading the body, so on a stream it is a deadline on the entire conversation rather than on getting a response, and a long turn would be cut off mid-flight.session.heartbeatis doubly-used upstream — the 15s keepalive and a ready signal on a different path share one payload — so it is surfaced rather than swallowed, and both the package doc and the README say a caller must not read it as a turn boundary.On redirects, Go's own rule strips
Authorization,Cookie,Www-AuthenticateandCookie2on a cross-host hop, which leaves a custom identity header travelling to whatever host the response named; it compares hostnames and not schemes, so an https-to-http hop keeps the credential and loses the encryption; a cross-host 307/308 replays the body, which here is the caller's prompt; and a 302 on a POST becomes a GET, so a dropped write would return 200. Those four plus a chain that never terminates areErrUnsafeRedirect. A caller supplying its ownCheckRedirectkeeps it and owns that.The two lookups, and approvals
CreateSessiontakes an agent id and there is no lookup-by-name route, soListAgentsis how a caller holding a name finds the id. Worth knowing: that route is named for built-in agents and returns more than those. It filterssession_id IS NULL, so operator-installed agents are included too, andAgentObject.Builtinis what distinguishes them.ListSessionsanswers the other question. A program that runs repeatedly must not create a second session when it already made one, and create is the one call this package will not retry — a retry after a committed-but-lost response orphans a session. So set a label at create time and filter byAgentIDto find it again.Both listings omit every option that is unset rather than sending a zero value, which is load-bearing rather than tidy: the server validates
orderagainst^(asc|desc)$,sort_byagainst^(created_at|updated_at)$,kindagainst^(default|sub_agent|any)$andlimitagainst1..1000, so forwarding Go's""or0would 422 a caller who simply expressed no preference. The enums are typed so the values are discoverable, but membership is deliberately not checked client-side — the server's pattern is the authority, and a hardcoded copy here would reject a value a newer server accepts. That matchesSendInput, which checks itsTypeis non-empty and leaves the vocabulary to the server.Page[T]is one type rather than two because the server shares the envelope: both routes return the same four fields around a differently-typeddata.AgentObjectis typed as the element even though the route's declared response is the sharedPaginatedList, whosedatais untyped because it is reused across routes returning different things — this route builds its page out ofAgentObject, so typing it saves every caller an[]anyre-decode.Approvals go on the route this package already writes to, not a new one. There is a dedicated
POST /sessions/{id}/elicitations/{id}/resolveand this client does not call it: it is registeredinclude_in_schema=Falseas an internal flow for prompts published inurlmode, and its own docstring names thetype == "approval"event as the equivalent path with identical resolution semantics. One write route is enough. Accepting is privileged differently from refusing — it authorises the pending tool to run with the session owner's execution identity, so the server demands approval access foracceptspecifically and answers 403 to a caller holding only edit access, which can stilldeclineorcancel.Test Plan
Covered: the four redirect refusals and that a caller's own
CheckRedirectsurvives; loopback and case-folded loopback accepted over plain http while a remote host with a credential is refused; userinfo in the base URL rejected and no password echoed in the error;Timeoutsplit between the two clients; the idle watchdog firing on real silence and not on a heartbeat; incremental SSE framing including a frame split across reads and a line-length bound; sealed-union dispatch over all 52 variants plus an unknowntype;APIErrornever rendering the body, and the header stripped ofSet-Cookie/Set-Cookie2/Authorization; path-segment escaping (GetSession(ctx, "..")cannot walk); and the generator's fail-closed paths (an unhandled JSON-Schema construct, a residual leak, a missingoapi-codegen, and CI being unable to skip the freshness check).Also covered: query encoding for both listings, including the zero-value and false-boolean omissions and query escaping; page decoding for both element types and for an empty page; the paging loop the
Pagedoc prescribes, executed against a two-page server so the documented recipe runs rather than only being described; the approval payload through JSON, so an unsetcontentis absent rather thannull; the three incomplete verdicts refused before a request is sent; and that a verdict reaches the events route rather than the hidden one.AgentObjectandSessionListItemjoin the pointer-to-collection invariant's roots. Neither was reachable from the previous four, so it did not cover them; both carry collections a caller ranges over. Verified by mutation — makingAgentObject.Skillsa*[]SkillSummaryfails that test with the field named.The README quickstart and its three lookup-and-approval recipes were compiled verbatim in a fresh module.
Two of those cases exist because an independent review of this increment found defects in the documented recipes, and both are mutation-proven rather than merely present: reverting the paging guard makes
TestDocumentedWalkTerminatesOnAnEmptyPagefail, and reverting to a single-page scan makesTestAdoptRecipeFindsASessionBeyondTheFirstPagefail. See the note below.Demo
N/A — no UI surface.
Type of change
Test coverage
Coverage notes
Manual verification was the two README quickstarts, compiled verbatim in a fresh module outside the repo, to confirm the documented entry point actually builds against the published surface.
There are no integration or E2E tests against a live server. Everything is exercised against
httptestservers, including the streaming and redirect paths. Wiring this into the existing E2E suite would mean standing up the Go toolchain in that job, which seemed worth a maintainer's call rather than assuming.Surface a reviewer should weigh in on before a v1 tag
Naming and layout are one-way doors once the module is tagged, so these are called out rather than settled unilaterally. All are cosmetic or additive, none affects the wire contract.
54 of 67 generated enum types hold exactly one value (99 constants total). They are the per-event discriminators: each event's
typeis a fixed string, so the generator emits a one-value enum such asSessionChangedFilesInvalidatedEventType. They are harmless and they are noise. A generator rule could drop a single-valued enum in favour of the string, at the cost of the constant a caller might reasonably want to compare against.Long three-noun type names, up to 39 characters (
SessionChangedFilesInvalidatedEventType). Inherited from the schema names; shortening them means diverging from the spec.SessionResponseis the snapshot type, and it is whatCreateSessionandGetSessionboth return.SessionorSessionSnapshotreads better in Go, but the name is the spec's and renaming breaks the one-to-one mapping a reader uses to check the binding.Eventexposes no wire-type accessor. The interface is sealed withisEvent()only, so getting thetypestring means a type switch or reading the struct field. AType() stringmethod would be additive and convenient; it is left out for now because it duplicates state that already lives on each struct.Two enum pairs are duplicates by value:
ErrorDataSource/ErrorEventSource(execution,llm,tool) andSandboxStatusStage/SessionSandboxStatusEventStage(six values). Two schemas describe the same set. Collapsing them belongs in the spec, not the generator.ValidationErrorLoc0 = stringandValidationErrorLoc1 = intare generated aliases for the string-or-int union members, and their doc comments readdefines model for .because the spec gives the members no description. Cosmetic, fixable in the spec.Versioning. The module is nested and untagged, so
go getneeds@maintoday. A tag would besdks/go-client/v0.1.0. Worth deciding whether the SDK versions with the server or on its own line.Five internal module paths survive in ten doc comments. The generator redacts server internals out of descriptions before they become published Go docs, and it recognises them by shape: a
.pyreference, a private_name, an absolute home directory. A dotted lowercase path is not recognisable that way —omnigent.runtime.pending_inputsis the same shape as the wire-visible label keyomnigent.codex_native.collaboration_mode, and telling them apart needs a list of what the server publishes rather than a wider pattern. Three of the five name a server-side schema class that models a payload this package already exposes as a Go type; two name in-process server state a client cannot reach. Extending the pattern was tried and reverted, because deleting these references leaves sentences whose subject was the path. The fix is a substitution table mapping each path to what it denotes, and it deserves its own review. The README enumerates them.Changelog
A Go client for the session API, with a range-over-func event stream, cursor-paginated agent and session listings, and approval handling