diff --git a/.gitattributes b/.gitattributes
index f5c91ceed6..9a631847d7 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,3 +4,11 @@
# assets, and collapse it in review UIs. Drift is still caught by the content-based
# `git diff --quiet -- internal/api/dashboardspa/dist` gate in the Makefile.
internal/api/dashboardspa/dist/** -diff linguist-generated
+
+# The typed Go API client under internal/api/genclient/client_gen.go is emitted by
+# oapi-codegen (its header says "DO NOT EDIT") from the OpenAPI spec. Treat it as
+# generated output like the SPA bundle above: skip textual diffing and the
+# whitespace lint on the machine-generated source, and do not attribute its shape
+# to authored complexity. Drift is still caught by content-based build/regeneration
+# gates, since a blob-hash change is detected even with -diff.
+internal/api/genclient/client_gen.go -diff linguist-generated
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index d3937a2c61..053ef24c09 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -4,8 +4,9 @@ set -euo pipefail
staged_go_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go' || true)
staged_web_src=$(git diff --cached --name-only --diff-filter=ACM -- 'internal/api/dashboardspa/web/shared/src/' 'internal/api/dashboardspa/web/frontend/src/' 'internal/api/dashboardspa/web/frontend/index.html' 'internal/api/dashboardspa/web/frontend/public/' 'internal/api/dashboardspa/web/package.json' 'internal/api/dashboardspa/web/shared/package.json' 'internal/api/dashboardspa/web/frontend/package.json' 'internal/api/dashboardspa/web/frontend/vite.config.ts' 'internal/api/dashboardspa/web/frontend/tsconfig.json' 'internal/api/dashboardspa/web/shared/tsconfig.json' || true)
staged_docs=$(git diff --cached --name-only --diff-filter=ACM -- '*.md' 'docs/**' 'engdocs/**' 'plans/**' 'specs/**' 'AGENTS.md' 'CONTRIBUTING.md' 'README.md' 'TESTING.md' || true)
+staged_spec=$(git diff --cached --name-only --diff-filter=ACM -- 'internal/api/openapi.json' || true)
-if [ -z "$staged_go_files" ] && [ -z "$staged_web_src" ] && [ -z "$staged_docs" ]; then
+if [ -z "$staged_go_files" ] && [ -z "$staged_web_src" ] && [ -z "$staged_docs" ] && [ -z "$staged_spec" ]; then
exit 0
fi
@@ -44,12 +45,24 @@ if [ -n "$staged_docs" ]; then
make check-docs
fi
-# Dashboard SPA rebuild: whenever the spec changes OR the SPA source
-# changes, regenerate the TS types, typecheck, and rebuild the compiled
-# bundle. Guarded on `npm` availability so contributors without Node
-# tooling aren't blocked; CI enforces the full regeneration.
+# Dashboard SPA rebuild: when internal/api/openapi.json changes, regenerate
+# the generated TS API client from the new spec and stage it. When the spec
+# OR the SPA source changes, typecheck and rebuild the compiled bundle.
+# Guarded on `npm` availability so contributors without Node tooling aren't
+# blocked; CI enforces the full regeneration via make dashboard-ci.
if command -v npm >/dev/null 2>&1; then
+ # Re-read the index rather than reusing the pre-hook snapshot: the Go
+ # block above runs `go run ./cmd/genspec` and stages the regenerated
+ # internal/api/openapi.json, so a Go-only commit that moves the API
+ # surface only shows up here (#4627, #4607).
spec_changed=$(git diff --cached --name-only --diff-filter=ACM -- 'internal/api/openapi.json' || true)
+ if [ -n "$spec_changed" ]; then
+ # Regenerate BEFORE typecheck/build below: a client that no longer
+ # matches the new spec must fail typecheck immediately instead of
+ # silently building/committing a stale client (#4627, #4607).
+ (cd internal/api/dashboardspa/web && npm ci --silent && npm run generate:client)
+ git add internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client
+ fi
if [ -n "$spec_changed" ] || [ -n "$staged_web_src" ]; then
# Typecheck BEFORE build: vite's build transpiles TS to JS and
# silently ignores type errors. The Makefile target also runs the
diff --git a/.githooks/pre-push b/.githooks/pre-push
index 1b40142658..7629690d90 100755
--- a/.githooks/pre-push
+++ b/.githooks/pre-push
@@ -14,11 +14,13 @@ set -euo pipefail
zero="0000000000000000000000000000000000000000"
go_changed=0
+any_non_deletion=0
while read -r _local_ref local_sha _remote_ref remote_sha; do
- # Branch deletion — nothing to test.
+ # Branch deletion — nothing to test, nothing to guard.
if [ "$local_sha" = "$zero" ]; then
continue
fi
+ any_non_deletion=1
# New remote branch: no cheap base to diff against — run the suite.
if [ "$remote_sha" = "$zero" ]; then
go_changed=1
@@ -29,6 +31,21 @@ while read -r _local_ref local_sha _remote_ref remote_sha; do
fi
done
+# Bead ownership/staleness guard (ga-fip9ps.1): re-checks bd claim state
+# immediately before this push leaves the machine, once per invocation
+# (not per ref) regardless of whether Go sources changed. A mayor ruling
+# (reassign/close/hold/reroute) that landed after this push was queued
+# must not be allowed to clobber a branch another agent has since taken
+# over. Bypass: `git push --no-verify`.
+if [ "$any_non_deletion" -eq 1 ]; then
+ repo_root="$(git rev-parse --show-toplevel)"
+ # shellcheck source=../scripts/push-ownership-guard.sh disable=SC1091
+ . "$repo_root/scripts/push-ownership-guard.sh"
+ if ! assert_bead_still_claimed; then
+ exit 1
+ fi
+fi
+
if [ "$go_changed" -eq 0 ]; then
exit 0
fi
diff --git a/.github/requirements/mcp-agent-mail.in b/.github/requirements/mcp-agent-mail.in
index 67fa1e2512..096e1b32cc 100644
--- a/.github/requirements/mcp-agent-mail.in
+++ b/.github/requirements/mcp-agent-mail.in
@@ -2,11 +2,17 @@
# publishes current wheel/sdist assets.
mcp-agent-mail @ https://github.com/Dicklesworthstone/mcp_agent_mail/archive/32783f6848bd63c425c4b5004cee3350016635fb.tar.gz
-# Security floor: GitPython 3.1.49 has GHSA-mv93-w799-cj2w (HIGH).
-# Pinning floor at 3.1.50 to ensure the resolver picks the patched version
-# even if mcp-agent-mail's transitive constraint allows older. Drop this
-# line once mcp-agent-mail upstream pins GitPython>=3.1.50 itself.
-gitpython>=3.1.50
+# Security floor: GitPython < 3.1.52 has multiple HIGH-severity command
+# injection and path traversal advisories reported by the image gate.
+# Pinning the floor ensures the resolver picks the patched version even if
+# mcp-agent-mail's transitive constraint allows older. Drop this line once
+# mcp-agent-mail upstream pins GitPython>=3.1.52 itself.
+gitpython>=3.1.52
+
+# Security floor: Pillow < 12.3.0 has multiple HIGH-severity image parsing
+# vulnerabilities reported by the image gate. Drop this line once transitive
+# constraints carry the patched version themselves.
+pillow>=12.3.0
# Security floor: urllib3 < 2.7.0 has GHSA-mf9v-mfxr-j63j (HIGH,
# decompression-bomb safeguards bypassed in parts of the streaming API)
@@ -31,6 +37,15 @@ litellm>=1.84.0
# authlib's transitive constraint carries the patched version itself.
joserfc>=1.6.8
+# Security floor: CVE-2026-52869 + CVE-2026-52870 (HIGH, fixed 1.27.2 — HTTP
+# transports serve session requests without verifying the authenticated
+# session, and experimental task handlers are reachable by any client) and
+# CVE-2026-59950 (HIGH, fixed 1.28.1 — WebSocket transport lacks Host/Origin
+# validation) in the mcp Python SDK < 1.28.1. mcp arrives transitively via
+# fastmcp-slim (mcp<2.0,>=1.24.0); floor at 1.28.1 to clear all three. Drop
+# once fastmcp-slim's transitive constraint carries the patched version itself.
+mcp>=1.28.1
+
# Authlib and FastMCP security floors live in
# .github/requirements/mcp-agent-mail.overrides.txt because mcp-agent-mail
# v0.3.2 still caps Authlib below the fixed release.
diff --git a/.github/requirements/mcp-agent-mail.txt b/.github/requirements/mcp-agent-mail.txt
index 716f55ce3d..64e8127a14 100644
--- a/.github/requirements/mcp-agent-mail.txt
+++ b/.github/requirements/mcp-agent-mail.txt
@@ -770,9 +770,9 @@ gitdb==4.0.12 \
--hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
--hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
# via gitpython
-gitpython==3.1.50 \
- --hash=sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc \
- --hash=sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9
+gitpython==3.1.54 \
+ --hash=sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0 \
+ --hash=sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf
# via
# -r .github/requirements/mcp-agent-mail.in
# mcp-agent-mail
@@ -1345,10 +1345,12 @@ markupsafe==3.0.3 \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via jinja2
-mcp==1.27.0 \
- --hash=sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741 \
- --hash=sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83
- # via fastmcp-slim
+mcp==1.28.1 \
+ --hash=sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df \
+ --hash=sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683
+ # via
+ # -r .github/requirements/mcp-agent-mail.in
+ # fastmcp-slim
mcp-agent-mail @ https://github.com/Dicklesworthstone/mcp_agent_mail/archive/32783f6848bd63c425c4b5004cee3350016635fb.tar.gz \
--hash=sha256:8ffe6d9ee8665e957a83a885e5f45d0ad2733f5a50a1e4ec4479e66ef625e35a
# via -r .github/requirements/mcp-agent-mail.in
@@ -1614,99 +1616,97 @@ pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via mcp-agent-mail
-pillow==12.2.0 \
- --hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \
- --hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \
- --hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \
- --hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \
- --hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \
- --hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \
- --hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \
- --hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \
- --hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \
- --hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \
- --hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \
- --hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \
- --hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \
- --hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \
- --hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \
- --hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \
- --hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \
- --hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \
- --hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \
- --hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \
- --hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \
- --hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \
- --hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \
- --hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \
- --hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \
- --hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \
- --hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \
- --hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \
- --hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \
- --hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \
- --hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \
- --hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \
- --hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \
- --hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \
- --hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \
- --hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \
- --hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \
- --hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \
- --hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \
- --hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \
- --hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \
- --hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \
- --hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \
- --hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \
- --hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \
- --hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \
- --hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \
- --hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \
- --hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \
- --hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \
- --hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \
- --hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \
- --hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \
- --hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \
- --hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \
- --hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \
- --hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \
- --hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \
- --hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \
- --hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \
- --hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \
- --hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \
- --hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \
- --hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \
- --hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \
- --hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \
- --hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \
- --hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \
- --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \
- --hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \
- --hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \
- --hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \
- --hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \
- --hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \
- --hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \
- --hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \
- --hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \
- --hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \
- --hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \
- --hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \
- --hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \
- --hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \
- --hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \
- --hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \
- --hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \
- --hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \
- --hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \
- --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \
- --hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \
- --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \
- --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5
- # via mcp-agent-mail
+pillow==12.3.0 \
+ --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
+ --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
+ --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
+ --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
+ --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
+ --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
+ --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
+ --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
+ --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
+ --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
+ --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
+ --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
+ --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
+ --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
+ --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
+ --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
+ --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
+ --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
+ --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
+ --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
+ --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
+ --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
+ --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
+ --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
+ --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
+ --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
+ --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
+ --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
+ --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
+ --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
+ --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
+ --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
+ --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
+ --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
+ --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
+ --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
+ --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
+ --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
+ --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
+ --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
+ --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
+ --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
+ --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
+ --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
+ --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
+ --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
+ --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
+ --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
+ --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
+ --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
+ --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
+ --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
+ --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
+ --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
+ --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
+ --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
+ --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
+ --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
+ --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
+ --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
+ --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
+ --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
+ --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
+ --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
+ --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
+ --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
+ --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
+ --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
+ --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
+ --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
+ --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
+ --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
+ --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
+ --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
+ --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
+ --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
+ --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
+ --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
+ --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
+ --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
+ --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
+ --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
+ --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
+ --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
+ --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
+ --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
+ --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
+ # via
+ # -r .github/requirements/mcp-agent-mail.in
+ # mcp-agent-mail
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 480112aa5b..d6613e1c0a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,6 +65,7 @@ jobs:
worker: ${{ steps.filter.outputs.worker == 'true' || steps.filter.outputs.shared == 'true' }}
worker_phase2: ${{ steps.filter.outputs.worker_phase2 == 'true' || steps.filter.outputs.shared == 'true' }}
cmd_gc_process: ${{ steps.filter.outputs.cmd_gc_process == 'true' || steps.filter.outputs.shared == 'true' }}
+ credential_provider: ${{ steps.filter.outputs.credential_provider == 'true' || steps.filter.outputs.shared == 'true' }}
integration: ${{ steps.filter.outputs.integration == 'true' || steps.filter.outputs.shared == 'true' }}
openclaw_bridge: ${{ steps.filter.outputs.openclaw_bridge }}
# Raw cross-cutting signal and per-run coverage classification (metric).
@@ -131,6 +132,12 @@ jobs:
- 'cmd/gc/**'
- 'internal/**'
- 'examples/gastown/**'
+ credential_provider:
+ - 'go.mod'
+ - 'go.sum'
+ - 'internal/credentialprovider/**'
+ - 'internal/testenv/**'
+ - 'internal/testutil/**'
integration:
- 'go.mod'
- 'go.sum'
@@ -139,6 +146,7 @@ jobs:
- '**/*.go'
- 'scripts/test-integration-shard'
- 'scripts/test-go-test-shard'
+ - 'scripts/runtime-tmux-tests.manifest'
- 'scripts/go-test-observable'
- 'examples/gastown/**'
openclaw_bridge:
@@ -176,11 +184,21 @@ jobs:
runs-on: ${{ needs.runner-policy.outputs.runner_16vcpu }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: CI workflow policy
run: make test-ci-policy
+ - name: Classify static-analysis scope
+ id: static-scope
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ scope="$(scripts/ci-static-scope)"
+ printf 'scope=%s\n' "$scope" >> "$GITHUB_OUTPUT"
- name: go.mod replace guard
run: make check-gomod-replace
- name: Native dependency surface guard
@@ -206,16 +224,36 @@ jobs:
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: .cache/golangci-lint
+ # Exact-key-only restore (no restore-keys fallback): a broad prefix
+ # let a partial/incompatible cache (missing testing.T.Fatal no-return
+ # facts) be inherited across differing keys and re-saved forward,
+ # repeatedly re-poisoning the cache with a SA5011 false-positive
+ # storm (ga-35qn7i, ga-v7x9vk, ga-l14mnf, ga-ndx34y). A miss now
+ # forces a fully fresh, never-poisoned recompute.
key: ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}-${{ hashFiles('go.sum', '.golangci.yml') }}
- restore-keys: |
- ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}-
- - name: Lint
+ - name: Lint affected packages
+ if: steps.static-scope.outputs.scope == 'changed'
+ env:
+ GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint
+ LINT_CHANGED_SCOPE: tracked
+ LINT_CHANGED_REF: ${{ github.event.pull_request.base.sha }}
+ run: make lint-affected
+ - name: Lint full repository
+ if: steps.static-scope.outputs.scope != 'changed'
env:
GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint
run: make lint
- - name: Format
+ - name: Format changed files
+ if: steps.static-scope.outputs.scope == 'changed'
+ env:
+ LINT_CHANGED_SCOPE: tracked
+ LINT_CHANGED_REF: ${{ github.event.pull_request.base.sha }}
+ run: make fmt-check-changed
+ - name: Format full repository
+ if: steps.static-scope.outputs.scope != 'changed'
run: make fmt-check
- name: Vet
+ if: steps.static-scope.outputs.scope != 'changed'
run: make vet
- name: Docs
run: make check-docs
@@ -559,6 +597,67 @@ jobs:
if-no-files-found: warn
retention-days: 7
+ cmd-gc-productmetrics-testhook:
+ name: cmd/gc product metrics testhook
+ needs:
+ - runner-policy
+ - changes
+ if: needs.changes.outputs.cmd_gc_process == 'true'
+ runs-on: ${{ needs.runner-policy.outputs.runner_32vcpu }}
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ with:
+ go-version-file: go.mod
+ - name: Ensure timing renderer is available
+ run: command -v jq >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends jq)
+ - name: Run product metrics testhook profile
+ env:
+ OBSERVABLE_TIMING_FILE: ${{ runner.temp }}/cmd-gc-productmetrics-testhook.json
+ OBSERVABLE_SHARD_ID: cmd-gc-productmetrics-testhook
+ OBSERVABLE_VARIANT: linux-productmetrics-testhook
+ OBSERVABLE_RUNNER_LABEL: ${{ needs.runner-policy.outputs.runner_32vcpu }}
+ EXTRA_TEST_ENV: >-
+ OBSERVABLE_TIMING_FILE="$${OBSERVABLE_TIMING_FILE}"
+ OBSERVABLE_SHARD_ID="$${OBSERVABLE_SHARD_ID}"
+ OBSERVABLE_VARIANT="$${OBSERVABLE_VARIANT}"
+ OBSERVABLE_RUNNER_LABEL="$${OBSERVABLE_RUNNER_LABEL}"
+ OBSERVABLE_COMMIT_SHA="$${GITHUB_SHA}"
+ OBSERVABLE_WORKFLOW="$${GITHUB_WORKFLOW}"
+ OBSERVABLE_RUN_ID="$${GITHUB_RUN_ID}"
+ OBSERVABLE_RUN_ATTEMPT="$${GITHUB_RUN_ATTEMPT}"
+ OBSERVABLE_JOB="$${GITHUB_JOB}"
+ OBSERVABLE_RUNNER_NAME="$${RUNNER_NAME}"
+ OBSERVABLE_RUNNER_OS="$${RUNNER_OS}"
+ OBSERVABLE_RUNNER_ARCH="$${RUNNER_ARCH}"
+ run: make test-productmetrics-testhook EXTRA_TEST_ENV="$EXTRA_TEST_ENV"
+ - name: Upload product metrics testhook timing
+ if: ${{ always() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: timing-cmd-gc-productmetrics-testhook-attempt-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/cmd-gc-productmetrics-testhook.json
+ if-no-files-found: error
+ retention-days: 7
+
+ credential-provider-windows:
+ name: Credential provider / Windows process tree
+ needs:
+ - runner-policy
+ - changes
+ if: needs.changes.outputs.credential_provider == 'true'
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ with:
+ go-version-file: go.mod
+ - name: Test credential-provider unit contract
+ run: go test -count=1 ./internal/credentialprovider
+ - name: Test Windows Job Object descendant cleanup
+ run: go test -tags=integration -count=1 ./internal/credentialprovider -run '^TestCredentialProviderWindowsJob' -timeout=45s
+
integration-shards:
name: Integration / ${{ matrix.shard_name }}
needs:
@@ -885,9 +984,7 @@ jobs:
run: mkdir -p "$WORKER_REPORT_DIR"
- name: WorkerCore phase-2 conformance
id: worker_core_phase2_tests
- run: |
- GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2 PROFILE="$PROFILE"
- GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-real-transport PROFILE="$PROFILE"
+ run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-all PROFILE="$PROFILE"
- name: Ensure WorkerCore phase-2 reports
if: ${{ always() && steps.worker_core_phase2_tests.outcome != 'success' }}
run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-core-phase2"
@@ -923,9 +1020,7 @@ jobs:
run: mkdir -p "$WORKER_REPORT_DIR"
- name: WorkerCore phase-2 conformance
id: worker_core_phase2_tests
- run: |
- GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2 PROFILE="$PROFILE"
- GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-real-transport PROFILE="$PROFILE"
+ run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-all PROFILE="$PROFILE"
- name: Ensure WorkerCore phase-2 reports
if: ${{ always() && steps.worker_core_phase2_tests.outcome != 'success' }}
run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-core-phase2"
@@ -961,9 +1056,7 @@ jobs:
run: mkdir -p "$WORKER_REPORT_DIR"
- name: WorkerCore phase-2 conformance
id: worker_core_phase2_tests
- run: |
- GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2 PROFILE="$PROFILE"
- GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-real-transport PROFILE="$PROFILE"
+ run: GC_WORKER_REPORT_DIR="$WORKER_REPORT_DIR" make test-worker-core-phase2-all PROFILE="$PROFILE"
- name: Ensure WorkerCore phase-2 reports
if: ${{ always() && steps.worker_core_phase2_tests.outcome != 'success' }}
run: python3 .github/workflows/scripts/worker_report_stub.py "$WORKER_REPORT_DIR" "worker-core-phase2"
@@ -1165,12 +1258,70 @@ jobs:
- name: Typecheck test files (tsc -p tsconfig.test.json)
run: npm run --workspace gas-city-dashboard-frontend typecheck:test
working-directory: internal/api/dashboardspa/web
+ - name: Typecheck e2e specs (tsc -p e2e/tsconfig.json)
+ run: npm run --workspace gas-city-dashboard-frontend typecheck:e2e
+ working-directory: internal/api/dashboardspa/web
- name: Vitest
run: npm run --workspace gas-city-dashboard-frontend test
working-directory: internal/api/dashboardspa/web
- name: Build
run: npm run build --silent
working-directory: internal/api/dashboardspa/web
+ # Sync the freshly built bundle into the embedded dist/ the fakesupervisor
+ # serves, so the Playwright render smoke (Layer B) runs against the SPA
+ # this job just built, not a stale committed embed.
+ - name: Sync embedded SPA bundle
+ run: rm -rf internal/api/dashboardspa/dist && cp -rf internal/api/dashboardspa/web/frontend/dist internal/api/dashboardspa/dist
+ - name: Build seeded fakesupervisor (Layer B server)
+ run: go build -tags integration -o fakesupervisor .
+ working-directory: test/dashport/cmd/fakesupervisor
+ - name: Resolve Playwright version
+ id: playwright-version
+ run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
+ working-directory: internal/api/dashboardspa/web
+ # Cache the downloaded browser binaries keyed on the resolved
+ # @playwright/test version (from the lockfile-pinned install above). A cache
+ # hit skips the ~150 MB Chromium download; --with-deps in the install step
+ # below still runs (system libs aren't cached), so a hit is safe.
+ # Restore/save are split (rather than the combined actions/cache) so the
+ # save step can run with if: always() — a completed download still gets
+ # cached even if a later step in this job fails.
+ - name: Restore Playwright browsers cache
+ uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
+ - name: Install Playwright Chromium
+ timeout-minutes: 12
+ run: |
+ for attempt in 1 2 3; do
+ if npm run test:e2e:install:ci; then
+ exit 0
+ fi
+ if [ "$attempt" = "3" ]; then
+ exit 1
+ fi
+ sleep $((attempt * 10))
+ done
+ working-directory: internal/api/dashboardspa/web/frontend
+ - name: Save Playwright browsers cache
+ if: always()
+ uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
+ - name: Playwright render smoke (Layer B)
+ run: npm run test:e2e
+ working-directory: internal/api/dashboardspa/web/frontend
+ timeout-minutes: 10
+ - name: Upload Playwright report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: playwright-report
+ path: internal/api/dashboardspa/web/frontend/playwright-report/
+ retention-days: 7
+ if-no-files-found: ignore
# openclaw-bridge Node test suite. The bridge ships its own npm tests
# (inbound at-least-once redelivery + gc client wire shapes); without this
@@ -1356,6 +1507,8 @@ jobs:
- ci-preflight
- ci-integration
- cmd-gc-process
+ - cmd-gc-productmetrics-testhook
+ - credential-provider-windows
- worker-core-summary
- worker-core-phase2-summary
- pack-gate
@@ -1377,6 +1530,8 @@ jobs:
needs = json.loads(os.environ["NEEDS_JSON"])
allow_skipped = {
"cmd-gc-process",
+ "cmd-gc-productmetrics-testhook",
+ "credential-provider-windows",
"pack-gate",
"docker-session",
"k8s-session",
diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml
index c82a9f9ad7..572a5d2ce8 100644
--- a/.github/workflows/container-scan.yml
+++ b/.github/workflows/container-scan.yml
@@ -181,7 +181,7 @@ jobs:
mkdir -p "$bin_dir"
BD_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-bd-archive.sh "$BD_VERSION"
BR_INSTALL_BIN_DIR="$bin_dir" .github/scripts/install-br-archive.sh "$BR_VERSION"
- go build -o gc ./cmd/gc
+ CGO_ENABLED=0 go build -o gc ./cmd/gc
cp -f "$bin_dir/bd" bd
cp -f "$bin_dir/br" br
diff --git a/.github/workflows/mac-regression.yml b/.github/workflows/mac-regression.yml
index 52a1758c23..6875237d6a 100644
--- a/.github/workflows/mac-regression.yml
+++ b/.github/workflows/mac-regression.yml
@@ -152,9 +152,13 @@ jobs:
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: .cache/golangci-lint
+ # Exact-key-only restore (no restore-keys fallback): a broad prefix
+ # let a partial/incompatible cache (missing testing.T.Fatal no-return
+ # facts) be inherited across differing keys and re-saved forward,
+ # repeatedly re-poisoning the cache with a SA5011 false-positive
+ # storm (ga-35qn7i, ga-v7x9vk, ga-l14mnf, ga-ndx34y). A miss now
+ # forces a fully fresh, never-poisoned recompute.
key: ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}-${{ hashFiles('go.sum', '.golangci.yml') }}
- restore-keys: |
- ${{ runner.os }}-golangci-lint-${{ steps.glint-version.outputs.version }}-${{ steps.glint-version.outputs.goversion }}-
- name: Lint
env:
GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint
@@ -230,6 +234,9 @@ jobs:
install-claude-cli: "false"
- name: Run cmd/gc process shard
run: make test-cmd-gc-process-shard CMD_GC_PROCESS_SHARD=${{ matrix.shard }} CMD_GC_PROCESS_TOTAL=12
+ - name: Run product metrics testhook profile
+ if: ${{ matrix.shard == 6 }}
+ run: make test-productmetrics-testhook
# Tier A acceptance — smoke-level gate on every PR.
mac-acceptance:
diff --git a/.github/workflows/remove-needs-info.yml b/.github/workflows/remove-needs-info.yml
index 58233e7781..0cde73337d 100644
--- a/.github/workflows/remove-needs-info.yml
+++ b/.github/workflows/remove-needs-info.yml
@@ -43,11 +43,23 @@ jobs:
for (const label of labels) {
if (!currentNames.includes(label)) continue;
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: number,
- name: label,
- });
- console.log(`Removed '${label}' from #${number}`);
+ // Idempotent removal: concurrent triggers can race to remove the
+ // same label, leaving the loser with a 404 "Label does not
+ // exist". Treat an already-absent label as success rather than
+ // failing the job.
+ try {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: number,
+ name: label,
+ });
+ console.log(`Removed '${label}' from #${number}`);
+ } catch (err) {
+ if (err.status === 404) {
+ console.log(`'${label}' already absent from #${number} (concurrent removal); nothing to do`);
+ continue;
+ }
+ throw err;
+ }
}
diff --git a/.github/workflows/remove-needs-triage.yml b/.github/workflows/remove-needs-triage.yml
index 189c61ae09..dbc5f6646a 100644
--- a/.github/workflows/remove-needs-triage.yml
+++ b/.github/workflows/remove-needs-triage.yml
@@ -12,6 +12,8 @@ jobs:
# pull_request_target is safe here because this job never checks out or runs
# pull request code; it only removes labels from the issue/PR metadata.
remove-triage-label:
+ # Olivia owns label, comment, and inbox removal as one recoverable transaction.
+ if: github.event.sender.login != 'gascityinc-olivia[bot]'
runs-on: ubuntu-latest
permissions:
issues: write
@@ -35,10 +37,25 @@ jobs:
if (!current.data.some(l => l.name === target)) return;
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: number,
- name: target,
- });
- console.log(`Removed '${target}' from #${number} (triaged with '${added}')`);
+ // The check above narrows a race but cannot close it: adding
+ // several non-status labels at once fires one `labeled` event
+ // (and one job) each, and they run concurrently. Every run sees
+ // the label present here, then all race to remove it. The winner
+ // gets 204; the losers get 404 "Label does not exist". Treat an
+ // already-absent label as success so a benign lost race does not
+ // paint a red X on the PR's checks.
+ try {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: number,
+ name: target,
+ });
+ console.log(`Removed '${target}' from #${number} (triaged with '${added}')`);
+ } catch (err) {
+ if (err.status === 404) {
+ console.log(`'${target}' already absent from #${number} (concurrent removal); nothing to do`);
+ return;
+ }
+ throw err;
+ }
diff --git a/.github/workflows/scripts/test_remove_needs_triage_policy.py b/.github/workflows/scripts/test_remove_needs_triage_policy.py
new file mode 100644
index 0000000000..ad3ef1c8c6
--- /dev/null
+++ b/.github/workflows/scripts/test_remove_needs_triage_policy.py
@@ -0,0 +1,19 @@
+import pathlib
+import unittest
+
+
+WORKFLOW = pathlib.Path(__file__).parents[1] / "remove-needs-triage.yml"
+
+
+class RemoveNeedsTriagePolicyTests(unittest.TestCase):
+ def test_olivia_label_events_do_not_run_the_automatic_removal_job(self) -> None:
+ lines = WORKFLOW.read_text(encoding="utf-8").splitlines()
+
+ self.assertIn(
+ " if: github.event.sender.login != 'gascityinc-olivia[bot]'",
+ lines,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.gitignore b/.gitignore
index fc8b2b4033..4476873f71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,17 @@ cmd/gc/.runtime/
/bin/
/dist/
/gc
+
+# Dashboard e2e (Layer B) build + run artifacts. The fakesupervisor is a
+# compiled -tags integration binary; Playwright emits reports/results/browsers.
+# The bare /fakesupervisor guard catches `go build ./test/.../fakesupervisor/`
+# run from the repo root (which drops the binary in cwd), not just the -o form.
+test/dashport/cmd/fakesupervisor/fakesupervisor
+/fakesupervisor
+internal/api/dashboardspa/web/frontend/test-results/
+internal/api/dashboardspa/web/frontend/playwright-report/
+internal/api/dashboardspa/web/frontend/blob-report/
+internal/api/dashboardspa/web/frontend/.playwright/
/genschema
/bd
/br
diff --git a/.golangci.yml b/.golangci.yml
index 98b233f8fa..5f6486ba8e 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -20,9 +20,13 @@ formatters:
- goimports
linters:
- # Default linters (errcheck, govet, ineffassign, staticcheck, unused)
- # are always enabled. Add extras here.
+ # Keep govet explicit in configured lint. The changed-scope target bounds
+ # both this copy and the Go tool's vet to the same affected graph, preserving
+ # their distinct diagnostics without repeating either across the whole repo.
+ # Other default linters (errcheck, ineffassign, staticcheck, unused) remain
+ # enabled alongside these configured extras.
enable:
+ - govet
- errorlint
- misspell
- gocritic
diff --git a/.goreleaser.yml b/.goreleaser.yml
index da326bd1eb..754698b4f8 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -5,8 +5,15 @@ builds:
binary: gc
env:
- CGO_ENABLED=0
+ # compiledReleaseTag is the only linker-injected product-metrics identity
+ # input; it labels reporting (release vs canary via release_version shape) and
+ # can never redirect telemetry or bypass consent. Snapshot (edge/RC) builds
+ # inject a prerelease semver so they classify as canary; stable releases inject
+ # the clean tag so they classify as release. Archive/checksum names stay on the
+ # canonical {{ .Version }} (see snapshot.version_template) so the RC formula
+ # keeps matching gascity_ Fetching transcript.
+ {state.error}
+ No structured transcript yet. ▲ {PROMPT_INJECTION_NOTICE} No diff available for this run.
- The run did not record a work_dir, so there is no work tree to compare.
- Execution folder is not a git work tree.
- {diff.error}
- {diff.rootPath.path}
- {comparisonText(diff.comparison)}
-
- No renderable patch in this work tree.
-
- Diff truncated at the backend output cap.
- No textual hunks. Local changes are not loaded for this run. Loading local changes.
- {diff.error}
-
- {diff.refreshState.error}
-
- Refreshing local changes
-
+ {result.items.map((item) =>
+ // StructuredMessage renders its own
child is an
+ Live peek
Local Changes
-
- {diff.changedFiles.length} changed file{diff.changedFiles.length === 1 ? '' : 's'}
-
-
-
- {filePath(file)}
-
-
- +{counts.additions} -{counts.deletions}
-
-
- {file.hunks.length === 0 || file.isBinary ? (
- `, with a literal `\n` text node between lines —
+ * the old `renderDiffPre` model. That keeps the `
`'s textContent equal to
+ * the original diff (so a selected diff copies with its line breaks) while each
+ * line's tone is derived from `diffLineKind`, reproducing the old dashboard's
+ * diff coloring without the `log-msg-diff-*` BEM classes. Splitting on `\n`
+ * (after `\r\n` normalization) keeps blank lines as empty spans.
+ */
+export function DiffView({ text }: { text: string }) {
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
+ return (
+
+ {lines.map((line, index) => (
+
+ );
+}
diff --git a/internal/api/dashboardspa/web/frontend/src/components/structured/StructuredTranscript.test.tsx b/internal/api/dashboardspa/web/frontend/src/components/structured/StructuredTranscript.test.tsx
new file mode 100644
index 0000000000..286cb25967
--- /dev/null
+++ b/internal/api/dashboardspa/web/frontend/src/components/structured/StructuredTranscript.test.tsx
@@ -0,0 +1,441 @@
+import { cleanup, render, within } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import type {
+ SessionStructuredBlock,
+ SessionStructuredHistory,
+ SessionStructuredMessage,
+} from 'gas-city-dashboard-shared';
+import {
+ StructuredBlock,
+ StructuredMessage,
+ StructuredTranscript,
+ PendingInteractionView,
+} from './StructuredTranscript';
+
+afterEach(cleanup);
+
+function message(overrides: Partial renders as colorized per-line spans.
+ const spans = Array.from(container.querySelectorAll('span'));
+ expect(spans.find((s) => s.textContent === '-old line')?.className).toContain('text-warn');
+ expect(spans.find((s) => s.textContent === '+new line')?.className).toContain('text-ok');
+ expect(spans.find((s) => s.textContent === '*** Update File: src/app.ts')?.className).toContain(
+ 'text-fg-faint',
+ );
+ });
+
+ it('applies error styling when is_error is set', () => {
+ const block: SessionStructuredBlock = {
+ type: 'tool_result',
+ is_error: true,
+ structured: {
+ kind: 'bash',
+ error: { category: 'command_failure', message: 'boom' },
+ exit_code: 1,
+ },
+ };
+ const { container } = render( for a CSP-allowed data URL', () => {
+ const block: SessionStructuredBlock = {
+ type: 'image',
+ file_path: 'screens/shot.png',
+ image_url: 'data:image/png;base64,c2hvdA==',
+ mime_type: 'image/png',
+ };
+ const { container } = render(
when there is no image_url', () => {
+ const { container } = render(
{body}
+ )}
+ {children}
+ `. Spec §7. */
+export function ToolUseBlock({
+ block,
+}: {
+ block: Extract`, and a diff when present. Spec §8. */
+export function ToolResultBlock({
+ block,
+}: {
+ block: Extract` when an image_url is present. Spec §6. */
+export function ImageBlock({
+ block,
+}: {
+ block: Extract
+ )}
+
{formatInteraction(block)};
+}
+
+/** A streamed pending-interaction frame, rendered as its own message-shaped block. Spec §9. */
+export function PendingInteractionView({ pending }: { pending: PendingInteraction }) {
+ const rows = pendingRows(pending);
+ return (
+
+ {block.text ?? ''}
+
+ );
+ case 'thinking':
+ return (
+
+ {block.thinking !== undefined && block.thinking !== '' ? `[thinking] ${block.thinking}` : '[thinking]'}
+
+ );
+ case 'tool_use':
+ return
+ {formatInlineValue(block)}
+
+ );
+ }
+}
+
+/**
+ * A single structured message: a metadata header (role, provider, time, model,
+ * usage, status, and stop_reason — in spec §1 order, empties omitted) followed
+ * by the body. The body renders user-prompt and system-event
+ * metadata first, then each block; the first `text` block is suppressed when
+ * either metadata kind is present (spec §1.3), since the metadata already
+ * structures that raw prompt/system text.
+ */
+export function StructuredMessage({ message }: { message: SessionStructuredMessage }) {
+ const role = message.role;
+ const assistantMetadata =
+ message.role === 'assistant' || message.role === 'unknown' ? message : undefined;
+ const userPrompt =
+ message.role === 'user' || message.role === 'unknown' ? message.user_prompt : undefined;
+ const systemEvent =
+ message.role === 'system' || message.role === 'unknown' ? message.system_event : undefined;
+ const usage = formatUsage(assistantMetadata?.usage);
+
+ // Suppression gates on whether the metadata actually RENDERED (non-empty
+ // rows), mirroring the old renderer that keyed off the returned element, not
+ // mere field presence. When either metadata block renders, every `text` block
+ // is dropped — the metadata already structures that raw prompt/system text.
+ const promptRendered = userPrompt !== undefined && userPromptRows(userPrompt).length > 0;
+ const systemRendered = systemEvent !== undefined && systemEventRows(systemEvent).length > 0;
+ const suppressText = promptRendered || systemRendered;
+
+ const blocks = message.blocks;
+
+ return (
+
+ {messages.map((message, index) => (
+
+
+ last 24 hours
+
+
runs in flight · canonical state
diff --git a/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx b/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx
index ca0237ef87..a369146932 100644
--- a/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx
+++ b/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx
@@ -10,7 +10,6 @@ import {
GC_EVENT_PREFIX,
type TranscriptResult,
type TranscriptTurn,
- type RunDiffResponse,
type FormulaRunDetail,
type RunScopeKind,
type RunLane,
@@ -36,20 +35,17 @@ const eventSources: FakeEventSource[] = [];
interface FormulaRunDetailFixture {
detail: FormulaRunDetail;
- diff: RunDiffResponse;
transcripts: Record
` body text, the
+// header role class, and the diff-line kinds — so the Slice 3b React renderer
+// can map these strings to JSX at parity.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ roleClass,
+ diffLineKind,
+ formatInteraction,
+ formatInlineValue,
+ formatArgument,
+ userPromptRows,
+ systemEventRows,
+ historyRows,
+ toolInputRows,
+ toolResultSections,
+ imageRows,
+ pendingRows,
+} from './structured-render.js';
+import { roleClass as barrelRoleClass } from './index.js';
+import type {
+ SessionStructuredBlock,
+ SessionStructuredHistory,
+ SessionStructuredSystemEvent,
+ SessionStructuredToolInput,
+ SessionStructuredToolResult,
+ SessionStructuredUserPrompt,
+} from './structured-transcript.js';
+import type { PendingInteraction } from './pending.js';
+
+// Helper: build a tool_result block carrying a typed structured payload.
+function resultBlock(structured: SessionStructuredToolResult): SessionStructuredBlock {
+ return { type: 'tool_result', structured };
+}
+
+// --- roleClass (spec §1) ---------------------------------------------------
+
+test('roleClass maps assistant/agent → assistant, system, result, else user', () => {
+ assert.equal(roleClass('assistant'), 'assistant');
+ assert.equal(roleClass('agent'), 'assistant');
+ assert.equal(roleClass('AGENT'), 'assistant');
+ assert.equal(roleClass('system'), 'system');
+ assert.equal(roleClass('result'), 'result');
+ assert.equal(roleClass('user'), 'user');
+ assert.equal(roleClass('tool'), 'user');
+ assert.equal(roleClass(''), 'user');
+});
+
+// --- diffLineKind (spec __diffRules__) -------------------------------------
+
+test('diffLineKind classifies each prefix with the load-bearing order', () => {
+ assert.equal(diffLineKind('@@ -1 +1 @@'), 'hunk');
+ assert.equal(diffLineKind('diff --git a/x b/x'), 'file');
+ assert.equal(diffLineKind('index abc..def 100644'), 'file');
+ assert.equal(diffLineKind('*** Update File: src/app.ts'), 'file');
+ // `---`/`+++` file headers must match as file BEFORE the +/- add/del rules.
+ assert.equal(diffLineKind('--- a/src/app.ts'), 'file');
+ assert.equal(diffLineKind('+++ b/src/app.ts'), 'file');
+ assert.equal(diffLineKind('+ new line'), 'add');
+ assert.equal(diffLineKind('- old line'), 'del');
+ assert.equal(diffLineKind(' context line'), 'context');
+ assert.equal(diffLineKind(''), 'context');
+});
+
+// --- formatInlineValue / formatArgument (spec §10) -------------------------
+
+test('formatInlineValue renders null/undefined as empty and primitives verbatim', () => {
+ assert.equal(formatInlineValue(null), '');
+ assert.equal(formatInlineValue(undefined), '');
+ assert.equal(formatInlineValue('hello'), 'hello');
+ assert.equal(formatInlineValue(42), '42');
+ assert.equal(formatInlineValue(0), '0');
+ assert.equal(formatInlineValue(true), 'true');
+ assert.equal(formatInlineValue({ a: 1 }), '{"a":1}');
+});
+
+test('formatArgument renders name: value, defaulting name and inlining non-string value', () => {
+ assert.equal(
+ formatArgument({ name: 'Select rollout scope', value: 'All providers' }),
+ 'Select rollout scope: All providers',
+ );
+ assert.equal(formatArgument({ value: 'x' }), 'argument: x');
+ assert.equal(formatArgument({ name: 'count', value: 5 }), 'count: 5');
+ assert.equal(formatArgument('plain'), 'plain');
+});
+
+// --- formatInteraction (spec §9) -------------------------------------------
+
+test('formatInteraction joins kind/state/request/action/prompt/options filtered', () => {
+ const block: SessionStructuredBlock = {
+ type: 'interaction',
+ interaction: {
+ kind: 'approval',
+ state: 'awaiting_user',
+ request_id: 'approval-1',
+ action: 'Approve',
+ prompt: 'Allow Edit to modify src/app.ts?',
+ options: ['Approve', 'Deny'],
+ },
+ };
+ assert.equal(
+ formatInteraction(block),
+ 'approval awaiting_user approval-1 Approve Allow Edit to modify src/app.ts? Approve, Deny',
+ );
+});
+
+test('formatInteraction defaults the kind to "interaction" and drops empty parts', () => {
+ assert.equal(formatInteraction({ type: 'interaction' }), 'interaction');
+ assert.equal(
+ formatInteraction({ type: 'interaction', interaction: { state: 'pending' } }),
+ 'interaction pending',
+ );
+});
+
+// --- userPromptRows (spec §2) ----------------------------------------------
+
+test('userPromptRows renders prompt, opened files, uploaded files, and selections', () => {
+ const prompt: SessionStructuredUserPrompt = {
+ text: 'Please inspect this.',
+ opened_files: ['/tmp/project/src/app.ts'],
+ uploaded_files: [
+ {
+ original_name: 'diagram.png',
+ size: '12 KB',
+ mime_type: 'image/png',
+ file_path: '/tmp/uploads/diagram.png',
+ },
+ ],
+ selections: [{ text: 'const answer = 42;' }],
+ };
+ assert.deepEqual(userPromptRows(prompt), [
+ 'prompt: Please inspect this.',
+ 'opened files: /tmp/project/src/app.ts',
+ 'uploaded files:',
+ 'diagram.png (12 KB, image/png): /tmp/uploads/diagram.png'.replace(/^/, '- '),
+ 'selections:',
+ '- const answer = 42;',
+ ]);
+});
+
+test('userPromptRows renders an uploaded file with a preview suffix and no path', () => {
+ assert.deepEqual(
+ userPromptRows({
+ uploaded_files: [{ original_name: 'note.txt', preview_url: 'https://ex/p' }],
+ }),
+ ['uploaded files:', '- note.txt preview: https://ex/p'],
+ );
+});
+
+test('userPromptRows is empty for an empty prompt', () => {
+ assert.deepEqual(userPromptRows({}), []);
+});
+
+// --- systemEventRows (spec §3) ---------------------------------------------
+
+test('systemEventRows renders kind/category/code/message in order', () => {
+ const event: SessionStructuredSystemEvent = {
+ kind: 'error',
+ category: 'usage_limit',
+ code: 'usage_limit_exceeded',
+ message: "You've hit your usage limit.",
+ };
+ assert.deepEqual(systemEventRows(event), [
+ 'kind: error',
+ 'category: usage_limit',
+ 'code: usage_limit_exceeded',
+ "message: You've hit your usage limit.",
+ ]);
+});
+
+// --- historyRows (spec §4) -------------------------------------------------
+
+test('historyRows renders stream/generation/continuity/tail/diagnostics in order', () => {
+ const history: SessionStructuredHistory = {
+ transcript_stream_id: 'stream-open-code-1',
+ provider_session_id: 'provider-session-99',
+ generation: { id: 'generation-1', observed_at: '2026-04-18T20:00:00Z' },
+ cursor: { after_entry_id: 'entry-42', resume_token: 'st1.history-rows' },
+ continuity: { status: 'compacted', has_branches: true, note: 'compacted transcript' },
+ tail_state: {
+ activity: 'in-turn',
+ last_entry_id: 'entry-42',
+ open_tool_call_ids: ['tool-open'],
+ pending_interaction_ids: ['approval-1'],
+ degraded: true,
+ degraded_reason: 'reader recovering',
+ },
+ diagnostics: [{ code: 'partial_history', count: 2, message: 'older entries compacted' }],
+ };
+ assert.deepEqual(historyRows(history), [
+ 'stream: stream-open-code-1',
+ 'provider session: provider-session-99',
+ 'generation: generation-1',
+ 'observed: 2026-04-18T20:00:00Z',
+ 'cursor: entry-42',
+ 'continuity: compacted',
+ 'branches: yes',
+ 'note: compacted transcript',
+ 'activity: in-turn',
+ 'last entry: entry-42',
+ 'open tools: tool-open',
+ 'pending: approval-1',
+ 'degraded: yes',
+ 'degraded reason: reader recovering',
+ 'diagnostic: code: partial_history, count: 2, message: older entries compacted',
+ ]);
+});
+
+test('historyRows includes a zero compaction count (appendNumber keeps zero)', () => {
+ const history: SessionStructuredHistory = {
+ transcript_stream_id: 's',
+ generation: { id: 'g' },
+ cursor: { resume_token: 'st1.minimal-history' },
+ continuity: { status: 'continuous', compaction_count: 0 },
+ tail_state: { activity: 'idle' },
+ };
+ assert.deepEqual(historyRows(history), [
+ 'stream: s',
+ 'generation: g',
+ 'continuity: continuous',
+ 'compactions: 0',
+ 'activity: idle',
+ ]);
+});
+
+// --- toolInputRows (spec §7) -----------------------------------------------
+
+test('toolInputRows renders fields in the appendField order', () => {
+ const input: SessionStructuredToolInput = {
+ kind: 'patch',
+ file_path: 'src/app.ts',
+ language: 'typescript',
+ patch: '*** Update File: src/app.ts',
+ };
+ assert.deepEqual(toolInputRows(input), [
+ 'kind: patch',
+ 'file: src/app.ts',
+ 'language: typescript',
+ 'patch: *** Update File: src/app.ts',
+ ]);
+});
+
+test('toolInputRows renders plan steps and stdin linked command', () => {
+ assert.deepEqual(
+ toolInputRows({
+ kind: 'plan',
+ plan: 'Expose typed plan data without HTML.',
+ explanation: 'Keep clients provider-neutral.',
+ steps: [{ step: 'Add plan DTO', status: 'in_progress' }],
+ }),
+ [
+ 'kind: plan',
+ 'plan: Expose typed plan data without HTML.',
+ 'explanation: Keep clients provider-neutral.',
+ 'steps:',
+ '- [in_progress] Add plan DTO',
+ ],
+ );
+ assert.deepEqual(
+ toolInputRows({
+ kind: 'stdin',
+ task_id: '42',
+ text: 'hello\n',
+ linked_command: 'claude --resume',
+ }),
+ ['kind: stdin', 'task: 42', 'linked command: claude --resume', 'text: hello\n'],
+ );
+});
+
+test('toolInputRows renders typed todos and arguments', () => {
+ assert.deepEqual(
+ toolInputRows({
+ kind: 'todo',
+ todos: [
+ {
+ content: 'Normalize typed todos',
+ status: 'in_progress',
+ active_form: 'Normalizing typed todos',
+ priority: 'high',
+ },
+ ],
+ }),
+ [
+ 'kind: todo',
+ 'todos:',
+ '- [in_progress] Normalize typed todos priority high (Normalizing typed todos)',
+ ],
+ );
+ assert.deepEqual(
+ toolInputRows({
+ kind: 'arguments',
+ arguments: [
+ { name: 'a', value: '1' },
+ { name: 'b', value: '2' },
+ ],
+ }),
+ ['kind: arguments', 'a: 1', 'b: 2'],
+ );
+});
+
+test('toolInputRows falls back to inline value when no rows accumulate', () => {
+ assert.deepEqual(
+ toolInputRows({} as unknown as SessionStructuredToolInput),
+ ['{}'],
+ );
+});
+
+// --- toolResultSections per kind (spec §8 + __perKindRendering__) ----------
+
+test('toolResultSections bash renders command/task/stdout lines/timestamp and tool error', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'bash',
+ command: 'npm test',
+ task_id: 'shell-123',
+ task_status: 'completed',
+ stdout: 'tests passed',
+ stderr: 'warn',
+ exit_code: 0,
+ stdout_lines: 1,
+ stderr_lines: 1,
+ timestamp: '2026-06-01T00:00:02Z',
+ error: {
+ category: 'command_failure',
+ message: 'npm ERR! test failed',
+ user_reason: 'stopped by user',
+ },
+ }),
+ );
+ assert.equal(sections.kind, 'bash');
+ assert.equal(sections.diff, '');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: bash',
+ 'error category: command_failure',
+ 'error: npm ERR! test failed',
+ 'user reason: stopped by user',
+ 'command: npm test',
+ 'task: shell-123',
+ 'task status: completed',
+ 'stdout: tests passed',
+ 'stderr: warn',
+ 'stdout lines: 1',
+ 'stderr lines: 1',
+ 'timestamp: 2026-06-01T00:00:02Z',
+ 'exit 0',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections python renders code/stdout/stderr/exit', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'python',
+ code: 'print(1)',
+ stdout: 'out',
+ stderr: 'err',
+ exit_code: 0,
+ truncated: true,
+ }),
+ );
+ assert.equal(sections.kind, 'python');
+ assert.equal(
+ sections.body,
+ 'kind: python\ncode: print(1)\nstdout: out\nstderr: err\nexit 0\ntruncated',
+ );
+});
+
+test('toolResultSections stdin renders task/content/text', () => {
+ const sections = toolResultSections(
+ resultBlock({ kind: 'stdin', task_id: '42', content: 'sent' }),
+ );
+ assert.equal(sections.kind, 'stdin');
+ assert.equal(sections.body, 'kind: stdin\ntask: 42\ncontent: sent');
+});
+
+test('toolResultSections edit renders fields plus a diff from patch_hunks', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'edit',
+ file_path: 'src/app.ts',
+ language: 'typescript',
+ old_string: 'old line',
+ new_string: 'new line',
+ original_file: 'export const message = "old line";\n',
+ replace_all: false,
+ user_modified: false,
+ patch_hunks: [
+ {
+ file_path: 'src/app.ts',
+ old_start: 1,
+ old_lines: 1,
+ new_start: 1,
+ new_lines: 1,
+ lines: ['- old line', '+ new line'],
+ },
+ ],
+ }),
+ );
+ assert.equal(sections.kind, 'edit');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: edit',
+ 'file: src/app.ts',
+ 'language: typescript',
+ 'old: old line',
+ 'new: new line',
+ // original_file carries a trailing newline; appendField preserves it verbatim.
+ 'original file: export const message = "old line";\n',
+ 'replace all: false',
+ 'user modified: false',
+ ].join('\n'),
+ );
+ assert.equal(sections.diff, '*** Update File: src/app.ts\n@@ -1 +1 @@\n- old line\n+ new line');
+ // The diff text classifies via diffLineKind at the spec's load-bearing order.
+ const kinds = sections.diff.split('\n').map(diffLineKind);
+ assert.deepEqual(kinds, ['file', 'hunk', 'del', 'add']);
+});
+
+test('toolResultSections places appendToolError in the shared preamble for any kind', () => {
+ // The oracle only drove the error block through bash; pin its placement in
+ // the common preamble by exercising a non-bash kind (read).
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'read',
+ content: 'file body',
+ error: { category: 'file_error', message: 'no such file' },
+ }),
+ );
+ assert.equal(sections.kind, 'read');
+ assert.match(sections.body, /error category: file_error/);
+ assert.match(sections.body, /error: no such file/);
+});
+
+test('toolResultSections edit prefers an explicit patch string over hunks', () => {
+ const sections = toolResultSections(
+ resultBlock({ kind: 'edit', patch: 'explicit patch', patch_hunks: [{ lines: ['x'] }] }),
+ );
+ assert.equal(sections.diff, 'explicit patch');
+});
+
+test('toolResultSections read renders content and numeric line fields', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'read',
+ content: 'file body',
+ start_line: 1,
+ num_lines: 10,
+ total_lines: 100,
+ }),
+ );
+ assert.equal(sections.kind, 'read');
+ assert.equal(sections.body, 'kind: read\ncontent: file body\nstart: 1\nlines: 10\ntotal: 100');
+});
+
+test('toolResultSections write renders the body and a patch diff', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'write',
+ file_path: 'notes.txt',
+ language: 'text',
+ content: 'wrote notes.txt',
+ num_lines: 1,
+ }),
+ );
+ assert.equal(sections.kind, 'write');
+ assert.equal(
+ sections.body,
+ 'kind: write\nfile: notes.txt\nlanguage: text\ncontent: wrote notes.txt\nlines: 1',
+ );
+ assert.equal(sections.diff, '');
+});
+
+test('toolResultSections fetch renders url/status/bytes/duration', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'fetch',
+ url: 'https://example.com/spec',
+ status_code: 200,
+ status_text: 'OK',
+ bytes: 4096,
+ duration_ms: 83,
+ content: 'Fetched structured spec content.',
+ }),
+ );
+ assert.equal(sections.kind, 'fetch');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: fetch',
+ 'url: https://example.com/spec',
+ 'status: 200',
+ 'status text: OK',
+ 'bytes: 4096',
+ 'duration ms: 83',
+ 'content: Fetched structured spec content.',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections todo renders old/new todo lists', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'todo',
+ content: 'todos updated',
+ old_todos: [
+ {
+ content: 'Normalize typed todos',
+ status: 'in_progress',
+ active_form: 'Normalizing typed todos',
+ },
+ ],
+ new_todos: [
+ {
+ content: 'Normalize typed todos',
+ status: 'completed',
+ active_form: 'Normalizing typed todos',
+ },
+ ],
+ }),
+ );
+ assert.equal(sections.kind, 'todo');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: todo',
+ 'content: todos updated',
+ 'old todos:',
+ '- [in_progress] Normalize typed todos (Normalizing typed todos)',
+ 'new todos:',
+ '- [completed] Normalize typed todos (Normalizing typed todos)',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections plan renders plan/explanation/steps', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'plan',
+ plan: 'Expose typed plan data without HTML.',
+ content: 'plan captured',
+ }),
+ );
+ assert.equal(sections.kind, 'plan');
+ assert.equal(
+ sections.body,
+ 'kind: plan\nplan: Expose typed plan data without HTML.\ncontent: plan captured',
+ );
+});
+
+test('toolResultSections question renders questions/options/answer/answers', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'question',
+ question: 'Select rollout scope',
+ questions: [
+ {
+ question: 'Select rollout scope',
+ header: 'Scope',
+ multi_select: true,
+ options: [
+ { label: 'All providers', description: 'Validate first-class and graceful providers' },
+ { label: 'Claude only', description: 'Narrow smoke test' },
+ ],
+ },
+ ],
+ options: ['All providers', 'Claude only'],
+ answer: 'All providers',
+ answers: [{ name: 'Select rollout scope', value: 'All providers' }],
+ content: 'question answered',
+ }),
+ );
+ assert.equal(sections.kind, 'question');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: question',
+ 'question: Select rollout scope',
+ 'questions:',
+ '- Scope | Select rollout scope | multi-select',
+ ' options: All providers | Validate first-class and graceful providers; Claude only | Narrow smoke test',
+ 'options: All providers, Claude only',
+ 'answer: All providers',
+ 'answers:',
+ '- Select rollout scope: All providers',
+ 'content: question answered',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections task renders task fields and total tool calls', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'task',
+ task_id: 'task-123',
+ task_type: 'subagent',
+ task_status: 'completed',
+ description: 'Run delegated check',
+ total_duration_ms: 1234,
+ total_tokens: 321,
+ total_tool_use_count: 4,
+ output: 'delegated check passed',
+ exit_code: 0,
+ }),
+ );
+ assert.equal(sections.kind, 'task');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: task',
+ 'task: task-123',
+ 'task type: subagent',
+ 'task status: completed',
+ 'description: Run delegated check',
+ 'total duration ms: 1234',
+ 'total tokens: 321',
+ 'total tool calls: 4',
+ 'output: delegated check passed',
+ 'exit 0',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections grep renders count mode with files/counts/results/limit', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'grep',
+ mode: 'count',
+ filenames: ['README.md', 'src/app.ts'],
+ counts: [
+ { name: 'README.md', value: '2' },
+ { name: 'src/app.ts', value: '5' },
+ ],
+ num_files: 2,
+ num_results: 7,
+ applied_limit: 100,
+ content: 'README.md:2\nsrc/app.ts:5\n',
+ }),
+ );
+ assert.equal(sections.kind, 'grep');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: grep',
+ 'files: README.md, src/app.ts',
+ 'mode: count',
+ 'counts:',
+ '- README.md: 2',
+ '- src/app.ts: 5',
+ 'content: README.md:2\nsrc/app.ts:5\n',
+ 'files: 2',
+ 'results: 7',
+ 'applied limit: 100',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections search renders result items', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'search',
+ query: 'structured tool result formats',
+ mode: 'query',
+ filenames: ['https://example.com/provider-format'],
+ num_results: 1,
+ result_items: [
+ {
+ title: 'Provider format notes',
+ url: 'https://example.com/provider-format',
+ snippet: 'Typed provider-neutral search item.',
+ },
+ ],
+ content: 'https://example.com/provider-format: Provider format notes\n',
+ }),
+ );
+ assert.equal(sections.kind, 'search');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: search',
+ 'files: https://example.com/provider-format',
+ 'query: structured tool result formats',
+ 'mode: query',
+ 'result items:',
+ '- Provider format notes | https://example.com/provider-format | Typed provider-neutral search item.',
+ 'content: https://example.com/provider-format: Provider format notes\n',
+ 'results: 1',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections glob renders files/duration and truncated flag', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'glob',
+ filenames: ['internal/api/session_structured_types.go'],
+ num_files: 1,
+ duration_ms: 27,
+ truncated: true,
+ }),
+ );
+ assert.equal(sections.kind, 'glob');
+ assert.equal(
+ sections.body,
+ [
+ 'kind: glob',
+ 'files: internal/api/session_structured_types.go',
+ 'files: 1',
+ 'duration ms: 27',
+ 'truncated',
+ ].join('\n'),
+ );
+});
+
+test('toolResultSections generic fallback appends inline value when only kind rendered', () => {
+ const sections = toolResultSections(
+ resultBlock({ kind: 'mystery', foo: 'bar' } as unknown as SessionStructuredToolResult),
+ );
+ assert.equal(sections.kind, 'mystery');
+ assert.equal(sections.body, 'kind: mystery\n{"kind":"mystery","foo":"bar"}');
+ assert.equal(sections.diff, '');
+});
+
+test('toolResultSections generic fallback renders common content/stdout when present', () => {
+ const sections = toolResultSections(
+ resultBlock({
+ kind: 'unknownkind',
+ content: 'plain content',
+ exit_code: 1,
+ } as unknown as SessionStructuredToolResult),
+ );
+ assert.equal(sections.body, 'kind: unknownkind\ncontent: plain content\nexit 1');
+});
+
+test('toolResultSections with no structured payload uses block.content', () => {
+ assert.deepEqual(toolResultSections({ type: 'tool_result', content: 'raw string' }), {
+ kind: 'result',
+ body: 'raw string',
+ diff: '',
+ });
+ assert.deepEqual(toolResultSections({ type: 'tool_result' }), {
+ kind: 'result',
+ body: '',
+ diff: '',
+ });
+});
+
+// --- imageRows (spec §6) ---------------------------------------------------
+
+test('imageRows renders file/url/mime rows', () => {
+ assert.deepEqual(
+ imageRows({
+ type: 'image',
+ file_path: 'screens/shot.png',
+ image_url: 'https://example.com/shot.png',
+ mime_type: 'image/png',
+ }),
+ ['file: screens/shot.png', 'url: https://example.com/shot.png', 'mime: image/png'],
+ );
+ assert.deepEqual(imageRows({ type: 'image' }), []);
+});
+
+// --- pendingRows (spec §9) -------------------------------------------------
+
+test('pendingRows renders kind/request/prompt/options', () => {
+ const pending: PendingInteraction = {
+ kind: 'approval',
+ request_id: 'approval-stream',
+ prompt: 'Approve streamed write?',
+ options: ['Accept', 'Reject'],
+ };
+ assert.deepEqual(pendingRows(pending), [
+ 'kind: approval',
+ 'request: approval-stream',
+ 'prompt: Approve streamed write?',
+ 'options: Accept, Reject',
+ ]);
+});
+
+test('pendingRows omits absent optional fields', () => {
+ assert.deepEqual(pendingRows({ kind: 'approval', request_id: 'r-1' }), [
+ 'kind: approval',
+ 'request: r-1',
+ ]);
+});
+
+// --- barrel re-export ------------------------------------------------------
+
+test('barrel re-exports the structured-render module', () => {
+ assert.equal(barrelRoleClass, roleClass);
+});
diff --git a/internal/api/dashboardspa/web/shared/src/structured-render.ts b/internal/api/dashboardspa/web/shared/src/structured-render.ts
new file mode 100644
index 0000000000..4c4258d80c
--- /dev/null
+++ b/internal/api/dashboardspa/web/shared/src/structured-render.ts
@@ -0,0 +1,727 @@
+// Pure formatting layer for PR #3718's structured transcript rendering, ported
+// from the old dashboard's crew.ts at parity (spec: `.dashport-spec/02-old-render.md`).
+//
+// This module contains NO React and NO DOM construction. Every export returns
+// plain strings, string[], or small section objects; the React layer (Slice 3b)
+// maps those to JSX. The text content produced here is the parity contract — it
+// reproduces the exact ``/header/diff text the old crew.test.ts asserted.
+//
+// Because the wire is now typed (Slice 2's `structured-transcript.ts`), these
+// helpers operate on the typed fields directly instead of the old
+// `recordOf`/`unknown` probing — but the emitted output is byte-for-byte the
+// same as the old `append*` helpers (e.g. `appendField` emits a row only for a
+// non-empty string; `appendNumber` keeps an explicit zero; `formatUsage`'s
+// zero-skip lives in `structured-transcript.ts`).
+
+import { patchTextFromHunks } from './structured-transcript.js';
+import type {
+ SessionStructuredArgument,
+ SessionStructuredBlock,
+ SessionStructuredHistory,
+ SessionStructuredPlanStep,
+ SessionStructuredQuestion,
+ SessionStructuredSearchResultItem,
+ SessionStructuredSystemEvent,
+ SessionStructuredToolError,
+ SessionStructuredToolInput,
+ SessionStructuredToolResult,
+ SessionStructuredTodoItem,
+ SessionStructuredUploadedFile,
+ SessionStructuredUserPrompt,
+} from './structured-transcript.js';
+import type { PendingInteraction } from './pending.js';
+
+// `formatUsage` and `patchTextFromHunks` stay owned by `structured-transcript.ts`
+// (already barrel-exported there); this module imports them internally and does
+// NOT re-export them, so the package barrel has a single source for each symbol.
+
+// ---------------------------------------------------------------------------
+// Low-level value coercion (internal). Typed inputs make most of the old
+// `recordOf` probing unnecessary, but `formatArgument` still faces genuinely
+// `unknown` values (argument records whose `value` can be any JSON type).
+// ---------------------------------------------------------------------------
+
+function recordOf(value: unknown): Record | null {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+// ---------------------------------------------------------------------------
+// Row helpers (internal). Each mutates the `rows` array in place, matching the
+// old crew.ts `append*` signatures and emission rules exactly.
+// ---------------------------------------------------------------------------
+
+function appendField(rows: string[], label: string, value: string | undefined): void {
+ if (value === undefined || value === '') return;
+ rows.push(`${label}: ${value}`);
+}
+
+function appendNumber(rows: string[], label: string, value: number | undefined): void {
+ if (value === undefined) return;
+ rows.push(`${label}: ${String(value)}`);
+}
+
+function appendBoolean(rows: string[], label: string, value: boolean | undefined): void {
+ if (value === undefined) return;
+ rows.push(`${label}: ${String(value)}`);
+}
+
+function appendExit(rows: string[], value: number | undefined): void {
+ if (value === undefined) return;
+ rows.push(`exit ${String(value)}`);
+}
+
+function appendFlags(
+ rows: string[],
+ structured: Extract,
+): void {
+ if (structured.truncated === true) rows.push('truncated');
+ if ('interrupted' in structured && structured.interrupted === true) rows.push('interrupted');
+}
+
+function appendStringList(
+ rows: string[],
+ label: string,
+ value: readonly string[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ const parts = value.filter((item) => item !== '');
+ if (parts.length === 0) return;
+ rows.push(`${label}: ${parts.join(', ')}`);
+}
+
+function appendUploadedFiles(
+ rows: string[],
+ value: readonly SessionStructuredUploadedFile[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push('uploaded files:');
+ for (const file of value) {
+ const name = file.original_name ?? '';
+ const size = file.size ?? '';
+ const mime = file.mime_type ?? '';
+ const path = file.file_path ?? '';
+ const preview = file.preview_url ?? '';
+ const detail = [size, mime].filter((part) => part !== '').join(', ');
+ const suffix = preview !== '' ? ` preview: ${preview}` : '';
+ rows.push(`- ${name}${detail !== '' ? ` (${detail})` : ''}${path !== '' ? `: ${path}` : ''}${suffix}`);
+ }
+}
+
+function appendIDESelections(
+ rows: string[],
+ value: readonly { text?: string }[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ const selections = value.map((item) => item.text ?? '').filter((text) => text !== '');
+ if (selections.length === 0) return;
+ rows.push('selections:');
+ for (const selection of selections) rows.push(`- ${selection}`);
+}
+
+function appendPlanSteps(
+ rows: string[],
+ value: readonly SessionStructuredPlanStep[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push('steps:');
+ value.forEach((step, index) => {
+ const text = step.step ?? '';
+ const status = step.status ?? '';
+ const parts = [
+ status !== '' ? `[${status}]` : '',
+ text !== '' ? text : `step ${index + 1}`,
+ ].filter((part) => part !== '');
+ rows.push(`- ${parts.join(' ')}`);
+ });
+}
+
+function appendArgumentList(
+ rows: string[],
+ label: string,
+ value: readonly SessionStructuredArgument[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push(`${label}:`);
+ for (const item of value) {
+ const formatted = formatArgument(item);
+ if (formatted !== '') rows.push(`- ${formatted}`);
+ }
+}
+
+function appendSearchResultItems(
+ rows: string[],
+ value: readonly SessionStructuredSearchResultItem[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push('result items:');
+ value.forEach((item, index) => {
+ const title = item.title ?? '';
+ const url = item.url ?? '';
+ const snippet = item.snippet ?? '';
+ const label = title !== '' ? title : `result ${index + 1}`;
+ const parts = [label, url, snippet].filter((part) => part !== '');
+ rows.push(`- ${parts.join(' | ')}`);
+ });
+}
+
+function appendQuestions(
+ rows: string[],
+ value: readonly SessionStructuredQuestion[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push('questions:');
+ value.forEach((question, index) => {
+ const text = question.question ?? '';
+ const header = question.header ?? '';
+ const multiSelect = question.multi_select === true ? 'multi-select' : '';
+ const label = text !== '' ? text : `question ${index + 1}`;
+ const parts = [header, label, multiSelect].filter((part) => part !== '');
+ rows.push(`- ${parts.join(' | ')}`);
+ const options = question.options;
+ if (options !== undefined && options !== null && options.length > 0) {
+ const rendered = options
+ .map((option) => {
+ const optionLabel = option.label ?? '';
+ const description = option.description ?? '';
+ return [optionLabel, description].filter((part) => part !== '').join(' | ');
+ })
+ .filter((part) => part !== '');
+ if (rendered.length > 0) rows.push(` options: ${rendered.join('; ')}`);
+ }
+ });
+}
+
+function appendTodoList(
+ rows: string[],
+ label: string,
+ value: readonly SessionStructuredTodoItem[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push(`${label}:`);
+ value.forEach((todo, index) => {
+ const status = todo.status ?? '';
+ const content = todo.content ?? '';
+ const activeForm = todo.active_form ?? '';
+ const priority = todo.priority ?? '';
+ const parts = [
+ status !== '' ? `[${status}]` : '',
+ content !== '' ? content : `todo ${index + 1}`,
+ priority !== '' ? `priority ${priority}` : '',
+ activeForm !== '' ? `(${activeForm})` : '',
+ ].filter((part) => part !== '');
+ rows.push(`- ${parts.join(' ')}`);
+ });
+}
+
+function appendToolError(rows: string[], value: SessionStructuredToolError | undefined): void {
+ if (value === undefined) return;
+ appendField(rows, 'error category', value.category);
+ appendField(rows, 'error', value.message);
+ appendField(rows, 'user reason', value.user_reason);
+}
+
+// ---------------------------------------------------------------------------
+// Inline value / argument formatting.
+// ---------------------------------------------------------------------------
+
+/**
+ * Render an arbitrary value to a single inline string: `null`/`undefined` → "";
+ * a string → itself; number/boolean → `String(value)`; anything else →
+ * `JSON.stringify` (falling back to `String` if that throws). Spec §10.
+ */
+export function formatInlineValue(value: unknown): string {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+}
+
+/**
+ * Render a `{name, value}` argument record to `": "`. A non-record
+ * falls back to `formatInlineValue`; a missing `name` defaults to `"argument"`;
+ * a non-string `value` is rendered via `formatInlineValue`. Spec §10.
+ */
+export function formatArgument(value: unknown): string {
+ const argument = recordOf(value);
+ if (argument === null) return formatInlineValue(value);
+ const name = typeof argument.name === 'string' ? argument.name : 'argument';
+ const argValue = typeof argument.value === 'string' ? argument.value : formatInlineValue(argument.value);
+ return `${name}: ${argValue}`;
+}
+
+// ---------------------------------------------------------------------------
+// CSS-class helpers.
+// ---------------------------------------------------------------------------
+
+/**
+ * Map a message role to its header class suffix: `assistant`/`agent` →
+ * "assistant", `system` → "system", `result` → "result", anything else →
+ * "user". Spec §1.
+ */
+export function roleClass(role: string): string {
+ switch ((role ?? '').toLowerCase()) {
+ case 'assistant':
+ case 'agent':
+ return 'assistant';
+ case 'system':
+ return 'system';
+ case 'result':
+ return 'result';
+ default:
+ return 'user';
+ }
+}
+
+/** Semantic class of a unified-diff line; the React layer maps each kind to a style. */
+export type DiffLineKind = 'hunk' | 'file' | 'add' | 'del' | 'context';
+
+/**
+ * Classify a unified-diff line. The prefix checks run top-down (first match
+ * wins) and the order is load-bearing — `---`/`+++` file headers must be matched
+ * before the single `-`/`+` add/del rules. Spec __diffRules__ (the old dashboard
+ * baked these into `log-msg-diff-*` CSS classes; the new SPA maps the kind to
+ * Tailwind, so this returns the semantic kind, not a class string).
+ */
+export function diffLineKind(line: string): DiffLineKind {
+ if (line.startsWith('@@')) return 'hunk';
+ if (
+ line.startsWith('diff --git') ||
+ line.startsWith('index ') ||
+ line.startsWith('*** ') ||
+ line.startsWith('---') ||
+ line.startsWith('+++')
+ ) {
+ return 'file';
+ }
+ if (line.startsWith('+')) return 'add';
+ if (line.startsWith('-')) return 'del';
+ return 'context';
+}
+
+// ---------------------------------------------------------------------------
+// Interaction / pending.
+// ---------------------------------------------------------------------------
+
+/**
+ * Render an `interaction` block to its single summary line:
+ * `[kind, state, request_id, action, prompt, options.join(", ")]` with the
+ * empty parts filtered out and the rest space-joined. `kind` defaults to
+ * "interaction". Spec §9.
+ */
+export function formatInteraction(block: SessionStructuredBlock): string {
+ const interaction =
+ block.type === 'interaction' || block.type === 'unknown' ? block.interaction : undefined;
+ const kind = interaction?.kind ?? 'interaction';
+ const state = interaction?.state ?? '';
+ const prompt = interaction?.prompt ?? '';
+ const requestID = interaction?.request_id ?? '';
+ const action = interaction?.action ?? '';
+ const options = interaction?.options?.join(', ') ?? '';
+ return [kind, state, requestID, action, prompt, options].filter(Boolean).join(' ');
+}
+
+/** Build the `` rows for a streamed pending-interaction frame. Spec §9. */
+export function pendingRows(pending: PendingInteraction): string[] {
+ const rows: string[] = [];
+ appendField(rows, 'kind', pending.kind);
+ appendField(rows, 'request', pending.request_id);
+ appendField(rows, 'prompt', pending.prompt);
+ appendStringList(rows, 'options', pending.options === undefined ? undefined : [...pending.options]);
+ return rows;
+}
+
+// ---------------------------------------------------------------------------
+// Metadata / history rows.
+// ---------------------------------------------------------------------------
+
+/** Build the user-prompt metadata rows (prompt text, opened/uploaded files, IDE selections). Spec §2. */
+export function userPromptRows(prompt: SessionStructuredUserPrompt): string[] {
+ const rows: string[] = [];
+ appendField(rows, 'prompt', prompt.text);
+ appendStringList(rows, 'opened files', prompt.opened_files);
+ appendUploadedFiles(rows, prompt.uploaded_files);
+ appendIDESelections(rows, prompt.selections);
+ return rows;
+}
+
+/** Build the system-event metadata rows (kind/category/code/message, in order). Spec §3. */
+export function systemEventRows(event: SessionStructuredSystemEvent): string[] {
+ const rows: string[] = [];
+ appendField(rows, 'kind', event.kind);
+ appendField(rows, 'category', event.category);
+ appendField(rows, 'code', event.code);
+ appendField(rows, 'message', event.message);
+ return rows;
+}
+
+/** Build the structured-history envelope rows in full spec order, including diagnostics. Spec §4. */
+export function historyRows(history: SessionStructuredHistory): string[] {
+ const rows: string[] = [];
+ appendField(rows, 'stream', history.transcript_stream_id);
+ appendField(rows, 'provider session', history.provider_session_id);
+ appendField(rows, 'conversation', history.logical_conversation_id);
+ appendField(rows, 'gc session', history.gc_session_id);
+
+ appendField(rows, 'generation', history.generation.id);
+ appendField(rows, 'observed', history.generation.observed_at);
+
+ appendField(rows, 'cursor', history.cursor.after_entry_id);
+
+ appendField(rows, 'continuity', history.continuity.status);
+ appendNumber(rows, 'compactions', history.continuity.compaction_count);
+ if (history.continuity.has_branches === true) rows.push('branches: yes');
+ appendField(rows, 'note', history.continuity.note);
+
+ appendField(rows, 'activity', history.tail_state.activity);
+ appendField(rows, 'last entry', history.tail_state.last_entry_id);
+ appendStringList(rows, 'open tools', history.tail_state.open_tool_call_ids);
+ appendStringList(rows, 'pending', history.tail_state.pending_interaction_ids);
+ if (history.tail_state.degraded === true) rows.push('degraded: yes');
+ appendField(rows, 'degraded reason', history.tail_state.degraded_reason);
+
+ for (const diagnostic of history.diagnostics ?? []) {
+ const parts: string[] = [];
+ appendField(parts, 'code', diagnostic.code);
+ appendNumber(parts, 'count', diagnostic.count);
+ appendField(parts, 'message', diagnostic.message);
+ if (parts.length > 0) rows.push(`diagnostic: ${parts.join(', ')}`);
+ }
+
+ return rows;
+}
+
+// ---------------------------------------------------------------------------
+// Image block.
+// ---------------------------------------------------------------------------
+
+/** Build the image-block metadata rows (file/url/mime). The `
` itself is the React layer's job. Spec §6. */
+export function imageRows(block: SessionStructuredBlock): string[] {
+ const rows: string[] = [];
+ if (block.type !== 'image' && block.type !== 'unknown') return rows;
+ appendField(rows, 'file', block.file_path);
+ appendField(rows, 'url', block.image_url);
+ appendField(rows, 'mime', block.mime_type);
+ return rows;
+}
+
+// ---------------------------------------------------------------------------
+// Tool input.
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the tool-input `` rows for a `tool_use` block, in the exact
+ * appendField/list ordering of the old `renderToolInput`. When the block has no
+ * structured input, falls back to a single `formatInlineValue` line (or an empty
+ * row list when input is absent). Spec §7.
+ */
+export function toolInputRows(input: SessionStructuredToolInput): string[] {
+ const rows: string[] = [];
+ appendField(rows, 'kind', input.kind);
+
+ switch (input.kind) {
+ case 'command':
+ appendField(rows, 'command', input.command);
+ appendArgumentRows(rows, input.arguments);
+ break;
+ case 'stdin':
+ appendField(rows, 'task', input.task_id);
+ appendField(rows, 'linked command', input.linked_command);
+ appendField(rows, 'text', input.text);
+ break;
+ case 'code':
+ appendField(rows, 'language', input.language);
+ appendField(rows, 'code', input.code);
+ break;
+ case 'patch':
+ appendField(rows, 'file', input.file_path);
+ appendField(rows, 'language', input.language);
+ appendField(rows, 'patch', input.patch);
+ break;
+ case 'write':
+ appendField(rows, 'file', input.file_path);
+ appendField(rows, 'language', input.language);
+ appendField(rows, 'text', input.text);
+ break;
+ case 'glob':
+ case 'search':
+ appendField(rows, 'file', input.file_path);
+ if (input.kind === 'search') appendField(rows, 'command', input.command);
+ appendField(rows, 'query', input.query);
+ appendField(rows, 'pattern', input.pattern);
+ appendArgumentRows(rows, input.arguments);
+ break;
+ case 'fetch':
+ appendField(rows, 'url', input.url);
+ appendField(rows, 'prompt', input.prompt);
+ break;
+ case 'file':
+ appendField(rows, 'file', input.file_path);
+ appendField(rows, 'language', input.language);
+ appendField(rows, 'command', input.command);
+ break;
+ case 'todo':
+ appendTodoList(rows, 'todos', input.todos);
+ break;
+ case 'plan':
+ appendField(rows, 'plan', input.plan);
+ appendField(rows, 'explanation', input.explanation);
+ appendPlanSteps(rows, input.steps);
+ break;
+ case 'question':
+ appendField(rows, 'question', input.question);
+ appendStringList(rows, 'options', input.options);
+ break;
+ case 'task':
+ appendField(rows, 'prompt', input.prompt);
+ appendField(rows, 'task', input.task_id);
+ appendField(rows, 'task type', input.task_type);
+ appendField(rows, 'task status', input.task_status);
+ appendField(rows, 'description', input.description);
+ break;
+ case 'text':
+ appendField(rows, 'text', input.text);
+ break;
+ case 'arguments':
+ appendArgumentRows(rows, input.arguments);
+ break;
+ case 'unknown':
+ appendField(rows, 'file', input.file_path);
+ appendField(rows, 'language', input.language);
+ appendField(rows, 'url', input.url);
+ appendField(rows, 'prompt', input.prompt);
+ appendField(rows, 'task', input.task_id);
+ appendField(rows, 'task type', input.task_type);
+ appendField(rows, 'task status', input.task_status);
+ appendField(rows, 'description', input.description);
+ appendField(rows, 'question', input.question);
+ appendStringList(rows, 'options', input.options);
+ appendField(rows, 'command', input.command);
+ appendField(rows, 'linked command', input.linked_command);
+ appendField(rows, 'code', input.code);
+ appendField(rows, 'query', input.query);
+ appendField(rows, 'pattern', input.pattern);
+ appendField(rows, 'plan', input.plan);
+ appendField(rows, 'explanation', input.explanation);
+ appendPlanSteps(rows, input.steps);
+ appendField(rows, 'text', input.text);
+ appendField(rows, 'patch', input.patch);
+ appendTodoList(rows, 'todos', input.todos);
+ appendArgumentRows(rows, input.arguments);
+ break;
+ }
+ if (rows.length === 0) rows.push(formatInlineValue(input));
+ return rows;
+}
+
+function appendArgumentRows(
+ rows: string[],
+ value: readonly SessionStructuredArgument[] | null | undefined,
+): void {
+ if (value === undefined || value === null || value.length === 0) return;
+ rows.push(...value.map((argument) => formatArgument(argument)));
+}
+
+// ---------------------------------------------------------------------------
+// Tool result.
+// ---------------------------------------------------------------------------
+
+/** A rendered tool-result: the title `kind`, the `` body text, and the diff text (empty when none). */
+export interface ToolResultSections {
+ kind: string;
+ body: string;
+ diff: string;
+}
+
+/**
+ * Build the body + diff text for a `tool_result` block, reproducing the old
+ * `renderToolResult`: a common preamble (kind/file/language/error), a per-kind
+ * branch (bash, python, stdin, edit, read, write, fetch, todo, plan, question,
+ * task, and the shared grep|search|glob branch), and a generic fallback. The
+ * `body` is `lines.filter(Boolean).join("\n")`; the `diff` is the edit/write
+ * patch text. When the block carries no structured payload, the body comes from
+ * `block.content` and `kind` is "result". Spec §8 + __perKindRendering__.
+ */
+export function toolResultSections(block: SessionStructuredBlock): ToolResultSections {
+ const structured =
+ block.type === 'tool_result' || block.type === 'unknown' ? block.structured : undefined;
+ if (structured === undefined) {
+ const content =
+ block.type === 'tool_result' || block.type === 'unknown' ? block.content : undefined;
+ if (typeof content === 'string') return { kind: 'result', body: content, diff: '' };
+ if (content !== undefined)
+ return { kind: 'result', body: formatInlineValue(content), diff: '' };
+ return { kind: 'result', body: '', diff: '' };
+ }
+
+ const kind = structured.kind;
+ const lines: string[] = [];
+ appendField(lines, 'kind', kind);
+ appendField(lines, 'file', 'file_path' in structured ? structured.file_path : undefined);
+ appendField(lines, 'language', 'language' in structured ? structured.language : undefined);
+ appendToolError(lines, structured.error);
+
+ if (structured.kind === 'bash') {
+ appendField(lines, 'command', structured.command);
+ appendField(lines, 'task', structured.task_id);
+ appendField(lines, 'task status', structured.task_status);
+ appendField(lines, 'stdout', structured.stdout);
+ appendField(lines, 'stderr', structured.stderr);
+ appendNumber(lines, 'stdout lines', structured.stdout_lines);
+ appendNumber(lines, 'stderr lines', structured.stderr_lines);
+ appendField(lines, 'timestamp', structured.timestamp);
+ appendExit(lines, structured.exit_code);
+ appendFlags(lines, structured);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'python') {
+ appendField(lines, 'code', structured.code);
+ appendField(lines, 'stdout', structured.stdout);
+ appendField(lines, 'stderr', structured.stderr);
+ appendExit(lines, structured.exit_code);
+ appendFlags(lines, structured);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'stdin') {
+ appendField(lines, 'task', structured.task_id);
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'edit') {
+ const patch = (structured.patch ?? '') || patchTextFromHunks(structured.patch_hunks);
+ appendField(lines, 'old', structured.old_string);
+ appendField(lines, 'new', structured.new_string);
+ appendField(lines, 'original file', structured.original_file);
+ appendBoolean(lines, 'replace all', structured.replace_all);
+ appendBoolean(lines, 'user modified', structured.user_modified);
+ appendField(lines, 'content', structured.content);
+ return { kind, body: joinBody(lines), diff: patch };
+ }
+ if (structured.kind === 'read') {
+ appendField(lines, 'content', structured.content);
+ appendNumber(lines, 'start', structured.start_line);
+ appendNumber(lines, 'lines', structured.num_lines);
+ appendNumber(lines, 'total', structured.total_lines);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'write') {
+ const patch = (structured.patch ?? '') || patchTextFromHunks(structured.patch_hunks);
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ appendNumber(lines, 'start', structured.start_line);
+ appendNumber(lines, 'lines', structured.num_lines);
+ appendNumber(lines, 'total', structured.total_lines);
+ return { kind, body: joinBody(lines), diff: patch };
+ }
+ if (structured.kind === 'fetch') {
+ appendField(lines, 'url', structured.url);
+ appendNumber(lines, 'status', structured.status_code);
+ appendField(lines, 'status text', structured.status_text);
+ appendNumber(lines, 'bytes', structured.bytes);
+ appendNumber(lines, 'duration ms', structured.duration_ms);
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'todo') {
+ appendField(lines, 'content', structured.content);
+ appendTodoList(lines, 'old todos', structured.old_todos);
+ appendTodoList(lines, 'new todos', structured.new_todos);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'plan') {
+ appendField(lines, 'plan', structured.plan);
+ appendField(lines, 'explanation', structured.explanation);
+ appendPlanSteps(lines, structured.steps);
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'question') {
+ appendField(lines, 'question', structured.question);
+ appendQuestions(lines, structured.questions);
+ appendStringList(lines, 'options', structured.options);
+ appendField(lines, 'answer', structured.answer);
+ appendArgumentList(lines, 'answers', structured.answers);
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'task') {
+ appendField(lines, 'task', structured.task_id);
+ appendField(lines, 'task type', structured.task_type);
+ appendField(lines, 'task status', structured.task_status);
+ appendField(lines, 'description', structured.description);
+ appendNumber(lines, 'total duration ms', structured.total_duration_ms);
+ appendNumber(lines, 'total tokens', structured.total_tokens);
+ appendNumber(lines, 'total tool calls', structured.total_tool_use_count);
+ appendField(lines, 'output', structured.output);
+ appendField(lines, 'stdout', structured.stdout);
+ appendField(lines, 'stderr', structured.stderr);
+ appendExit(lines, structured.exit_code);
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+ if (structured.kind === 'grep' || structured.kind === 'search') {
+ if (
+ structured.filenames !== undefined &&
+ structured.filenames !== null &&
+ structured.filenames.length > 0
+ ) {
+ appendField(lines, 'files', structured.filenames.join(', '));
+ }
+ appendField(lines, 'query', structured.query);
+ appendField(lines, 'mode', structured.mode);
+ appendArgumentList(lines, 'counts', structured.counts);
+ appendSearchResultItems(lines, structured.result_items);
+ appendField(lines, 'content', structured.content);
+ appendNumber(lines, 'files', structured.num_files);
+ appendNumber(lines, 'results', structured.num_results);
+ appendNumber(lines, 'duration ms', structured.duration_ms);
+ appendNumber(lines, 'applied limit', structured.applied_limit);
+ appendNumber(lines, 'lines', structured.num_lines);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+
+ if (structured.kind === 'glob') {
+ if (
+ structured.filenames !== undefined &&
+ structured.filenames !== null &&
+ structured.filenames.length > 0
+ ) {
+ appendField(lines, 'files', structured.filenames.join(', '));
+ }
+ appendField(lines, 'content', structured.content);
+ appendNumber(lines, 'files', structured.num_files);
+ appendNumber(lines, 'duration ms', structured.duration_ms);
+ appendNumber(lines, 'lines', structured.num_lines);
+ appendFlags(lines, structured);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+
+ if (structured.kind === 'text') {
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ return { kind, body: joinBody(lines), diff: '' };
+ }
+
+ appendField(lines, 'content', structured.content);
+ appendField(lines, 'text', structured.text);
+ appendField(lines, 'stdout', structured.stdout);
+ appendField(lines, 'stderr', structured.stderr);
+ appendExit(lines, structured.exit_code);
+ if (lines.length === 1) lines.push(formatInlineValue(structured));
+ return { kind, body: joinBody(lines), diff: '' };
+}
+
+/** Body text = the non-empty result lines joined by newlines (mirrors `toolResultNodes`). */
+function joinBody(lines: string[]): string {
+ return lines.filter(Boolean).join('\n');
+}
diff --git a/internal/api/dashboardspa/web/shared/src/structured-transcript.test.ts b/internal/api/dashboardspa/web/shared/src/structured-transcript.test.ts
new file mode 100644
index 0000000000..dfa7680811
--- /dev/null
+++ b/internal/api/dashboardspa/web/shared/src/structured-transcript.test.ts
@@ -0,0 +1,243 @@
+// Run with: npx tsx --test shared/src/structured-transcript.test.ts
+//
+// Slice 2 of the structured-transcript port (PR #3718 → new dashboard): the
+// hand-authored wire types, the four accepted-frame shape guards, and the two
+// pure render helpers (patchTextFromHunks, formatUsage). The exact-string
+// assertions reproduce the old dashboard's test-asserted output verbatim so the
+// Slice 3 renderers and Slice 4 stream can match it at parity.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import {
+ patchTextFromHunks,
+ formatUsage,
+ isSessionStructuredEvent,
+ isSessionActivityEvent,
+ isSessionHeartbeatEvent,
+ isSessionStructuredHistory,
+ isStructuredMessage,
+ STRUCTURED_SCHEMA_VERSION,
+ type SessionStructuredPatchHunk,
+ type SessionStructuredUsage,
+ type SessionStreamStructuredMessageEvent,
+} from './structured-transcript.js';
+import { patchTextFromHunks as barrelPatch } from './index.js';
+
+test('STRUCTURED_SCHEMA_VERSION pins the wire schema constant', () => {
+ assert.equal(STRUCTURED_SCHEMA_VERSION, 'session.structured.v1');
+});
+
+test('structured wire DTOs come only from the generated supervisor client', () => {
+ const source = readFileSync(new URL('./structured-transcript.ts', import.meta.url), 'utf8');
+ assert.match(source, /from '.\/generated\/gc-supervisor-client\/types\.gen\.js'/);
+ assert.doesNotMatch(source, /export\s+interface\s+SessionStructured/);
+ assert.doesNotMatch(source, /interface\s+SessionStreamStructuredMessageEventBase/);
+});
+
+test('patchTextFromHunks renders file separator + hunk header + lines', () => {
+ const hunks: SessionStructuredPatchHunk[] = [
+ {
+ file_path: 'src/app.ts',
+ old_start: 1,
+ old_lines: 1,
+ new_start: 1,
+ new_lines: 1,
+ lines: ['- old line', '+ new line'],
+ },
+ ];
+ assert.equal(
+ patchTextFromHunks(hunks),
+ '*** Update File: src/app.ts\n@@ -1 +1 @@\n- old line\n+ new line',
+ );
+});
+
+test('patchTextFromHunks emits multi-line ranges as start,count and single as start', () => {
+ // old_lines=3 → "1,3"; new_lines=2 → "1,2"; no file_path → no separator.
+ assert.equal(
+ patchTextFromHunks([
+ { old_start: 1, old_lines: 3, new_start: 1, new_lines: 2, lines: ['ctx'] },
+ ]),
+ '@@ -1,3 +1,2 @@\nctx',
+ );
+});
+
+test('patchTextFromHunks emits bare @@ when both starts are absent', () => {
+ assert.equal(patchTextFromHunks([{ lines: ['x'] }]), '@@\nx');
+});
+
+test('patchTextFromHunks emits the file separator once per distinct file_path', () => {
+ const same = patchTextFromHunks([
+ { file_path: 'a.ts', old_start: 1, new_start: 1, lines: ['one'] },
+ { file_path: 'a.ts', old_start: 2, new_start: 2, lines: ['two'] },
+ ]);
+ assert.equal(same, '*** Update File: a.ts\n@@ -1 +1 @@\none\n@@ -2 +2 @@\ntwo');
+
+ const cross = patchTextFromHunks([
+ { file_path: 'a.ts', old_start: 1, new_start: 1, lines: ['one'] },
+ { file_path: 'b.ts', old_start: 1, new_start: 1, lines: ['two'] },
+ ]);
+ assert.equal(
+ cross,
+ '*** Update File: a.ts\n@@ -1 +1 @@\none\n*** Update File: b.ts\n@@ -1 +1 @@\ntwo',
+ );
+});
+
+test('patchTextFromHunks returns empty string for empty or absent input', () => {
+ assert.equal(patchTextFromHunks([]), '');
+ assert.equal(patchTextFromHunks(undefined), '');
+});
+
+test('formatUsage renders the full token line in canonical order', () => {
+ const usage: SessionStructuredUsage = {
+ input_tokens: 100,
+ output_tokens: 20,
+ reasoning_tokens: 7,
+ cache_read_tokens: 5,
+ cache_creation_tokens: 3,
+ context_used_tokens: 108,
+ context_window_tokens: 200000,
+ context_percent: 1,
+ };
+ assert.equal(formatUsage(usage), 'tokens in 100 out 20 reason 7 cache 5 write 3 108/200000 1%');
+});
+
+test('formatUsage skips zero token counts but keeps a defined zero percent', () => {
+ assert.equal(formatUsage({ input_tokens: 0, output_tokens: 20 }), 'tokens out 20');
+ assert.equal(formatUsage({ context_percent: 0 }), 'tokens 0%');
+});
+
+test('formatUsage requires both context_used and context_window for the pair', () => {
+ assert.equal(
+ formatUsage({ context_used_tokens: 108, context_window_tokens: 200000 }),
+ 'tokens 108/200000',
+ );
+ assert.equal(formatUsage({ context_used_tokens: 108 }), '');
+});
+
+test('formatUsage returns empty string when nothing renders', () => {
+ assert.equal(formatUsage({}), '');
+ assert.equal(formatUsage(undefined), '');
+});
+
+test('isSessionStructuredEvent accepts a structured envelope and rejects others', () => {
+ const event: SessionStreamStructuredMessageEvent = {
+ id: 'e1',
+ template: 'tmpl',
+ provider: 'claude',
+ format: 'structured',
+ schema_version: STRUCTURED_SCHEMA_VERSION,
+ operation: 'snapshot',
+ history: {
+ transcript_stream_id: 'stream-1',
+ generation: { id: 'generation-1' },
+ cursor: { resume_token: 'st1.snapshot' },
+ continuity: { status: 'continuous' },
+ tail_state: { activity: 'idle' },
+ },
+ structured_messages: [],
+ };
+ assert.equal(isSessionStructuredEvent(event), true);
+ assert.equal(isSessionStructuredEvent({ ...event, operation: 'upsert' }), true);
+ assert.equal(
+ isSessionStructuredEvent({
+ ...event,
+ operation: 'reset',
+ reset_reason: 'stream_changed',
+ }),
+ true,
+ );
+ assert.equal(isSessionStructuredEvent({ format: 'raw', messages: [] }), false);
+ assert.equal(isSessionStructuredEvent({ format: 'structured', structured_messages: 'x' }), false);
+ assert.equal(isSessionStructuredEvent({ ...event, operation: undefined }), false);
+ assert.equal(isSessionStructuredEvent({ ...event, operation: 'append' }), false);
+ assert.equal(isSessionStructuredEvent({ ...event, schema_version: 'session.structured.v2' }), false);
+ assert.equal(isSessionStructuredEvent({ ...event, id: undefined }), false);
+ assert.equal(isSessionStructuredEvent({ ...event, template: undefined }), false);
+ assert.equal(isSessionStructuredEvent({ ...event, provider: undefined }), false);
+ assert.equal(
+ isSessionStructuredEvent({ ...event, structured_messages: [{ blocks: [] }] }),
+ false,
+ );
+ assert.equal(
+ isSessionStructuredEvent({
+ ...event,
+ structured_messages: [
+ {
+ id: 'tool-1',
+ role: 'assistant',
+ status: 'final',
+ blocks: [
+ {
+ type: 'tool_use',
+ input: { kind: 'plan', steps: 'not-an-array' },
+ },
+ ],
+ },
+ ],
+ }),
+ false,
+ );
+ assert.equal(
+ isSessionStructuredEvent({ ...event, operation: 'reset', reset_reason: undefined }),
+ false,
+ );
+ assert.equal(
+ isSessionStructuredEvent({ ...event, operation: 'reset', reset_reason: 'unknown' }),
+ false,
+ );
+ assert.equal(isSessionStructuredEvent({ ...event, history: undefined }), false);
+ assert.equal(
+ isSessionStructuredEvent({
+ ...event,
+ history: { ...event.history, cursor: {} },
+ }),
+ false,
+ );
+ assert.equal(isSessionStructuredEvent('nope'), false);
+ assert.equal(isSessionStructuredEvent(null), false);
+});
+
+test('isSessionActivityEvent / isSessionHeartbeatEvent are shape guards', () => {
+ assert.equal(isSessionActivityEvent({ activity: 'idle' }), true);
+ assert.equal(isSessionActivityEvent({ activity: 5 }), false);
+ assert.equal(isSessionActivityEvent({}), false);
+ assert.equal(isSessionHeartbeatEvent({ timestamp: '2026-06-30T00:00:00Z' }), true);
+ assert.equal(isSessionHeartbeatEvent({}), false);
+});
+
+test('isSessionStructuredHistory requires the load-bearing nested fields', () => {
+ const ok = {
+ transcript_stream_id: 's',
+ generation: { id: 'g' },
+ cursor: { resume_token: 'st1.history' },
+ continuity: { status: 'continuous' },
+ tail_state: { activity: 'idle' },
+ };
+ assert.equal(isSessionStructuredHistory(ok), true);
+ assert.equal(isSessionStructuredHistory({ ...ok, transcript_stream_id: 1 }), false);
+ assert.equal(isSessionStructuredHistory({ ...ok, generation: {} }), false);
+ assert.equal(isSessionStructuredHistory({ ...ok, continuity: {} }), false);
+ assert.equal(isSessionStructuredHistory({ ...ok, tail_state: {} }), false);
+ assert.equal(isSessionStructuredHistory({ ...ok, cursor: {} }), false);
+ assert.equal(isSessionStructuredHistory({ ...ok, cursor: { resume_token: 1 } }), false);
+ assert.equal(isSessionStructuredHistory(null), false);
+ // Intentional hardening over the old guard: an array is not a record, so a
+ // sub-field supplied as an array is rejected (the server never sends one).
+ assert.equal(isSessionStructuredHistory({ ...ok, cursor: [] }), false);
+});
+
+test('isStructuredMessage requires identity, a closed role, status, and typed blocks', () => {
+ const message = { id: 'm1', role: 'assistant', status: 'final', blocks: [] };
+ assert.equal(isStructuredMessage(message), true);
+ assert.equal(isStructuredMessage({ ...message, id: undefined }), false);
+ assert.equal(isStructuredMessage({ ...message, role: 'provider-special' }), false);
+ assert.equal(isStructuredMessage({ ...message, status: undefined }), false);
+ assert.equal(isStructuredMessage({ ...message, blocks: 'x' }), false);
+ assert.equal(isStructuredMessage({ ...message, blocks: [{ type: 'provider-special' }] }), false);
+ assert.equal(isStructuredMessage({}), false);
+});
+
+test('barrel re-exports the structured-transcript module', () => {
+ assert.equal(barrelPatch, patchTextFromHunks);
+});
diff --git a/internal/api/dashboardspa/web/shared/src/structured-transcript.ts b/internal/api/dashboardspa/web/shared/src/structured-transcript.ts
new file mode 100644
index 0000000000..e8c4d7e1c4
--- /dev/null
+++ b/internal/api/dashboardspa/web/shared/src/structured-transcript.ts
@@ -0,0 +1,311 @@
+// Generated structured transcript wire types (`session.structured.v1`) plus
+// the shape guards and pure render helpers the dashboard uses to consume them.
+// The committed OpenAPI contract is the sole owner of every SessionStructured*
+// DTO below; this module only re-exports or derives compatibility names.
+
+import type {
+ PaginationInfo,
+ SessionStreamStructuredMessageEvent,
+ SessionStructuredArgument,
+ SessionStructuredBlock,
+ SessionStructuredContinuity,
+ SessionStructuredCursor,
+ SessionStructuredDiagnostic,
+ SessionStructuredGeneration,
+ SessionStructuredHistory,
+ SessionStructuredIdeSelection,
+ SessionStructuredInteraction,
+ SessionStructuredMessage,
+ SessionStructuredPatchHunk,
+ SessionStructuredPlanStep,
+ SessionStructuredQuestion,
+ SessionStructuredQuestionOption,
+ SessionStructuredSearchResultItem,
+ SessionStructuredSystemEvent,
+ SessionStructuredTailState,
+ SessionStructuredTodoItem,
+ SessionStructuredToolError,
+ SessionStructuredToolInput,
+ SessionStructuredToolResult,
+ SessionStructuredUploadedFile,
+ SessionStructuredUsage,
+ SessionStructuredUserPrompt,
+ SessionTranscriptStructuredResponse,
+} from './generated/gc-supervisor-client/types.gen.js';
+import { zSessionStreamStructuredMessageEvent } from './generated/gc-supervisor-client/zod.gen.js';
+
+export type {
+ SessionStreamStructuredMessageEvent,
+ SessionStructuredArgument,
+ SessionStructuredBlock,
+ SessionStructuredContinuity,
+ SessionStructuredCursor,
+ SessionStructuredDiagnostic,
+ SessionStructuredGeneration,
+ SessionStructuredHistory,
+ SessionStructuredInteraction,
+ SessionStructuredMessage,
+ SessionStructuredPatchHunk,
+ SessionStructuredPlanStep,
+ SessionStructuredQuestion,
+ SessionStructuredQuestionOption,
+ SessionStructuredSearchResultItem,
+ SessionStructuredSystemEvent,
+ SessionStructuredTailState,
+ SessionStructuredTodoItem,
+ SessionStructuredToolError,
+ SessionStructuredToolInput,
+ SessionStructuredToolResult,
+ SessionStructuredUploadedFile,
+ SessionStructuredUsage,
+ SessionStructuredUserPrompt,
+};
+
+/** The structured transcript schema version emitted on the wire. */
+export const STRUCTURED_SCHEMA_VERSION =
+ 'session.structured.v1' satisfies SessionStreamStructuredMessageEvent['schema_version'];
+
+/** How a structured frame is applied to the current transcript projection. */
+export type SessionStructuredOperation = SessionStreamStructuredMessageEvent['operation'];
+
+/** Why a reset frame replaces the current transcript projection. */
+export type SessionStructuredResetReason = NonNullable<
+ SessionStreamStructuredMessageEvent['reset_reason']
+>;
+
+/**
+ * Diagnostic code the server attaches when the provider transcript is
+ * unavailable and it falls back to provider-neutral text.
+ */
+export const STRUCTURED_TRANSCRIPT_UNAVAILABLE_CODE = 'transcript_unavailable';
+
+/** Closed block discriminator generated from the structured wire union. */
+export type StructuredBlockType = SessionStructuredBlock['type'];
+
+/** Closed tool-input discriminator generated from the structured wire union. */
+export type StructuredToolInputKind = SessionStructuredToolInput['kind'];
+
+/** Closed tool-result discriminator generated from the structured wire union. */
+export type StructuredToolResultKind = SessionStructuredToolResult['kind'];
+
+/** REST `…/transcript?format=structured` response. */
+export type SessionStructuredTranscriptResponse = SessionTranscriptStructuredResponse;
+
+/** Pagination envelope (compatibility name for the generated wire type). */
+export type SessionStructuredPagination = PaginationInfo;
+
+/** Compatibility spelling retained for existing dashboard consumers. */
+export type SessionStructuredIDESelection = SessionStructuredIdeSelection;
+
+// ---------------------------------------------------------------------------
+// Non-message stream frames consumed alongside structured frames. Dashboard-
+// owned (the `pending.ts` precedent); the pending frame reuses pending.ts.
+// ---------------------------------------------------------------------------
+
+/** SSE `activity` frame: `idle` | `in-turn` (worker may also emit `unknown`). */
+export interface SessionActivityEvent {
+ activity: string;
+}
+
+/** SSE `heartbeat` keepalive frame. */
+export interface SessionHeartbeatEvent {
+ timestamp: string;
+}
+
+// ---------------------------------------------------------------------------
+// Shape guards. These reproduce the old dashboard's sse.ts/crew.ts guards'
+// accept/reject behavior for real wire frames: shallow envelope discriminators
+// that trust the server contract for the remaining fields. One intentional
+// hardening: `isRecord` excludes arrays (matching this dashboard's pending.ts
+// convention), so an array supplied where an object is expected is rejected.
+// The server never sends arrays for these fields, so real traffic is unchanged.
+// ---------------------------------------------------------------------------
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** True for a `structured` SSE frame / structured transcript body. */
+export function isSessionStructuredEvent(
+ data: unknown,
+): data is SessionStreamStructuredMessageEvent {
+ if (
+ !isRecord(data) ||
+ data.format !== 'structured' ||
+ data.schema_version !== STRUCTURED_SCHEMA_VERSION ||
+ typeof data.id !== 'string' ||
+ typeof data.template !== 'string' ||
+ typeof data.provider !== 'string' ||
+ !Array.isArray(data.structured_messages) ||
+ !data.structured_messages.every(isStructuredMessage) ||
+ !zSessionStreamStructuredMessageEvent.safeParse(data).success
+ ) {
+ return false;
+ }
+ if (!isSessionStructuredHistory(data.history)) return false;
+ switch (data.operation) {
+ case 'snapshot':
+ case 'upsert':
+ return data.reset_reason === undefined;
+ case 'reset':
+ return isSessionStructuredResetReason(data.reset_reason);
+ default:
+ return false;
+ }
+}
+
+function isSessionStructuredResetReason(value: unknown): value is SessionStructuredResetReason {
+ return (
+ value === 'resume_invalid' ||
+ value === 'stream_changed' ||
+ value === 'cursor_invalidated' ||
+ value === 'history_rewritten'
+ );
+}
+
+/** True for an `activity` SSE frame. */
+export function isSessionActivityEvent(data: unknown): data is SessionActivityEvent {
+ return isRecord(data) && typeof data.activity === 'string';
+}
+
+/** True for a `heartbeat` SSE frame. */
+export function isSessionHeartbeatEvent(data: unknown): data is SessionHeartbeatEvent {
+ return isRecord(data) && typeof data.timestamp === 'string';
+}
+
+/**
+ * True for a renderable history envelope — requires the load-bearing nested
+ * fields the renderer reads (the old `isSessionStructuredHistory`, with the
+ * array-excluding `isRecord` above).
+ */
+export function isSessionStructuredHistory(value: unknown): value is SessionStructuredHistory {
+ if (!isRecord(value)) return false;
+ if (typeof value.transcript_stream_id !== 'string') return false;
+ const generation = value.generation;
+ if (!isRecord(generation) || typeof generation.id !== 'string') return false;
+ const cursor = value.cursor;
+ if (!isRecord(cursor) || typeof cursor.resume_token !== 'string' || cursor.resume_token === '')
+ return false;
+ const continuity = value.continuity;
+ if (!isRecord(continuity) || typeof continuity.status !== 'string') return false;
+ const tailState = value.tail_state;
+ if (!isRecord(tailState) || typeof tailState.activity !== 'string') return false;
+ return true;
+}
+
+/** True for a structured message — requires the `blocks` array (matches old `isStructuredMessage`). */
+export function isStructuredMessage(value: unknown): value is SessionStructuredMessage {
+ return (
+ isRecord(value) &&
+ typeof value.id === 'string' &&
+ isSessionStructuredRole(value.role) &&
+ typeof value.status === 'string' &&
+ Array.isArray(value.blocks) &&
+ value.blocks.every(isSessionStructuredBlock)
+ );
+}
+
+function isSessionStructuredRole(value: unknown): value is SessionStructuredMessage['role'] {
+ return (
+ value === 'unknown' ||
+ value === 'user' ||
+ value === 'assistant' ||
+ value === 'system' ||
+ value === 'tool'
+ );
+}
+
+function isSessionStructuredBlock(value: unknown): value is SessionStructuredBlock {
+ if (!isRecord(value)) return false;
+ return (
+ value.type === 'text' ||
+ value.type === 'thinking' ||
+ value.type === 'tool_use' ||
+ value.type === 'tool_result' ||
+ value.type === 'interaction' ||
+ value.type === 'image' ||
+ value.type === 'unknown'
+ );
+}
+
+/**
+ * Extract the renderable structured messages from an envelope, dropping any
+ * element that is not a well-formed message. Mirrors the old
+ * `structuredMessagesFromEnvelope` consumer helper.
+ */
+export function structuredMessagesFromEnvelope(
+ event: SessionStreamStructuredMessageEvent,
+): SessionStructuredMessage[] {
+ if (!Array.isArray(event.structured_messages)) return [];
+ return event.structured_messages.filter(isStructuredMessage);
+}
+
+// ---------------------------------------------------------------------------
+// Pure render helpers (ported from the old dashboard crew.ts at parity).
+// ---------------------------------------------------------------------------
+
+function formatPatchRange(start: number | undefined, lines: number | undefined): string {
+ const safeStart = start ?? 1;
+ if (lines === undefined || lines === 1) return String(safeStart);
+ return `${safeStart},${lines}`;
+}
+
+function formatPatchHunkHeader(hunk: SessionStructuredPatchHunk): string {
+ const oldStart = hunk.old_start;
+ const newStart = hunk.new_start;
+ if (oldStart === undefined && newStart === undefined) return '@@';
+ return `@@ -${formatPatchRange(oldStart, hunk.old_lines)} +${formatPatchRange(newStart, hunk.new_lines)} @@`;
+}
+
+/**
+ * Render edit/write patch hunks to unified-diff text. Emits a
+ * `*** Update File: ` separator each time the hunk's file_path changes,
+ * a `@@ … @@` header per hunk, then the hunk's lines verbatim.
+ */
+export function patchTextFromHunks(
+ hunks: readonly SessionStructuredPatchHunk[] | null | undefined,
+): string {
+ if (hunks === undefined || hunks === null || hunks.length === 0) return '';
+ const lines: string[] = [];
+ let lastFilePath = '';
+ for (const hunk of hunks) {
+ const filePath = hunk.file_path ?? '';
+ if (filePath !== '' && filePath !== lastFilePath) {
+ lines.push(`*** Update File: ${filePath}`);
+ lastFilePath = filePath;
+ }
+ lines.push(formatPatchHunkHeader(hunk));
+ if (hunk.lines !== undefined && hunk.lines !== null) {
+ for (const line of hunk.lines) lines.push(line);
+ }
+ }
+ return lines.join('\n');
+}
+
+function appendUsagePart(parts: string[], label: string, value: number | undefined): void {
+ // Zero token counts are dropped (distinct from the context pair/percent below).
+ if (value !== undefined && value !== 0) parts.push(`${label} ${value}`);
+}
+
+/**
+ * Render provider-neutral token usage to the compact `tokens …` summary line.
+ * Zero token counts are dropped; the context pair and percent render whenever
+ * defined (including an explicit `0%`). Returns `""` when nothing renders.
+ */
+export function formatUsage(usage: SessionStructuredUsage | undefined): string {
+ if (usage === undefined) return '';
+ const parts: string[] = [];
+ appendUsagePart(parts, 'in', usage.input_tokens);
+ appendUsagePart(parts, 'out', usage.output_tokens);
+ appendUsagePart(parts, 'reason', usage.reasoning_tokens);
+ appendUsagePart(parts, 'cache', usage.cache_read_tokens);
+ appendUsagePart(parts, 'write', usage.cache_creation_tokens);
+ const contextUsed = usage.context_used_tokens;
+ const contextWindow = usage.context_window_tokens;
+ if (contextUsed !== undefined && contextWindow !== undefined) {
+ parts.push(`${contextUsed}/${contextWindow}`);
+ }
+ const contextPercent = usage.context_percent;
+ if (contextPercent !== undefined) parts.push(`${contextPercent}%`);
+ return parts.length > 0 ? `tokens ${parts.join(' ')}` : '';
+}
diff --git a/internal/api/decode_status.go b/internal/api/decode_status.go
index c484647fd3..b226b679e6 100644
--- a/internal/api/decode_status.go
+++ b/internal/api/decode_status.go
@@ -24,6 +24,12 @@ func statusViewFromGen(body *genclient.StatusBody) StatusView {
RunningAgents: int(body.Agents.Running),
},
}
+ if body.Partial != nil {
+ out.Partial = *body.Partial
+ }
+ if body.PartialErrors != nil {
+ out.PartialErrors = append([]string(nil), (*body.PartialErrors)...)
+ }
if body.Version != nil {
out.Version = *body.Version
}
diff --git a/internal/api/fake_state_test.go b/internal/api/fake_state_test.go
index 61df75595f..5e8a2ffbef 100644
--- a/internal/api/fake_state_test.go
+++ b/internal/api/fake_state_test.go
@@ -39,6 +39,7 @@ type fakeState struct {
cfg *config.City
rawCfg *config.City // optional: raw config for provenance detection
sp *runtime.Fake
+ sessionProvider runtime.Provider // optional override for SessionProvider
stores map[string]beads.Store
cityBeadStore beads.Store // city-level store for session beads
nudgesBeadStore beads.Store // relocated nudges store; nil falls back to cityBeadStore (default backend)
@@ -97,8 +98,13 @@ func newFakeState(t testing.TB) *fakeState {
}
}
-func (f *fakeState) Config() *config.City { return f.cfg }
-func (f *fakeState) SessionProvider() runtime.Provider { return f.sp }
+func (f *fakeState) Config() *config.City { return f.cfg }
+func (f *fakeState) SessionProvider() runtime.Provider {
+ if f.sessionProvider != nil {
+ return f.sessionProvider
+ }
+ return f.sp
+}
func (f *fakeState) BeadStore(rig string) beads.Store { return f.stores[rig] }
func (f *fakeState) BeadStores() map[string]beads.Store { return f.stores }
func (f *fakeState) MailProvider(_ string) mail.Provider { return f.cityMailProv }
@@ -358,10 +364,12 @@ func (f *fakeMutatorState) CreateRig(r config.Rig) error {
// clone/SSRF and just appends the rig (emitting synthetic progress) so handler
// tests can exercise the 202 flow without a network. If onStep is set it emits
// a clone + done step. onManifest is invoked record-then-create with the
-// created dir so persistence/rollback wiring is exercised. When provisionFailN
-// is set it returns provisionErr after the manifest is reported (a failure once
-// the dir exists), without appending the rig.
-func (f *fakeMutatorState) ProvisionRigFromGit(ctx context.Context, r config.Rig, gitURL string, onStep func(step, detail string, warn bool), onManifest func(RigProvisionManifest)) (config.Rig, error) {
+// created dir so persistence/rollback wiring is exercised; mirroring the real
+// path, a pre-clone onManifest error is fail-closed (abort before appending the
+// rig) while the post-init one is best-effort. When provisionFailN is set it
+// returns provisionErr after the manifest is reported (a failure once the dir
+// exists), without appending the rig.
+func (f *fakeMutatorState) ProvisionRigFromGit(ctx context.Context, r config.Rig, gitURL string, onStep func(step, detail string, warn bool), onManifest func(RigProvisionManifest) error) (config.Rig, error) {
_, hasDeadline := ctx.Deadline()
f.provisionMu.Lock()
f.provisionCtxHadDeadline = hasDeadline
@@ -381,9 +389,13 @@ func (f *fakeMutatorState) ProvisionRigFromGit(ctx context.Context, r config.Rig
if r.Path == "" {
r.Path = "rigs/" + r.Name
}
- // Record-then-create: manifest the dir before "cloning".
+ // Record-then-create: manifest the dir before "cloning". A pre-clone persist
+ // failure is fail-closed (mirror the real ProvisionRigFromGit): abort before
+ // appending the rig so no un-manifested rig is created.
if onManifest != nil {
- onManifest(RigProvisionManifest{RigName: r.Name, CreatedDir: r.Path})
+ if err := onManifest(RigProvisionManifest{RigName: r.Name, CreatedDir: r.Path}); err != nil {
+ return config.Rig{}, err
+ }
}
f.provisionMu.Lock()
@@ -399,8 +411,11 @@ func (f *fakeMutatorState) ProvisionRigFromGit(ctx context.Context, r config.Rig
}
f.cfg.Rigs = append(f.cfg.Rigs, r)
+ // Post-init manifest is best-effort in the real path (a complete rig is
+ // forward-reconciled, never torn down), so a persist error here does not fail
+ // the provision.
if onManifest != nil {
- onManifest(RigProvisionManifest{RigName: r.Name, CreatedDir: r.Path})
+ _ = onManifest(RigProvisionManifest{RigName: r.Name, CreatedDir: r.Path})
}
if onStep != nil {
onStep("done", "Rig added.", false)
diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go
index dec46bbdfc..f116e09d03 100644
--- a/internal/api/genclient/client_gen.go
+++ b/internal/api/genclient/client_gen.go
@@ -237,6 +237,240 @@ func (e RunStepStatus) Valid() bool {
}
}
+// Defines values for SessionStreamStructuredMessageEventOperation.
+const (
+ Reset SessionStreamStructuredMessageEventOperation = "reset"
+ Snapshot SessionStreamStructuredMessageEventOperation = "snapshot"
+ Upsert SessionStreamStructuredMessageEventOperation = "upsert"
+)
+
+// Valid indicates whether the value is a known member of the SessionStreamStructuredMessageEventOperation enum.
+func (e SessionStreamStructuredMessageEventOperation) Valid() bool {
+ switch e {
+ case Reset:
+ return true
+ case Snapshot:
+ return true
+ case Upsert:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStreamStructuredMessageEventResetReason.
+const (
+ CursorInvalidated SessionStreamStructuredMessageEventResetReason = "cursor_invalidated"
+ HistoryRewritten SessionStreamStructuredMessageEventResetReason = "history_rewritten"
+ ResumeInvalid SessionStreamStructuredMessageEventResetReason = "resume_invalid"
+ StreamChanged SessionStreamStructuredMessageEventResetReason = "stream_changed"
+)
+
+// Valid indicates whether the value is a known member of the SessionStreamStructuredMessageEventResetReason enum.
+func (e SessionStreamStructuredMessageEventResetReason) Valid() bool {
+ switch e {
+ case CursorInvalidated:
+ return true
+ case HistoryRewritten:
+ return true
+ case ResumeInvalid:
+ return true
+ case StreamChanged:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStructuredMessageAssistantStatus.
+const (
+ SessionStructuredMessageAssistantStatusFinal SessionStructuredMessageAssistantStatus = "final"
+ SessionStructuredMessageAssistantStatusPartial SessionStructuredMessageAssistantStatus = "partial"
+ SessionStructuredMessageAssistantStatusSuperseded SessionStructuredMessageAssistantStatus = "superseded"
+ SessionStructuredMessageAssistantStatusUnknown SessionStructuredMessageAssistantStatus = "unknown"
+)
+
+// Valid indicates whether the value is a known member of the SessionStructuredMessageAssistantStatus enum.
+func (e SessionStructuredMessageAssistantStatus) Valid() bool {
+ switch e {
+ case SessionStructuredMessageAssistantStatusFinal:
+ return true
+ case SessionStructuredMessageAssistantStatusPartial:
+ return true
+ case SessionStructuredMessageAssistantStatusSuperseded:
+ return true
+ case SessionStructuredMessageAssistantStatusUnknown:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStructuredMessageSystemStatus.
+const (
+ SessionStructuredMessageSystemStatusFinal SessionStructuredMessageSystemStatus = "final"
+ SessionStructuredMessageSystemStatusPartial SessionStructuredMessageSystemStatus = "partial"
+ SessionStructuredMessageSystemStatusSuperseded SessionStructuredMessageSystemStatus = "superseded"
+ SessionStructuredMessageSystemStatusUnknown SessionStructuredMessageSystemStatus = "unknown"
+)
+
+// Valid indicates whether the value is a known member of the SessionStructuredMessageSystemStatus enum.
+func (e SessionStructuredMessageSystemStatus) Valid() bool {
+ switch e {
+ case SessionStructuredMessageSystemStatusFinal:
+ return true
+ case SessionStructuredMessageSystemStatusPartial:
+ return true
+ case SessionStructuredMessageSystemStatusSuperseded:
+ return true
+ case SessionStructuredMessageSystemStatusUnknown:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStructuredMessageToolStatus.
+const (
+ SessionStructuredMessageToolStatusFinal SessionStructuredMessageToolStatus = "final"
+ SessionStructuredMessageToolStatusPartial SessionStructuredMessageToolStatus = "partial"
+ SessionStructuredMessageToolStatusSuperseded SessionStructuredMessageToolStatus = "superseded"
+ SessionStructuredMessageToolStatusUnknown SessionStructuredMessageToolStatus = "unknown"
+)
+
+// Valid indicates whether the value is a known member of the SessionStructuredMessageToolStatus enum.
+func (e SessionStructuredMessageToolStatus) Valid() bool {
+ switch e {
+ case SessionStructuredMessageToolStatusFinal:
+ return true
+ case SessionStructuredMessageToolStatusPartial:
+ return true
+ case SessionStructuredMessageToolStatusSuperseded:
+ return true
+ case SessionStructuredMessageToolStatusUnknown:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStructuredMessageUnknownStatus.
+const (
+ SessionStructuredMessageUnknownStatusFinal SessionStructuredMessageUnknownStatus = "final"
+ SessionStructuredMessageUnknownStatusPartial SessionStructuredMessageUnknownStatus = "partial"
+ SessionStructuredMessageUnknownStatusSuperseded SessionStructuredMessageUnknownStatus = "superseded"
+ SessionStructuredMessageUnknownStatusUnknown SessionStructuredMessageUnknownStatus = "unknown"
+)
+
+// Valid indicates whether the value is a known member of the SessionStructuredMessageUnknownStatus enum.
+func (e SessionStructuredMessageUnknownStatus) Valid() bool {
+ switch e {
+ case SessionStructuredMessageUnknownStatusFinal:
+ return true
+ case SessionStructuredMessageUnknownStatusPartial:
+ return true
+ case SessionStructuredMessageUnknownStatusSuperseded:
+ return true
+ case SessionStructuredMessageUnknownStatusUnknown:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStructuredMessageUserStatus.
+const (
+ SessionStructuredMessageUserStatusFinal SessionStructuredMessageUserStatus = "final"
+ SessionStructuredMessageUserStatusPartial SessionStructuredMessageUserStatus = "partial"
+ SessionStructuredMessageUserStatusSuperseded SessionStructuredMessageUserStatus = "superseded"
+ SessionStructuredMessageUserStatusUnknown SessionStructuredMessageUserStatus = "unknown"
+)
+
+// Valid indicates whether the value is a known member of the SessionStructuredMessageUserStatus enum.
+func (e SessionStructuredMessageUserStatus) Valid() bool {
+ switch e {
+ case SessionStructuredMessageUserStatusFinal:
+ return true
+ case SessionStructuredMessageUserStatusPartial:
+ return true
+ case SessionStructuredMessageUserStatusSuperseded:
+ return true
+ case SessionStructuredMessageUserStatusUnknown:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionStructuredToolErrorCategory.
+const (
+ SessionStructuredToolErrorCategoryCommandFailure SessionStructuredToolErrorCategory = "command_failure"
+ SessionStructuredToolErrorCategoryFileError SessionStructuredToolErrorCategory = "file_error"
+ SessionStructuredToolErrorCategoryNetworkError SessionStructuredToolErrorCategory = "network_error"
+ SessionStructuredToolErrorCategoryTimeout SessionStructuredToolErrorCategory = "timeout"
+ SessionStructuredToolErrorCategoryUnknown SessionStructuredToolErrorCategory = "unknown"
+ SessionStructuredToolErrorCategoryUserRejection SessionStructuredToolErrorCategory = "user_rejection"
+ SessionStructuredToolErrorCategoryUserRejectionWithReason SessionStructuredToolErrorCategory = "user_rejection_with_reason"
+ SessionStructuredToolErrorCategoryValidationError SessionStructuredToolErrorCategory = "validation_error"
+)
+
+// Valid indicates whether the value is a known member of the SessionStructuredToolErrorCategory enum.
+func (e SessionStructuredToolErrorCategory) Valid() bool {
+ switch e {
+ case SessionStructuredToolErrorCategoryCommandFailure:
+ return true
+ case SessionStructuredToolErrorCategoryFileError:
+ return true
+ case SessionStructuredToolErrorCategoryNetworkError:
+ return true
+ case SessionStructuredToolErrorCategoryTimeout:
+ return true
+ case SessionStructuredToolErrorCategoryUnknown:
+ return true
+ case SessionStructuredToolErrorCategoryUserRejection:
+ return true
+ case SessionStructuredToolErrorCategoryUserRejectionWithReason:
+ return true
+ case SessionStructuredToolErrorCategoryValidationError:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionTranscriptConversationResponseFormat.
+const (
+ SessionTranscriptConversationResponseFormatConversation SessionTranscriptConversationResponseFormat = "conversation"
+ SessionTranscriptConversationResponseFormatText SessionTranscriptConversationResponseFormat = "text"
+)
+
+// Valid indicates whether the value is a known member of the SessionTranscriptConversationResponseFormat enum.
+func (e SessionTranscriptConversationResponseFormat) Valid() bool {
+ switch e {
+ case SessionTranscriptConversationResponseFormatConversation:
+ return true
+ case SessionTranscriptConversationResponseFormatText:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for SessionTranscriptRawResponseFormat.
+const (
+ SessionTranscriptRawResponseFormatRaw SessionTranscriptRawResponseFormat = "raw"
+)
+
+// Valid indicates whether the value is a known member of the SessionTranscriptRawResponseFormat enum.
+func (e SessionTranscriptRawResponseFormat) Valid() bool {
+ switch e {
+ case SessionTranscriptRawResponseFormatRaw:
+ return true
+ default:
+ return false
+ }
+}
+
// Defines values for StatusConditionalWriteStoreVerdictLatch.
const (
StatusConditionalWriteStoreVerdictLatchIncapable StatusConditionalWriteStoreVerdictLatch = "incapable"
@@ -615,6 +849,48 @@ func (e PostV0CityByCityNameRigByNameByActionParamsAction) Valid() bool {
}
}
+// Defines values for StreamSessionParamsFormat.
+const (
+ StreamSessionParamsFormatConversation StreamSessionParamsFormat = "conversation"
+ StreamSessionParamsFormatRaw StreamSessionParamsFormat = "raw"
+ StreamSessionParamsFormatStructured StreamSessionParamsFormat = "structured"
+)
+
+// Valid indicates whether the value is a known member of the StreamSessionParamsFormat enum.
+func (e StreamSessionParamsFormat) Valid() bool {
+ switch e {
+ case StreamSessionParamsFormatConversation:
+ return true
+ case StreamSessionParamsFormatRaw:
+ return true
+ case StreamSessionParamsFormatStructured:
+ return true
+ default:
+ return false
+ }
+}
+
+// Defines values for GetV0CityByCityNameSessionByIdTranscriptParamsFormat.
+const (
+ GetV0CityByCityNameSessionByIdTranscriptParamsFormatConversation GetV0CityByCityNameSessionByIdTranscriptParamsFormat = "conversation"
+ GetV0CityByCityNameSessionByIdTranscriptParamsFormatRaw GetV0CityByCityNameSessionByIdTranscriptParamsFormat = "raw"
+ GetV0CityByCityNameSessionByIdTranscriptParamsFormatStructured GetV0CityByCityNameSessionByIdTranscriptParamsFormat = "structured"
+)
+
+// Valid indicates whether the value is a known member of the GetV0CityByCityNameSessionByIdTranscriptParamsFormat enum.
+func (e GetV0CityByCityNameSessionByIdTranscriptParamsFormat) Valid() bool {
+ switch e {
+ case GetV0CityByCityNameSessionByIdTranscriptParamsFormatConversation:
+ return true
+ case GetV0CityByCityNameSessionByIdTranscriptParamsFormatRaw:
+ return true
+ case GetV0CityByCityNameSessionByIdTranscriptParamsFormatStructured:
+ return true
+ default:
+ return false
+ }
+}
+
// AdapterCapabilities defines model for AdapterCapabilities.
type AdapterCapabilities struct {
MaxMessageLength int64 `json:"MaxMessageLength"`
@@ -839,7 +1115,7 @@ type AsyncAcceptedBody struct {
// AsyncAcceptedResponse defines model for AsyncAcceptedResponse.
type AsyncAcceptedResponse struct {
- // EventCursor Supervisor event-stream cursor captured before the async request was accepted. Pass this value as after_cursor to /v0/events/stream to receive the request result without replaying unrelated historical backlog. A value of 0 can also mean no event provider is configured or every event log is empty.
+ // EventCursor Supervisor event-stream cursor captured before the async request was accepted. Pass this value as after_cursor to /v0/events/stream to receive the request result. A populated cursor resumes each city at its exact per-city position, so no unrelated historical backlog is replayed. The value 0 is returned only when no event provider is registered at capture time; passing 0 back requests a replay from zero for every provider present at resume time, which still delivers this request result because no provider predates the capture boundary.
EventCursor string `json:"event_cursor"`
// RequestId Correlation ID. Watch /v0/events/stream for request.result.city.create, request.result.city.unregister, or request.failed with this request_id.
@@ -2414,13 +2690,15 @@ type OrderListBody struct {
// OrderResponse defines model for OrderResponse.
type OrderResponse struct {
- CaptureOutput bool `json:"capture_output"`
- Check *string `json:"check,omitempty"`
- Description *string `json:"description,omitempty"`
- Enabled bool `json:"enabled"`
- Env *map[string]string `json:"env,omitempty"`
- Exec *string `json:"exec,omitempty"`
- Formula *string `json:"formula,omitempty"`
+ CaptureOutput bool `json:"capture_output"`
+ Check *string `json:"check,omitempty"`
+ CheckTimeout *string `json:"check_timeout,omitempty"`
+ CheckTimeoutMs *int64 `json:"check_timeout_ms,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Enabled bool `json:"enabled"`
+ Env *map[string]string `json:"env,omitempty"`
+ Exec *string `json:"exec,omitempty"`
+ Formula *string `json:"formula,omitempty"`
// Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set
Gate *string `json:"gate,omitempty"`
Interval *string `json:"interval,omitempty"`
@@ -2539,6 +2817,7 @@ type PackResponse struct {
// PaginationInfo defines model for PaginationInfo.
type PaginationInfo struct {
+ HasNewerMessages *bool `json:"has_newer_messages,omitempty"`
HasOlderMessages bool `json:"has_older_messages"`
ReturnedMessageCount int64 `json:"returned_message_count"`
TotalCompactions int64 `json:"total_compactions"`
@@ -3383,6 +3662,12 @@ type SessionPatchBody struct {
Title *string `json:"title,omitempty"`
}
+// SessionPendingClearedEvent defines model for SessionPendingClearedEvent.
+type SessionPendingClearedEvent struct {
+ // RequestId Request ID of the interaction that was cleared.
+ RequestId string `json:"request_id"`
+}
+
// SessionPendingResponse defines model for SessionPendingResponse.
type SessionPendingResponse struct {
Pending *PendingInteraction `json:"pending,omitempty"`
@@ -3484,7 +3769,7 @@ type SessionStrandedPayload struct {
WorkBeadIds *[]string `json:"work_bead_ids,omitempty"`
}
-// SessionStreamCommonEvent Non-message events emitted on the session SSE stream: activity transitions, pending interactions, and keepalive heartbeats. The concrete variant is identified by the SSE event name.
+// SessionStreamCommonEvent Non-message events emitted on the session SSE stream: activity transitions, pending-interaction lifecycle updates, and keepalive heartbeats. The concrete variant is identified by the SSE event name.
type SessionStreamCommonEvent struct {
union json.RawMessage
}
@@ -3495,7 +3780,7 @@ type SessionStreamMessageEvent struct {
Id string `json:"id"`
Pagination *PaginationInfo `json:"pagination,omitempty"`
- // Provider Producing provider identifier (claude, codex, gemini, open-code, etc.).
+ // Provider Producing provider identifier (claude, codex, gemini, opencode, etc.).
Provider string `json:"provider"`
Template string `json:"template"`
Turns *[]OutputTurn `json:"turns"`
@@ -3510,11 +3795,821 @@ type SessionStreamRawMessageEvent struct {
Messages *[]SessionRawMessageFrame `json:"messages"`
Pagination *PaginationInfo `json:"pagination,omitempty"`
- // Provider Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing.
+ // Provider Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing.
Provider string `json:"provider"`
Template string `json:"template"`
}
+// SessionStreamStructuredMessageEvent Provider-neutral structured transcript update with explicit snapshot, upsert, or reset application semantics.
+type SessionStreamStructuredMessageEvent struct {
+ // Format Always structured for this event.
+ Format string `json:"format"`
+ History SessionStructuredHistory `json:"history"`
+ Id string `json:"id"`
+
+ // Operation How the client applies this structured frame: replace from a snapshot/reset or merge an upsert.
+ Operation SessionStreamStructuredMessageEventOperation `json:"operation"`
+ Pagination *PaginationInfo `json:"pagination,omitempty"`
+
+ // Provider Producing provider identifier (claude, codex, gemini, opencode, etc.).
+ Provider string `json:"provider"`
+
+ // ResetReason Present if and only if operation is reset; absent for snapshot and upsert. Identifies why the reset replaced the client transcript.
+ ResetReason *SessionStreamStructuredMessageEventResetReason `json:"reset_reason,omitempty"`
+
+ // SchemaVersion Structured session transcript schema version.
+ SchemaVersion string `json:"schema_version"`
+
+ // StructuredMessages Provider-normalized structured messages.
+ StructuredMessages []SessionStructuredMessage `json:"structured_messages"`
+ Template string `json:"template"`
+}
+
+// SessionStreamStructuredMessageEventOperation How the client applies this structured frame: replace from a snapshot/reset or merge an upsert.
+type SessionStreamStructuredMessageEventOperation string
+
+// SessionStreamStructuredMessageEventResetReason Present if and only if operation is reset; absent for snapshot and upsert. Identifies why the reset replaced the client transcript.
+type SessionStreamStructuredMessageEventResetReason string
+
+// SessionStructuredArgument defines model for SessionStructuredArgument.
+type SessionStructuredArgument struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// SessionStructuredBlock Provider-normalized transcript block discriminated by its closed block type vocabulary.
+type SessionStructuredBlock struct {
+ union json.RawMessage
+}
+
+// SessionStructuredBlockImage defines model for SessionStructuredBlockImage.
+type SessionStructuredBlockImage struct {
+ FilePath *string `json:"file_path,omitempty"`
+ ImageUrl *string `json:"image_url,omitempty"`
+ MimeType *string `json:"mime_type,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredBlockInteraction defines model for SessionStructuredBlockInteraction.
+type SessionStructuredBlockInteraction struct {
+ Interaction *SessionStructuredInteraction `json:"interaction,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredBlockText defines model for SessionStructuredBlockText.
+type SessionStructuredBlockText struct {
+ Text *string `json:"text,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredBlockThinking defines model for SessionStructuredBlockThinking.
+type SessionStructuredBlockThinking struct {
+ Signature *string `json:"signature,omitempty"`
+ Thinking *string `json:"thinking,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredBlockToolResult defines model for SessionStructuredBlockToolResult.
+type SessionStructuredBlockToolResult struct {
+ Content *string `json:"content,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+ IsError *bool `json:"is_error,omitempty"`
+ Name *string `json:"name,omitempty"`
+
+ // Structured Provider-neutral tool result discriminated by its closed kind vocabulary.
+ Structured *SessionStructuredToolResult `json:"structured,omitempty"`
+ ToolCallId *string `json:"tool_call_id,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredBlockToolUse defines model for SessionStructuredBlockToolUse.
+type SessionStructuredBlockToolUse struct {
+ FilePath *string `json:"file_path,omitempty"`
+ Id *string `json:"id,omitempty"`
+
+ // Input Provider-neutral tool input discriminated by its closed kind vocabulary.
+ Input *SessionStructuredToolInput `json:"input,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredBlockUnknown defines model for SessionStructuredBlockUnknown.
+type SessionStructuredBlockUnknown struct {
+ Content *string `json:"content,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+ Id *string `json:"id,omitempty"`
+ ImageUrl *string `json:"image_url,omitempty"`
+
+ // Input Provider-neutral tool input discriminated by its closed kind vocabulary.
+ Input *SessionStructuredToolInput `json:"input,omitempty"`
+ Interaction *SessionStructuredInteraction `json:"interaction,omitempty"`
+ IsError *bool `json:"is_error,omitempty"`
+ MimeType *string `json:"mime_type,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Signature *string `json:"signature,omitempty"`
+
+ // Structured Provider-neutral tool result discriminated by its closed kind vocabulary.
+ Structured *SessionStructuredToolResult `json:"structured,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Thinking *string `json:"thinking,omitempty"`
+ ToolCallId *string `json:"tool_call_id,omitempty"`
+ Type string `json:"type"`
+}
+
+// SessionStructuredContinuity defines model for SessionStructuredContinuity.
+type SessionStructuredContinuity struct {
+ CompactionCount *int64 `json:"compaction_count,omitempty"`
+ HasBranches *bool `json:"has_branches,omitempty"`
+ Note *string `json:"note,omitempty"`
+ Status string `json:"status"`
+}
+
+// SessionStructuredCursor defines model for SessionStructuredCursor.
+type SessionStructuredCursor struct {
+ AfterEntryId *string `json:"after_entry_id,omitempty"`
+
+ // ResumeToken Opaque cursor for an exact structured REST-to-SSE handoff or SSE reconnect.
+ ResumeToken string `json:"resume_token"`
+}
+
+// SessionStructuredDiagnostic defines model for SessionStructuredDiagnostic.
+type SessionStructuredDiagnostic struct {
+ Code string `json:"code"`
+ Count *int64 `json:"count,omitempty"`
+ Message *string `json:"message,omitempty"`
+}
+
+// SessionStructuredGeneration defines model for SessionStructuredGeneration.
+type SessionStructuredGeneration struct {
+ Id string `json:"id"`
+ ObservedAt *string `json:"observed_at,omitempty"`
+}
+
+// SessionStructuredHistory defines model for SessionStructuredHistory.
+type SessionStructuredHistory struct {
+ Continuity SessionStructuredContinuity `json:"continuity"`
+ Cursor SessionStructuredCursor `json:"cursor"`
+ Diagnostics *[]SessionStructuredDiagnostic `json:"diagnostics,omitempty"`
+ GcSessionId *string `json:"gc_session_id,omitempty"`
+ Generation SessionStructuredGeneration `json:"generation"`
+ LogicalConversationId *string `json:"logical_conversation_id,omitempty"`
+ ProviderSessionId *string `json:"provider_session_id,omitempty"`
+ TailState SessionStructuredTailState `json:"tail_state"`
+ TranscriptStreamId string `json:"transcript_stream_id"`
+}
+
+// SessionStructuredIDESelection defines model for SessionStructuredIDESelection.
+type SessionStructuredIDESelection struct {
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredInteraction defines model for SessionStructuredInteraction.
+type SessionStructuredInteraction struct {
+ Action *string `json:"action,omitempty"`
+ Kind *string `json:"kind,omitempty"`
+ Options *[]string `json:"options,omitempty"`
+ Prompt *string `json:"prompt,omitempty"`
+ RequestId *string `json:"request_id,omitempty"`
+ State string `json:"state"`
+}
+
+// SessionStructuredMessage Provider-normalized transcript message discriminated by its closed role vocabulary.
+type SessionStructuredMessage struct {
+ union json.RawMessage
+}
+
+// SessionStructuredMessageAssistant defines model for SessionStructuredMessageAssistant.
+type SessionStructuredMessageAssistant struct {
+ Blocks []SessionStructuredBlock `json:"blocks"`
+ Id string `json:"id"`
+ Model *string `json:"model,omitempty"`
+ Provider *string `json:"provider,omitempty"`
+ Role string `json:"role"`
+ Status SessionStructuredMessageAssistantStatus `json:"status"`
+ StopReason *string `json:"stop_reason,omitempty"`
+ Timestamp *string `json:"timestamp,omitempty"`
+ Usage *SessionStructuredUsage `json:"usage,omitempty"`
+}
+
+// SessionStructuredMessageAssistantStatus defines model for SessionStructuredMessageAssistant.Status.
+type SessionStructuredMessageAssistantStatus string
+
+// SessionStructuredMessageSystem defines model for SessionStructuredMessageSystem.
+type SessionStructuredMessageSystem struct {
+ Blocks []SessionStructuredBlock `json:"blocks"`
+ Id string `json:"id"`
+ Provider *string `json:"provider,omitempty"`
+ Role string `json:"role"`
+ Status SessionStructuredMessageSystemStatus `json:"status"`
+ SystemEvent *SessionStructuredSystemEvent `json:"system_event,omitempty"`
+ Timestamp *string `json:"timestamp,omitempty"`
+}
+
+// SessionStructuredMessageSystemStatus defines model for SessionStructuredMessageSystem.Status.
+type SessionStructuredMessageSystemStatus string
+
+// SessionStructuredMessageTool defines model for SessionStructuredMessageTool.
+type SessionStructuredMessageTool struct {
+ Blocks []SessionStructuredBlock `json:"blocks"`
+ Id string `json:"id"`
+ Provider *string `json:"provider,omitempty"`
+ Role string `json:"role"`
+ Status SessionStructuredMessageToolStatus `json:"status"`
+ Timestamp *string `json:"timestamp,omitempty"`
+}
+
+// SessionStructuredMessageToolStatus defines model for SessionStructuredMessageTool.Status.
+type SessionStructuredMessageToolStatus string
+
+// SessionStructuredMessageUnknown defines model for SessionStructuredMessageUnknown.
+type SessionStructuredMessageUnknown struct {
+ Blocks []SessionStructuredBlock `json:"blocks"`
+ Id string `json:"id"`
+ Model *string `json:"model,omitempty"`
+ Provider *string `json:"provider,omitempty"`
+ Role string `json:"role"`
+ Status SessionStructuredMessageUnknownStatus `json:"status"`
+ StopReason *string `json:"stop_reason,omitempty"`
+ SystemEvent *SessionStructuredSystemEvent `json:"system_event,omitempty"`
+ Timestamp *string `json:"timestamp,omitempty"`
+ Usage *SessionStructuredUsage `json:"usage,omitempty"`
+ UserPrompt *SessionStructuredUserPrompt `json:"user_prompt,omitempty"`
+}
+
+// SessionStructuredMessageUnknownStatus defines model for SessionStructuredMessageUnknown.Status.
+type SessionStructuredMessageUnknownStatus string
+
+// SessionStructuredMessageUser defines model for SessionStructuredMessageUser.
+type SessionStructuredMessageUser struct {
+ Blocks []SessionStructuredBlock `json:"blocks"`
+ Id string `json:"id"`
+ Provider *string `json:"provider,omitempty"`
+ Role string `json:"role"`
+ Status SessionStructuredMessageUserStatus `json:"status"`
+ Timestamp *string `json:"timestamp,omitempty"`
+ UserPrompt *SessionStructuredUserPrompt `json:"user_prompt,omitempty"`
+}
+
+// SessionStructuredMessageUserStatus defines model for SessionStructuredMessageUser.Status.
+type SessionStructuredMessageUserStatus string
+
+// SessionStructuredPatchHunk defines model for SessionStructuredPatchHunk.
+type SessionStructuredPatchHunk struct {
+ FilePath *string `json:"file_path,omitempty"`
+ Lines *[]string `json:"lines,omitempty"`
+ NewLines *int64 `json:"new_lines,omitempty"`
+ NewStart *int64 `json:"new_start,omitempty"`
+ OldLines *int64 `json:"old_lines,omitempty"`
+ OldStart *int64 `json:"old_start,omitempty"`
+}
+
+// SessionStructuredPlanStep defines model for SessionStructuredPlanStep.
+type SessionStructuredPlanStep struct {
+ Status *string `json:"status,omitempty"`
+ Step *string `json:"step,omitempty"`
+}
+
+// SessionStructuredQuestion defines model for SessionStructuredQuestion.
+type SessionStructuredQuestion struct {
+ Header *string `json:"header,omitempty"`
+ MultiSelect *bool `json:"multi_select,omitempty"`
+ Options *[]SessionStructuredQuestionOption `json:"options,omitempty"`
+ Question *string `json:"question,omitempty"`
+}
+
+// SessionStructuredQuestionOption defines model for SessionStructuredQuestionOption.
+type SessionStructuredQuestionOption struct {
+ Description *string `json:"description,omitempty"`
+ Label *string `json:"label,omitempty"`
+}
+
+// SessionStructuredSearchResultItem defines model for SessionStructuredSearchResultItem.
+type SessionStructuredSearchResultItem struct {
+ Snippet *string `json:"snippet,omitempty"`
+ Title *string `json:"title,omitempty"`
+ Url *string `json:"url,omitempty"`
+}
+
+// SessionStructuredSystemEvent defines model for SessionStructuredSystemEvent.
+type SessionStructuredSystemEvent struct {
+ Category *string `json:"category,omitempty"`
+ Code *string `json:"code,omitempty"`
+ Kind *string `json:"kind,omitempty"`
+ Message *string `json:"message,omitempty"`
+}
+
+// SessionStructuredTailState defines model for SessionStructuredTailState.
+type SessionStructuredTailState struct {
+ Activity string `json:"activity"`
+ Degraded *bool `json:"degraded,omitempty"`
+ DegradedReason *string `json:"degraded_reason,omitempty"`
+ LastEntryId *string `json:"last_entry_id,omitempty"`
+ OpenToolCallIds *[]string `json:"open_tool_call_ids,omitempty"`
+ PendingInteractionIds *[]string `json:"pending_interaction_ids,omitempty"`
+}
+
+// SessionStructuredTodoItem defines model for SessionStructuredTodoItem.
+type SessionStructuredTodoItem struct {
+ ActiveForm *string `json:"active_form,omitempty"`
+ Content *string `json:"content,omitempty"`
+ Id *string `json:"id,omitempty"`
+ Priority *string `json:"priority,omitempty"`
+ Status *string `json:"status,omitempty"`
+}
+
+// SessionStructuredToolError defines model for SessionStructuredToolError.
+type SessionStructuredToolError struct {
+ // Category Provider-neutral category: user_rejection, user_rejection_with_reason, command_failure, file_error, validation_error, timeout, network_error, or unknown.
+ Category SessionStructuredToolErrorCategory `json:"category"`
+ Message *string `json:"message,omitempty"`
+ UserReason *string `json:"user_reason,omitempty"`
+}
+
+// SessionStructuredToolErrorCategory Provider-neutral category: user_rejection, user_rejection_with_reason, command_failure, file_error, validation_error, timeout, network_error, or unknown.
+type SessionStructuredToolErrorCategory string
+
+// SessionStructuredToolInput Provider-neutral tool input discriminated by its closed kind vocabulary.
+type SessionStructuredToolInput struct {
+ union json.RawMessage
+}
+
+// SessionStructuredToolInputArguments defines model for SessionStructuredToolInputArguments.
+type SessionStructuredToolInputArguments struct {
+ Arguments []SessionStructuredArgument `json:"arguments"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+}
+
+// SessionStructuredToolInputCode defines model for SessionStructuredToolInputCode.
+type SessionStructuredToolInputCode struct {
+ Code string `json:"code"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+}
+
+// SessionStructuredToolInputCommand defines model for SessionStructuredToolInputCommand.
+type SessionStructuredToolInputCommand struct {
+ Arguments *[]SessionStructuredArgument `json:"arguments,omitempty"`
+ Command string `json:"command"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+}
+
+// SessionStructuredToolInputFetch defines model for SessionStructuredToolInputFetch.
+type SessionStructuredToolInputFetch struct {
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Prompt *string `json:"prompt,omitempty"`
+ Url *string `json:"url,omitempty"`
+}
+
+// SessionStructuredToolInputFile defines model for SessionStructuredToolInputFile.
+type SessionStructuredToolInputFile struct {
+ Command *string `json:"command,omitempty"`
+ FilePath string `json:"file_path"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+}
+
+// SessionStructuredToolInputGlob defines model for SessionStructuredToolInputGlob.
+type SessionStructuredToolInputGlob struct {
+ Arguments *[]SessionStructuredArgument `json:"arguments,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Pattern *string `json:"pattern,omitempty"`
+ Query *string `json:"query,omitempty"`
+}
+
+// SessionStructuredToolInputPatch defines model for SessionStructuredToolInputPatch.
+type SessionStructuredToolInputPatch struct {
+ FilePath *string `json:"file_path,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+ Patch string `json:"patch"`
+}
+
+// SessionStructuredToolInputPlan defines model for SessionStructuredToolInputPlan.
+type SessionStructuredToolInputPlan struct {
+ Explanation *string `json:"explanation,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Plan *string `json:"plan,omitempty"`
+ Steps *[]SessionStructuredPlanStep `json:"steps,omitempty"`
+}
+
+// SessionStructuredToolInputQuestion defines model for SessionStructuredToolInputQuestion.
+type SessionStructuredToolInputQuestion struct {
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Options *[]string `json:"options,omitempty"`
+ Question *string `json:"question,omitempty"`
+}
+
+// SessionStructuredToolInputSearch defines model for SessionStructuredToolInputSearch.
+type SessionStructuredToolInputSearch struct {
+ Arguments *[]SessionStructuredArgument `json:"arguments,omitempty"`
+ Command *string `json:"command,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Pattern *string `json:"pattern,omitempty"`
+ Query *string `json:"query,omitempty"`
+}
+
+// SessionStructuredToolInputStdin defines model for SessionStructuredToolInputStdin.
+type SessionStructuredToolInputStdin struct {
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ LinkedCommand *string `json:"linked_command,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolInputTask defines model for SessionStructuredToolInputTask.
+type SessionStructuredToolInputTask struct {
+ Description *string `json:"description,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Prompt *string `json:"prompt,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ TaskStatus *string `json:"task_status,omitempty"`
+ TaskType *string `json:"task_type,omitempty"`
+}
+
+// SessionStructuredToolInputText defines model for SessionStructuredToolInputText.
+type SessionStructuredToolInputText struct {
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Text string `json:"text"`
+}
+
+// SessionStructuredToolInputTodo defines model for SessionStructuredToolInputTodo.
+type SessionStructuredToolInputTodo struct {
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Todos *[]SessionStructuredTodoItem `json:"todos,omitempty"`
+}
+
+// SessionStructuredToolInputUnknown defines model for SessionStructuredToolInputUnknown.
+type SessionStructuredToolInputUnknown struct {
+ Arguments *[]SessionStructuredArgument `json:"arguments,omitempty"`
+ Code *string `json:"code,omitempty"`
+ Command *string `json:"command,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Explanation *string `json:"explanation,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+ LinkedCommand *string `json:"linked_command,omitempty"`
+ Options *[]string `json:"options,omitempty"`
+ Patch *string `json:"patch,omitempty"`
+ Pattern *string `json:"pattern,omitempty"`
+ Plan *string `json:"plan,omitempty"`
+ Prompt *string `json:"prompt,omitempty"`
+ Query *string `json:"query,omitempty"`
+ Question *string `json:"question,omitempty"`
+ Steps *[]SessionStructuredPlanStep `json:"steps,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ TaskStatus *string `json:"task_status,omitempty"`
+ TaskType *string `json:"task_type,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Todos *[]SessionStructuredTodoItem `json:"todos,omitempty"`
+ Url *string `json:"url,omitempty"`
+}
+
+// SessionStructuredToolInputWrite defines model for SessionStructuredToolInputWrite.
+type SessionStructuredToolInputWrite struct {
+ FilePath *string `json:"file_path,omitempty"`
+
+ // Kind Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolResult Provider-neutral tool result discriminated by its closed kind vocabulary.
+type SessionStructuredToolResult struct {
+ union json.RawMessage
+}
+
+// SessionStructuredToolResultBash defines model for SessionStructuredToolResultBash.
+type SessionStructuredToolResultBash struct {
+ Command *string `json:"command,omitempty"`
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ ExitCode *int64 `json:"exit_code,omitempty"`
+ Interrupted *bool `json:"interrupted,omitempty"`
+ IsImage *bool `json:"is_image,omitempty"`
+ Kind string `json:"kind"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ Stderr *string `json:"stderr,omitempty"`
+ StderrLines *int64 `json:"stderr_lines,omitempty"`
+ Stdout *string `json:"stdout,omitempty"`
+ StdoutLines *int64 `json:"stdout_lines,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ TaskStatus *string `json:"task_status,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Timestamp *string `json:"timestamp,omitempty"`
+ Truncated *bool `json:"truncated,omitempty"`
+}
+
+// SessionStructuredToolResultEdit defines model for SessionStructuredToolResultEdit.
+type SessionStructuredToolResultEdit struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+ FilePaths *[]string `json:"file_paths,omitempty"`
+ Kind string `json:"kind"`
+ NewString *string `json:"new_string,omitempty"`
+ OldString *string `json:"old_string,omitempty"`
+ OriginalFile *string `json:"original_file,omitempty"`
+ Patch *string `json:"patch,omitempty"`
+ PatchHunks *[]SessionStructuredPatchHunk `json:"patch_hunks,omitempty"`
+ ReplaceAll *bool `json:"replace_all,omitempty"`
+ UserModified *bool `json:"user_modified,omitempty"`
+}
+
+// SessionStructuredToolResultFetch defines model for SessionStructuredToolResultFetch.
+type SessionStructuredToolResultFetch struct {
+ Bytes *int64 `json:"bytes,omitempty"`
+ Content *string `json:"content,omitempty"`
+ DurationMs *int64 `json:"duration_ms,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Kind string `json:"kind"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ StatusCode *int64 `json:"status_code,omitempty"`
+ StatusText *string `json:"status_text,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Url *string `json:"url,omitempty"`
+}
+
+// SessionStructuredToolResultGlob defines model for SessionStructuredToolResultGlob.
+type SessionStructuredToolResultGlob struct {
+ Content *string `json:"content,omitempty"`
+ DurationMs *int64 `json:"duration_ms,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Filenames *[]string `json:"filenames,omitempty"`
+ Kind string `json:"kind"`
+ NumFiles *int64 `json:"num_files,omitempty"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ Truncated *bool `json:"truncated,omitempty"`
+}
+
+// SessionStructuredToolResultGrep defines model for SessionStructuredToolResultGrep.
+type SessionStructuredToolResultGrep struct {
+ AppliedLimit *int64 `json:"applied_limit,omitempty"`
+ Content *string `json:"content,omitempty"`
+ Counts *[]SessionStructuredArgument `json:"counts,omitempty"`
+ DurationMs *int64 `json:"duration_ms,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Filenames *[]string `json:"filenames,omitempty"`
+ Kind string `json:"kind"`
+ Mode *string `json:"mode,omitempty"`
+ NumFiles *int64 `json:"num_files,omitempty"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ NumResults *int64 `json:"num_results,omitempty"`
+ Query *string `json:"query,omitempty"`
+ ResultItems *[]SessionStructuredSearchResultItem `json:"result_items,omitempty"`
+}
+
+// SessionStructuredToolResultPlan defines model for SessionStructuredToolResultPlan.
+type SessionStructuredToolResultPlan struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Explanation *string `json:"explanation,omitempty"`
+ Kind string `json:"kind"`
+ Plan *string `json:"plan,omitempty"`
+ Steps *[]SessionStructuredPlanStep `json:"steps,omitempty"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolResultPython defines model for SessionStructuredToolResultPython.
+type SessionStructuredToolResultPython struct {
+ Code *string `json:"code,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ ExitCode *int64 `json:"exit_code,omitempty"`
+ Interrupted *bool `json:"interrupted,omitempty"`
+ IsImage *bool `json:"is_image,omitempty"`
+ Kind string `json:"kind"`
+ Stderr *string `json:"stderr,omitempty"`
+ Stdout *string `json:"stdout,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Truncated *bool `json:"truncated,omitempty"`
+}
+
+// SessionStructuredToolResultQuestion defines model for SessionStructuredToolResultQuestion.
+type SessionStructuredToolResultQuestion struct {
+ Answer *string `json:"answer,omitempty"`
+ Answers *[]SessionStructuredArgument `json:"answers,omitempty"`
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Kind string `json:"kind"`
+ Options *[]string `json:"options,omitempty"`
+ Question *string `json:"question,omitempty"`
+ Questions *[]SessionStructuredQuestion `json:"questions,omitempty"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolResultRead defines model for SessionStructuredToolResultRead.
+type SessionStructuredToolResultRead struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ StartLine *int64 `json:"start_line,omitempty"`
+ TotalLines *int64 `json:"total_lines,omitempty"`
+}
+
+// SessionStructuredToolResultSearch defines model for SessionStructuredToolResultSearch.
+type SessionStructuredToolResultSearch struct {
+ AppliedLimit *int64 `json:"applied_limit,omitempty"`
+ Content *string `json:"content,omitempty"`
+ Counts *[]SessionStructuredArgument `json:"counts,omitempty"`
+ DurationMs *int64 `json:"duration_ms,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Filenames *[]string `json:"filenames,omitempty"`
+ Kind string `json:"kind"`
+ Mode *string `json:"mode,omitempty"`
+ NumFiles *int64 `json:"num_files,omitempty"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ NumResults *int64 `json:"num_results,omitempty"`
+ Query *string `json:"query,omitempty"`
+ ResultItems *[]SessionStructuredSearchResultItem `json:"result_items,omitempty"`
+}
+
+// SessionStructuredToolResultStdin defines model for SessionStructuredToolResultStdin.
+type SessionStructuredToolResultStdin struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Kind string `json:"kind"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolResultTask defines model for SessionStructuredToolResultTask.
+type SessionStructuredToolResultTask struct {
+ Content *string `json:"content,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ ExitCode *int64 `json:"exit_code,omitempty"`
+ Kind string `json:"kind"`
+ Output *string `json:"output,omitempty"`
+ Stderr *string `json:"stderr,omitempty"`
+ Stdout *string `json:"stdout,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ TaskStatus *string `json:"task_status,omitempty"`
+ TaskType *string `json:"task_type,omitempty"`
+ Text *string `json:"text,omitempty"`
+ TotalDurationMs *int64 `json:"total_duration_ms,omitempty"`
+ TotalTokens *int64 `json:"total_tokens,omitempty"`
+ TotalToolUseCount *int64 `json:"total_tool_use_count,omitempty"`
+}
+
+// SessionStructuredToolResultText defines model for SessionStructuredToolResultText.
+type SessionStructuredToolResultText struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Kind string `json:"kind"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolResultTodo defines model for SessionStructuredToolResultTodo.
+type SessionStructuredToolResultTodo struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ Kind string `json:"kind"`
+ NewTodos *[]SessionStructuredTodoItem `json:"new_todos,omitempty"`
+ OldTodos *[]SessionStructuredTodoItem `json:"old_todos,omitempty"`
+ Text *string `json:"text,omitempty"`
+}
+
+// SessionStructuredToolResultUnknown defines model for SessionStructuredToolResultUnknown.
+type SessionStructuredToolResultUnknown struct {
+ Answer *string `json:"answer,omitempty"`
+ Answers *[]SessionStructuredArgument `json:"answers,omitempty"`
+ AppliedLimit *int64 `json:"applied_limit,omitempty"`
+ Bytes *int64 `json:"bytes,omitempty"`
+ Code *string `json:"code,omitempty"`
+ Command *string `json:"command,omitempty"`
+ Content *string `json:"content,omitempty"`
+ Counts *[]SessionStructuredArgument `json:"counts,omitempty"`
+ Description *string `json:"description,omitempty"`
+ DurationMs *int64 `json:"duration_ms,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ ExitCode *int64 `json:"exit_code,omitempty"`
+ Explanation *string `json:"explanation,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+ FilePaths *[]string `json:"file_paths,omitempty"`
+ Filenames *[]string `json:"filenames,omitempty"`
+ Interrupted *bool `json:"interrupted,omitempty"`
+ IsImage *bool `json:"is_image,omitempty"`
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+ Mode *string `json:"mode,omitempty"`
+ NewString *string `json:"new_string,omitempty"`
+ NewTodos *[]SessionStructuredTodoItem `json:"new_todos,omitempty"`
+ NumFiles *int64 `json:"num_files,omitempty"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ NumResults *int64 `json:"num_results,omitempty"`
+ OldString *string `json:"old_string,omitempty"`
+ OldTodos *[]SessionStructuredTodoItem `json:"old_todos,omitempty"`
+ Options *[]string `json:"options,omitempty"`
+ OriginalFile *string `json:"original_file,omitempty"`
+ Output *string `json:"output,omitempty"`
+ Patch *string `json:"patch,omitempty"`
+ PatchHunks *[]SessionStructuredPatchHunk `json:"patch_hunks,omitempty"`
+ Plan *string `json:"plan,omitempty"`
+ Query *string `json:"query,omitempty"`
+ Question *string `json:"question,omitempty"`
+ Questions *[]SessionStructuredQuestion `json:"questions,omitempty"`
+ ReplaceAll *bool `json:"replace_all,omitempty"`
+ ResultItems *[]SessionStructuredSearchResultItem `json:"result_items,omitempty"`
+ StartLine *int64 `json:"start_line,omitempty"`
+ StatusCode *int64 `json:"status_code,omitempty"`
+ StatusText *string `json:"status_text,omitempty"`
+ Stderr *string `json:"stderr,omitempty"`
+ StderrLines *int64 `json:"stderr_lines,omitempty"`
+ Stdout *string `json:"stdout,omitempty"`
+ StdoutLines *int64 `json:"stdout_lines,omitempty"`
+ Steps *[]SessionStructuredPlanStep `json:"steps,omitempty"`
+ TaskId *string `json:"task_id,omitempty"`
+ TaskStatus *string `json:"task_status,omitempty"`
+ TaskType *string `json:"task_type,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Timestamp *string `json:"timestamp,omitempty"`
+ TotalDurationMs *int64 `json:"total_duration_ms,omitempty"`
+ TotalLines *int64 `json:"total_lines,omitempty"`
+ TotalTokens *int64 `json:"total_tokens,omitempty"`
+ TotalToolUseCount *int64 `json:"total_tool_use_count,omitempty"`
+ Truncated *bool `json:"truncated,omitempty"`
+ Url *string `json:"url,omitempty"`
+ UserModified *bool `json:"user_modified,omitempty"`
+}
+
+// SessionStructuredToolResultWrite defines model for SessionStructuredToolResultWrite.
+type SessionStructuredToolResultWrite struct {
+ Content *string `json:"content,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+ FilePath *string `json:"file_path,omitempty"`
+ FilePaths *[]string `json:"file_paths,omitempty"`
+ Kind string `json:"kind"`
+ Language *string `json:"language,omitempty"`
+ NumLines *int64 `json:"num_lines,omitempty"`
+ Patch *string `json:"patch,omitempty"`
+ PatchHunks *[]SessionStructuredPatchHunk `json:"patch_hunks,omitempty"`
+ StartLine *int64 `json:"start_line,omitempty"`
+ Text *string `json:"text,omitempty"`
+ TotalLines *int64 `json:"total_lines,omitempty"`
+}
+
+// SessionStructuredUploadedFile defines model for SessionStructuredUploadedFile.
+type SessionStructuredUploadedFile struct {
+ FilePath *string `json:"file_path,omitempty"`
+ MimeType *string `json:"mime_type,omitempty"`
+ OriginalName *string `json:"original_name,omitempty"`
+ PreviewUrl *string `json:"preview_url,omitempty"`
+ Size *string `json:"size,omitempty"`
+}
+
+// SessionStructuredUsage defines model for SessionStructuredUsage.
+type SessionStructuredUsage struct {
+ CacheCreationTokens *int64 `json:"cache_creation_tokens,omitempty"`
+ CacheReadTokens *int64 `json:"cache_read_tokens,omitempty"`
+ ContextPercent *int64 `json:"context_percent,omitempty"`
+ ContextUsedTokens *int64 `json:"context_used_tokens,omitempty"`
+ ContextWindowTokens *int64 `json:"context_window_tokens,omitempty"`
+ InputTokens *int64 `json:"input_tokens,omitempty"`
+ OutputTokens *int64 `json:"output_tokens,omitempty"`
+ ReasoningTokens *int64 `json:"reasoning_tokens,omitempty"`
+}
+
+// SessionStructuredUserPrompt defines model for SessionStructuredUserPrompt.
+type SessionStructuredUserPrompt struct {
+ OpenedFiles *[]string `json:"opened_files,omitempty"`
+ Selections *[]SessionStructuredIDESelection `json:"selections,omitempty"`
+ Text *string `json:"text,omitempty"`
+ UploadedFiles *[]SessionStructuredUploadedFile `json:"uploaded_files,omitempty"`
+}
+
// SessionSubmitInputBody defines model for SessionSubmitInputBody.
type SessionSubmitInputBody struct {
// Intent Semantic delivery choice for a user message on a session submit request.
@@ -3539,22 +4634,67 @@ type SessionSubmitSucceededPayload struct {
SessionId string `json:"session_id"`
}
-// SessionTranscriptGetResponse defines model for SessionTranscriptGetResponse.
+// SessionTranscriptConversationResponse defines model for SessionTranscriptConversationResponse.
+type SessionTranscriptConversationResponse struct {
+ // Format Conversation or text transcript format.
+ Format SessionTranscriptConversationResponseFormat `json:"format"`
+ Id string `json:"id"`
+ Pagination *PaginationInfo `json:"pagination,omitempty"`
+
+ // Provider Producing provider identifier (claude, codex, gemini, opencode, etc.).
+ Provider string `json:"provider"`
+ Template string `json:"template"`
+
+ // Turns Conversation/text transcript turns.
+ Turns *[]OutputTurn `json:"turns,omitempty"`
+}
+
+// SessionTranscriptConversationResponseFormat Conversation or text transcript format.
+type SessionTranscriptConversationResponseFormat string
+
+// SessionTranscriptGetResponse Discriminated union of session transcript response shapes. Raw provider-native frames are available only on the raw branch; structured responses contain only provider-neutral typed data.
type SessionTranscriptGetResponse struct {
- // Format conversation, text, or raw.
- Format string `json:"format"`
- Id string `json:"id"`
+ union json.RawMessage
+}
- // Messages Populated for raw format; provider-native frames emitted verbatim as the provider wrote them.
- Messages *[]SessionRawMessageFrame `json:"messages,omitempty"`
+// SessionTranscriptRawResponse defines model for SessionTranscriptRawResponse.
+type SessionTranscriptRawResponse struct {
+ // Format Raw provider-native transcript format.
+ Format SessionTranscriptRawResponseFormat `json:"format"`
+ Id string `json:"id"`
+
+ // Messages Provider-native transcript frames emitted only for raw format.
+ Messages *[]SessionRawMessageFrame `json:"messages"`
Pagination *PaginationInfo `json:"pagination,omitempty"`
- // Provider Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing.
+ // Provider Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing.
Provider string `json:"provider"`
Template string `json:"template"`
+}
- // Turns Populated for conversation/text formats.
- Turns *[]OutputTurn `json:"turns,omitempty"`
+// SessionTranscriptRawResponseFormat Raw provider-native transcript format.
+type SessionTranscriptRawResponseFormat string
+
+// SessionTranscriptStructuredResponse Provider-neutral structured transcript snapshot.
+type SessionTranscriptStructuredResponse struct {
+ // Format Structured provider-neutral transcript format.
+ Format string `json:"format"`
+ History SessionStructuredHistory `json:"history"`
+ Id string `json:"id"`
+
+ // Operation Always snapshot for a REST structured transcript.
+ Operation string `json:"operation"`
+ Pagination *PaginationInfo `json:"pagination,omitempty"`
+
+ // Provider Producing provider identifier (claude, codex, gemini, opencode, etc.).
+ Provider string `json:"provider"`
+
+ // SchemaVersion Structured session transcript schema version.
+ SchemaVersion string `json:"schema_version"`
+
+ // StructuredMessages Provider-normalized structured messages.
+ StructuredMessages []SessionStructuredMessage `json:"structured_messages"`
+ Template string `json:"template"`
}
// SessionUnknownStatePayload defines model for SessionUnknownStatePayload.
@@ -3589,6 +4729,21 @@ type SlingInputBody struct {
// Formula Formula name for workflow launch.
Formula *string `json:"formula,omitempty"`
+ // Merge Merge strategy: direct, mr, or local.
+ Merge *string `json:"merge,omitempty"`
+
+ // NoConvoy Do not create an auto-convoy for the routed bead.
+ NoConvoy *bool `json:"no_convoy,omitempty"`
+
+ // NoFormula Suppress the target's default_sling_formula even when configured.
+ NoFormula *bool `json:"no_formula,omitempty"`
+
+ // Owned Mark the routed bead as owned by the target.
+ Owned *bool `json:"owned,omitempty"`
+
+ // Reassign Clear any existing human assignee on the bead before routing, so a bead claimed via bd update --claim is handed to the target's pool.
+ Reassign *bool `json:"reassign,omitempty"`
+
// Rig Rig name.
Rig *string `json:"rig,omitempty"`
@@ -3882,13 +5037,13 @@ type StatusStoreHealth struct {
// LastGcStatus Status of last maintenance run ('success' or 'failed').
LastGcStatus *string `json:"last_gc_status,omitempty"`
- // LiveRows Live bead row count.
+ // LiveRows Retained bead row count used as the denominator, including open and closed beads.
LiveRows int64 `json:"live_rows"`
// Path On-disk path of the Dolt store.
Path string `json:"path"`
- // RatioMbPerRow Derived megabytes per row.
+ // RatioMbPerRow Derived megabytes per retained row, including open and closed beads.
RatioMbPerRow float64 `json:"ratio_mb_per_row"`
// SizeBytes Total bytes of the store directory.
@@ -4000,7 +5155,7 @@ type SupervisorCitiesOutputBody struct {
// SupervisorEventListOutputBody defines model for SupervisorEventListOutputBody.
type SupervisorEventListOutputBody struct {
- // EventCursor Supervisor event-stream cursor captured before the history snapshot was listed. Pass this value as after_cursor to /v0/events/stream to receive events emitted after the snapshot boundary without replaying unrelated historical backlog.
+ // EventCursor Supervisor event-stream cursor captured before the history snapshot was listed. Pass this value as after_cursor to /v0/events/stream to receive events emitted after the snapshot boundary. A populated cursor resumes each city at its exact per-city position, so no unrelated historical backlog is replayed. The value 0 is returned only when no event provider is registered at capture time; passing 0 back requests a replay from zero for every provider present at resume time.
EventCursor string `json:"event_cursor"`
Items *[]TypedTaggedEventStreamEnvelope `json:"items"`
Total int64 `json:"total"`
@@ -6899,7 +8054,8 @@ type UnboundEventPayload struct {
// UsageBody defines model for UsageBody.
type UsageBody struct {
// Available True when this city is configured to record local usage estimates.
- Available bool `json:"available"`
+ Available bool `json:"available"`
+ Last24h *UsageTotals `json:"last_24h,omitempty"`
// ObservedFrom RFC3339 timestamp of the oldest fact included in this bounded read.
ObservedFrom *string `json:"observed_from,omitempty"`
@@ -7422,10 +8578,10 @@ type GetV0CityByCityNameBeadsParams struct {
// Wait How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.
Wait *string `form:"wait,omitempty" json:"wait,omitempty"`
- // Cursor Pagination cursor from a previous response's next_cursor field.
+ // Cursor Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.
Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"`
- // Limit Maximum number of results to return. 0 = server default.
+ // Limit Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
// Status Filter by bead status.
@@ -7497,10 +8653,10 @@ type GetV0CityByCityNameConvoysParams struct {
// Wait How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.
Wait *string `form:"wait,omitempty" json:"wait,omitempty"`
- // Cursor Pagination cursor from a previous response's next_cursor field.
+ // Cursor Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.
Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"`
- // Limit Maximum number of results to return. 0 = server default.
+ // Limit Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
}
@@ -7521,10 +8677,10 @@ type GetV0CityByCityNameEventsParams struct {
// Wait How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.
Wait *string `form:"wait,omitempty" json:"wait,omitempty"`
- // Cursor Pagination cursor from a previous response's next_cursor field.
+ // Cursor Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.
Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"`
- // Limit Maximum number of results to return. 0 = server default.
+ // Limit Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
// Type Filter by event type.
@@ -7773,10 +8929,10 @@ type GetV0CityByCityNameMailParams struct {
// Wait How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.
Wait *string `form:"wait,omitempty" json:"wait,omitempty"`
- // Cursor Pagination cursor from a previous response's next_cursor field.
+ // Cursor Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.
Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"`
- // Limit Maximum number of results to return. 0 = server default.
+ // Limit Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
// Agent Filter by agent name.
@@ -8070,7 +9226,7 @@ type CreateRigParams struct {
// XGCRequest Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.
XGCRequest string `json:"X-GC-Request"`
- // IdempotencyKey Idempotency key for safe retries.
+ // IdempotencyKey Idempotency key for safe retries (synchronous create).
IdempotencyKey *string `json:"Idempotency-Key,omitempty"`
}
@@ -8154,10 +9310,22 @@ type PostV0CityByCityNameSessionByIdStopParams struct {
// StreamSessionParams defines parameters for StreamSession.
type StreamSessionParams struct {
- // Format Transcript format: conversation (default) or raw.
- Format *string `form:"format,omitempty" json:"format,omitempty"`
+ // Format Transcript format: conversation (default), raw, or structured.
+ Format *StreamSessionParamsFormat `form:"format,omitempty" json:"format,omitempty"`
+
+ // IncludeThinking Include thinking block text and signature in structured stream frames. Defaults to false; both are redacted otherwise.
+ IncludeThinking *bool `form:"include_thinking,omitempty" json:"include_thinking,omitempty"`
+
+ // AfterCursor Opaque structured transcript resume cursor from the REST snapshot. Last-Event-ID takes precedence on automatic SSE reconnect.
+ AfterCursor *string `form:"after_cursor,omitempty" json:"after_cursor,omitempty"`
+
+ // LastEventID Opaque structured transcript resume cursor from the last received SSE frame. Takes precedence over after_cursor.
+ LastEventID *string `json:"Last-Event-ID,omitempty"`
}
+// StreamSessionParamsFormat defines parameters for StreamSession.
+type StreamSessionParamsFormat string
+
// SubmitSessionParams defines parameters for SubmitSession.
type SubmitSessionParams struct {
// XGCRequest Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.
@@ -8175,16 +9343,22 @@ type GetV0CityByCityNameSessionByIdTranscriptParams struct {
// Tail Number of recent compaction segments to return. This API parameter keeps compaction-segment semantics even though gc session logs --tail counts displayed transcript entries. Omit for the endpoint default (usually 1); 0 returns all segments; N>0 returns the last N.
Tail *string `form:"tail,omitempty" json:"tail,omitempty"`
- // Format Transcript format: conversation (default) or raw.
- Format *string `form:"format,omitempty" json:"format,omitempty"`
+ // Format Transcript format: conversation (default), raw, or structured.
+ Format *GetV0CityByCityNameSessionByIdTranscriptParamsFormat `form:"format,omitempty" json:"format,omitempty"`
+
+ // IncludeThinking Include thinking block text and signature in structured responses. Defaults to false; both are redacted otherwise.
+ IncludeThinking *bool `form:"include_thinking,omitempty" json:"include_thinking,omitempty"`
- // Before Pagination cursor: return entries before this UUID.
+ // Before Pagination cursor: return entries before this stable transcript entry ID.
Before *string `form:"before,omitempty" json:"before,omitempty"`
- // After Pagination cursor: return entries after this UUID.
+ // After Pagination cursor: return entries after this stable transcript entry ID.
After *string `form:"after,omitempty" json:"after,omitempty"`
}
+// GetV0CityByCityNameSessionByIdTranscriptParamsFormat defines parameters for GetV0CityByCityNameSessionByIdTranscript.
+type GetV0CityByCityNameSessionByIdTranscriptParamsFormat string
+
// PostV0CityByCityNameSessionByIdWakeParams defines parameters for PostV0CityByCityNameSessionByIdWake.
type PostV0CityByCityNameSessionByIdWakeParams struct {
// XGCRequest Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.
@@ -8193,10 +9367,10 @@ type PostV0CityByCityNameSessionByIdWakeParams struct {
// GetV0CityByCityNameSessionsParams defines parameters for GetV0CityByCityNameSessions.
type GetV0CityByCityNameSessionsParams struct {
- // Cursor Pagination cursor from a previous response's next_cursor field.
+ // Cursor Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.
Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"`
- // Limit Maximum number of results to return. 0 = server default.
+ // Limit Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
// State Filter by session state (e.g. active, closed).
@@ -9941,6 +11115,32 @@ func (t *SessionStreamCommonEvent) MergePendingInteraction(v PendingInteraction)
return err
}
+// AsSessionPendingClearedEvent returns the union data inside the SessionStreamCommonEvent as a SessionPendingClearedEvent
+func (t SessionStreamCommonEvent) AsSessionPendingClearedEvent() (SessionPendingClearedEvent, error) {
+ var body SessionPendingClearedEvent
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionPendingClearedEvent overwrites any union data inside the SessionStreamCommonEvent as the provided SessionPendingClearedEvent
+func (t *SessionStreamCommonEvent) FromSessionPendingClearedEvent(v SessionPendingClearedEvent) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionPendingClearedEvent performs a merge with any union data inside the SessionStreamCommonEvent, using the provided SessionPendingClearedEvent
+func (t *SessionStreamCommonEvent) MergeSessionPendingClearedEvent(v SessionPendingClearedEvent) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
// AsHeartbeatEvent returns the union data inside the SessionStreamCommonEvent as a HeartbeatEvent
func (t SessionStreamCommonEvent) AsHeartbeatEvent() (HeartbeatEvent, error) {
var body HeartbeatEvent
@@ -9977,6 +11177,1557 @@ func (t *SessionStreamCommonEvent) UnmarshalJSON(b []byte) error {
return err
}
+// AsSessionStructuredBlockText returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockText
+func (t SessionStructuredBlock) AsSessionStructuredBlockText() (SessionStructuredBlockText, error) {
+ var body SessionStructuredBlockText
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockText overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockText
+func (t *SessionStructuredBlock) FromSessionStructuredBlockText(v SessionStructuredBlockText) error {
+ v.Type = "text"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockText performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockText
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockText(v SessionStructuredBlockText) error {
+ v.Type = "text"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredBlockThinking returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockThinking
+func (t SessionStructuredBlock) AsSessionStructuredBlockThinking() (SessionStructuredBlockThinking, error) {
+ var body SessionStructuredBlockThinking
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockThinking overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockThinking
+func (t *SessionStructuredBlock) FromSessionStructuredBlockThinking(v SessionStructuredBlockThinking) error {
+ v.Type = "thinking"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockThinking performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockThinking
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockThinking(v SessionStructuredBlockThinking) error {
+ v.Type = "thinking"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredBlockToolUse returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockToolUse
+func (t SessionStructuredBlock) AsSessionStructuredBlockToolUse() (SessionStructuredBlockToolUse, error) {
+ var body SessionStructuredBlockToolUse
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockToolUse overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockToolUse
+func (t *SessionStructuredBlock) FromSessionStructuredBlockToolUse(v SessionStructuredBlockToolUse) error {
+ v.Type = "tool_use"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockToolUse performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockToolUse
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockToolUse(v SessionStructuredBlockToolUse) error {
+ v.Type = "tool_use"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredBlockToolResult returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockToolResult
+func (t SessionStructuredBlock) AsSessionStructuredBlockToolResult() (SessionStructuredBlockToolResult, error) {
+ var body SessionStructuredBlockToolResult
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockToolResult overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockToolResult
+func (t *SessionStructuredBlock) FromSessionStructuredBlockToolResult(v SessionStructuredBlockToolResult) error {
+ v.Type = "tool_result"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockToolResult performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockToolResult
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockToolResult(v SessionStructuredBlockToolResult) error {
+ v.Type = "tool_result"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredBlockInteraction returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockInteraction
+func (t SessionStructuredBlock) AsSessionStructuredBlockInteraction() (SessionStructuredBlockInteraction, error) {
+ var body SessionStructuredBlockInteraction
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockInteraction overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockInteraction
+func (t *SessionStructuredBlock) FromSessionStructuredBlockInteraction(v SessionStructuredBlockInteraction) error {
+ v.Type = "interaction"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockInteraction performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockInteraction
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockInteraction(v SessionStructuredBlockInteraction) error {
+ v.Type = "interaction"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredBlockImage returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockImage
+func (t SessionStructuredBlock) AsSessionStructuredBlockImage() (SessionStructuredBlockImage, error) {
+ var body SessionStructuredBlockImage
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockImage overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockImage
+func (t *SessionStructuredBlock) FromSessionStructuredBlockImage(v SessionStructuredBlockImage) error {
+ v.Type = "image"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockImage performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockImage
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockImage(v SessionStructuredBlockImage) error {
+ v.Type = "image"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredBlockUnknown returns the union data inside the SessionStructuredBlock as a SessionStructuredBlockUnknown
+func (t SessionStructuredBlock) AsSessionStructuredBlockUnknown() (SessionStructuredBlockUnknown, error) {
+ var body SessionStructuredBlockUnknown
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredBlockUnknown overwrites any union data inside the SessionStructuredBlock as the provided SessionStructuredBlockUnknown
+func (t *SessionStructuredBlock) FromSessionStructuredBlockUnknown(v SessionStructuredBlockUnknown) error {
+ v.Type = "unknown"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredBlockUnknown performs a merge with any union data inside the SessionStructuredBlock, using the provided SessionStructuredBlockUnknown
+func (t *SessionStructuredBlock) MergeSessionStructuredBlockUnknown(v SessionStructuredBlockUnknown) error {
+ v.Type = "unknown"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t SessionStructuredBlock) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"type"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t SessionStructuredBlock) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "image":
+ return t.AsSessionStructuredBlockImage()
+ case "interaction":
+ return t.AsSessionStructuredBlockInteraction()
+ case "text":
+ return t.AsSessionStructuredBlockText()
+ case "thinking":
+ return t.AsSessionStructuredBlockThinking()
+ case "tool_result":
+ return t.AsSessionStructuredBlockToolResult()
+ case "tool_use":
+ return t.AsSessionStructuredBlockToolUse()
+ case "unknown":
+ return t.AsSessionStructuredBlockUnknown()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t SessionStructuredBlock) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *SessionStructuredBlock) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
+// AsSessionStructuredMessageUnknown returns the union data inside the SessionStructuredMessage as a SessionStructuredMessageUnknown
+func (t SessionStructuredMessage) AsSessionStructuredMessageUnknown() (SessionStructuredMessageUnknown, error) {
+ var body SessionStructuredMessageUnknown
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredMessageUnknown overwrites any union data inside the SessionStructuredMessage as the provided SessionStructuredMessageUnknown
+func (t *SessionStructuredMessage) FromSessionStructuredMessageUnknown(v SessionStructuredMessageUnknown) error {
+ v.Role = "unknown"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredMessageUnknown performs a merge with any union data inside the SessionStructuredMessage, using the provided SessionStructuredMessageUnknown
+func (t *SessionStructuredMessage) MergeSessionStructuredMessageUnknown(v SessionStructuredMessageUnknown) error {
+ v.Role = "unknown"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredMessageUser returns the union data inside the SessionStructuredMessage as a SessionStructuredMessageUser
+func (t SessionStructuredMessage) AsSessionStructuredMessageUser() (SessionStructuredMessageUser, error) {
+ var body SessionStructuredMessageUser
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredMessageUser overwrites any union data inside the SessionStructuredMessage as the provided SessionStructuredMessageUser
+func (t *SessionStructuredMessage) FromSessionStructuredMessageUser(v SessionStructuredMessageUser) error {
+ v.Role = "user"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredMessageUser performs a merge with any union data inside the SessionStructuredMessage, using the provided SessionStructuredMessageUser
+func (t *SessionStructuredMessage) MergeSessionStructuredMessageUser(v SessionStructuredMessageUser) error {
+ v.Role = "user"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredMessageAssistant returns the union data inside the SessionStructuredMessage as a SessionStructuredMessageAssistant
+func (t SessionStructuredMessage) AsSessionStructuredMessageAssistant() (SessionStructuredMessageAssistant, error) {
+ var body SessionStructuredMessageAssistant
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredMessageAssistant overwrites any union data inside the SessionStructuredMessage as the provided SessionStructuredMessageAssistant
+func (t *SessionStructuredMessage) FromSessionStructuredMessageAssistant(v SessionStructuredMessageAssistant) error {
+ v.Role = "assistant"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredMessageAssistant performs a merge with any union data inside the SessionStructuredMessage, using the provided SessionStructuredMessageAssistant
+func (t *SessionStructuredMessage) MergeSessionStructuredMessageAssistant(v SessionStructuredMessageAssistant) error {
+ v.Role = "assistant"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredMessageSystem returns the union data inside the SessionStructuredMessage as a SessionStructuredMessageSystem
+func (t SessionStructuredMessage) AsSessionStructuredMessageSystem() (SessionStructuredMessageSystem, error) {
+ var body SessionStructuredMessageSystem
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredMessageSystem overwrites any union data inside the SessionStructuredMessage as the provided SessionStructuredMessageSystem
+func (t *SessionStructuredMessage) FromSessionStructuredMessageSystem(v SessionStructuredMessageSystem) error {
+ v.Role = "system"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredMessageSystem performs a merge with any union data inside the SessionStructuredMessage, using the provided SessionStructuredMessageSystem
+func (t *SessionStructuredMessage) MergeSessionStructuredMessageSystem(v SessionStructuredMessageSystem) error {
+ v.Role = "system"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredMessageTool returns the union data inside the SessionStructuredMessage as a SessionStructuredMessageTool
+func (t SessionStructuredMessage) AsSessionStructuredMessageTool() (SessionStructuredMessageTool, error) {
+ var body SessionStructuredMessageTool
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredMessageTool overwrites any union data inside the SessionStructuredMessage as the provided SessionStructuredMessageTool
+func (t *SessionStructuredMessage) FromSessionStructuredMessageTool(v SessionStructuredMessageTool) error {
+ v.Role = "tool"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredMessageTool performs a merge with any union data inside the SessionStructuredMessage, using the provided SessionStructuredMessageTool
+func (t *SessionStructuredMessage) MergeSessionStructuredMessageTool(v SessionStructuredMessageTool) error {
+ v.Role = "tool"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t SessionStructuredMessage) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"role"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t SessionStructuredMessage) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "assistant":
+ return t.AsSessionStructuredMessageAssistant()
+ case "system":
+ return t.AsSessionStructuredMessageSystem()
+ case "tool":
+ return t.AsSessionStructuredMessageTool()
+ case "unknown":
+ return t.AsSessionStructuredMessageUnknown()
+ case "user":
+ return t.AsSessionStructuredMessageUser()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t SessionStructuredMessage) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *SessionStructuredMessage) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
+// AsSessionStructuredToolInputUnknown returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputUnknown
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputUnknown() (SessionStructuredToolInputUnknown, error) {
+ var body SessionStructuredToolInputUnknown
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputUnknown overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputUnknown
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputUnknown(v SessionStructuredToolInputUnknown) error {
+ v.Kind = "unknown"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputUnknown performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputUnknown
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputUnknown(v SessionStructuredToolInputUnknown) error {
+ v.Kind = "unknown"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputCommand returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputCommand
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputCommand() (SessionStructuredToolInputCommand, error) {
+ var body SessionStructuredToolInputCommand
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputCommand overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputCommand
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputCommand(v SessionStructuredToolInputCommand) error {
+ v.Kind = "command"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputCommand performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputCommand
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputCommand(v SessionStructuredToolInputCommand) error {
+ v.Kind = "command"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputStdin returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputStdin
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputStdin() (SessionStructuredToolInputStdin, error) {
+ var body SessionStructuredToolInputStdin
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputStdin overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputStdin
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputStdin(v SessionStructuredToolInputStdin) error {
+ v.Kind = "stdin"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputStdin performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputStdin
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputStdin(v SessionStructuredToolInputStdin) error {
+ v.Kind = "stdin"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputCode returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputCode
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputCode() (SessionStructuredToolInputCode, error) {
+ var body SessionStructuredToolInputCode
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputCode overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputCode
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputCode(v SessionStructuredToolInputCode) error {
+ v.Kind = "code"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputCode performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputCode
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputCode(v SessionStructuredToolInputCode) error {
+ v.Kind = "code"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputPatch returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputPatch
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputPatch() (SessionStructuredToolInputPatch, error) {
+ var body SessionStructuredToolInputPatch
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputPatch overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputPatch
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputPatch(v SessionStructuredToolInputPatch) error {
+ v.Kind = "patch"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputPatch performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputPatch
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputPatch(v SessionStructuredToolInputPatch) error {
+ v.Kind = "patch"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputWrite returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputWrite
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputWrite() (SessionStructuredToolInputWrite, error) {
+ var body SessionStructuredToolInputWrite
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputWrite overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputWrite
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputWrite(v SessionStructuredToolInputWrite) error {
+ v.Kind = "write"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputWrite performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputWrite
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputWrite(v SessionStructuredToolInputWrite) error {
+ v.Kind = "write"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputGlob returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputGlob
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputGlob() (SessionStructuredToolInputGlob, error) {
+ var body SessionStructuredToolInputGlob
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputGlob overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputGlob
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputGlob(v SessionStructuredToolInputGlob) error {
+ v.Kind = "glob"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputGlob performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputGlob
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputGlob(v SessionStructuredToolInputGlob) error {
+ v.Kind = "glob"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputFetch returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputFetch
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputFetch() (SessionStructuredToolInputFetch, error) {
+ var body SessionStructuredToolInputFetch
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputFetch overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputFetch
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputFetch(v SessionStructuredToolInputFetch) error {
+ v.Kind = "fetch"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputFetch performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputFetch
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputFetch(v SessionStructuredToolInputFetch) error {
+ v.Kind = "fetch"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputSearch returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputSearch
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputSearch() (SessionStructuredToolInputSearch, error) {
+ var body SessionStructuredToolInputSearch
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputSearch overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputSearch
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputSearch(v SessionStructuredToolInputSearch) error {
+ v.Kind = "search"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputSearch performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputSearch
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputSearch(v SessionStructuredToolInputSearch) error {
+ v.Kind = "search"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputFile returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputFile
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputFile() (SessionStructuredToolInputFile, error) {
+ var body SessionStructuredToolInputFile
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputFile overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputFile
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputFile(v SessionStructuredToolInputFile) error {
+ v.Kind = "file"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputFile performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputFile
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputFile(v SessionStructuredToolInputFile) error {
+ v.Kind = "file"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputTodo returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputTodo
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputTodo() (SessionStructuredToolInputTodo, error) {
+ var body SessionStructuredToolInputTodo
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputTodo overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputTodo
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputTodo(v SessionStructuredToolInputTodo) error {
+ v.Kind = "todo"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputTodo performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputTodo
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputTodo(v SessionStructuredToolInputTodo) error {
+ v.Kind = "todo"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputPlan returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputPlan
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputPlan() (SessionStructuredToolInputPlan, error) {
+ var body SessionStructuredToolInputPlan
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputPlan overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputPlan
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputPlan(v SessionStructuredToolInputPlan) error {
+ v.Kind = "plan"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputPlan performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputPlan
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputPlan(v SessionStructuredToolInputPlan) error {
+ v.Kind = "plan"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputQuestion returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputQuestion
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputQuestion() (SessionStructuredToolInputQuestion, error) {
+ var body SessionStructuredToolInputQuestion
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputQuestion overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputQuestion
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputQuestion(v SessionStructuredToolInputQuestion) error {
+ v.Kind = "question"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputQuestion performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputQuestion
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputQuestion(v SessionStructuredToolInputQuestion) error {
+ v.Kind = "question"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputTask returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputTask
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputTask() (SessionStructuredToolInputTask, error) {
+ var body SessionStructuredToolInputTask
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputTask overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputTask
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputTask(v SessionStructuredToolInputTask) error {
+ v.Kind = "task"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputTask performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputTask
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputTask(v SessionStructuredToolInputTask) error {
+ v.Kind = "task"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputText returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputText
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputText() (SessionStructuredToolInputText, error) {
+ var body SessionStructuredToolInputText
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputText overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputText
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputText(v SessionStructuredToolInputText) error {
+ v.Kind = "text"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputText performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputText
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputText(v SessionStructuredToolInputText) error {
+ v.Kind = "text"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolInputArguments returns the union data inside the SessionStructuredToolInput as a SessionStructuredToolInputArguments
+func (t SessionStructuredToolInput) AsSessionStructuredToolInputArguments() (SessionStructuredToolInputArguments, error) {
+ var body SessionStructuredToolInputArguments
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolInputArguments overwrites any union data inside the SessionStructuredToolInput as the provided SessionStructuredToolInputArguments
+func (t *SessionStructuredToolInput) FromSessionStructuredToolInputArguments(v SessionStructuredToolInputArguments) error {
+ v.Kind = "arguments"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolInputArguments performs a merge with any union data inside the SessionStructuredToolInput, using the provided SessionStructuredToolInputArguments
+func (t *SessionStructuredToolInput) MergeSessionStructuredToolInputArguments(v SessionStructuredToolInputArguments) error {
+ v.Kind = "arguments"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t SessionStructuredToolInput) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"kind"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t SessionStructuredToolInput) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "arguments":
+ return t.AsSessionStructuredToolInputArguments()
+ case "code":
+ return t.AsSessionStructuredToolInputCode()
+ case "command":
+ return t.AsSessionStructuredToolInputCommand()
+ case "fetch":
+ return t.AsSessionStructuredToolInputFetch()
+ case "file":
+ return t.AsSessionStructuredToolInputFile()
+ case "glob":
+ return t.AsSessionStructuredToolInputGlob()
+ case "patch":
+ return t.AsSessionStructuredToolInputPatch()
+ case "plan":
+ return t.AsSessionStructuredToolInputPlan()
+ case "question":
+ return t.AsSessionStructuredToolInputQuestion()
+ case "search":
+ return t.AsSessionStructuredToolInputSearch()
+ case "stdin":
+ return t.AsSessionStructuredToolInputStdin()
+ case "task":
+ return t.AsSessionStructuredToolInputTask()
+ case "text":
+ return t.AsSessionStructuredToolInputText()
+ case "todo":
+ return t.AsSessionStructuredToolInputTodo()
+ case "unknown":
+ return t.AsSessionStructuredToolInputUnknown()
+ case "write":
+ return t.AsSessionStructuredToolInputWrite()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t SessionStructuredToolInput) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *SessionStructuredToolInput) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
+// AsSessionStructuredToolResultUnknown returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultUnknown
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultUnknown() (SessionStructuredToolResultUnknown, error) {
+ var body SessionStructuredToolResultUnknown
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultUnknown overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultUnknown
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultUnknown(v SessionStructuredToolResultUnknown) error {
+ v.Kind = "unknown"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultUnknown performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultUnknown
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultUnknown(v SessionStructuredToolResultUnknown) error {
+ v.Kind = "unknown"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultBash returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultBash
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultBash() (SessionStructuredToolResultBash, error) {
+ var body SessionStructuredToolResultBash
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultBash overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultBash
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultBash(v SessionStructuredToolResultBash) error {
+ v.Kind = "bash"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultBash performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultBash
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultBash(v SessionStructuredToolResultBash) error {
+ v.Kind = "bash"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultPython returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultPython
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultPython() (SessionStructuredToolResultPython, error) {
+ var body SessionStructuredToolResultPython
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultPython overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultPython
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultPython(v SessionStructuredToolResultPython) error {
+ v.Kind = "python"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultPython performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultPython
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultPython(v SessionStructuredToolResultPython) error {
+ v.Kind = "python"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultRead returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultRead
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultRead() (SessionStructuredToolResultRead, error) {
+ var body SessionStructuredToolResultRead
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultRead overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultRead
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultRead(v SessionStructuredToolResultRead) error {
+ v.Kind = "read"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultRead performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultRead
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultRead(v SessionStructuredToolResultRead) error {
+ v.Kind = "read"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultGlob returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultGlob
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultGlob() (SessionStructuredToolResultGlob, error) {
+ var body SessionStructuredToolResultGlob
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultGlob overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultGlob
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultGlob(v SessionStructuredToolResultGlob) error {
+ v.Kind = "glob"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultGlob performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultGlob
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultGlob(v SessionStructuredToolResultGlob) error {
+ v.Kind = "glob"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultGrep returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultGrep
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultGrep() (SessionStructuredToolResultGrep, error) {
+ var body SessionStructuredToolResultGrep
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultGrep overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultGrep
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultGrep(v SessionStructuredToolResultGrep) error {
+ v.Kind = "grep"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultGrep performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultGrep
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultGrep(v SessionStructuredToolResultGrep) error {
+ v.Kind = "grep"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultSearch returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultSearch
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultSearch() (SessionStructuredToolResultSearch, error) {
+ var body SessionStructuredToolResultSearch
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultSearch overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultSearch
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultSearch(v SessionStructuredToolResultSearch) error {
+ v.Kind = "search"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultSearch performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultSearch
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultSearch(v SessionStructuredToolResultSearch) error {
+ v.Kind = "search"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultFetch returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultFetch
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultFetch() (SessionStructuredToolResultFetch, error) {
+ var body SessionStructuredToolResultFetch
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultFetch overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultFetch
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultFetch(v SessionStructuredToolResultFetch) error {
+ v.Kind = "fetch"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultFetch performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultFetch
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultFetch(v SessionStructuredToolResultFetch) error {
+ v.Kind = "fetch"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultTodo returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultTodo
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultTodo() (SessionStructuredToolResultTodo, error) {
+ var body SessionStructuredToolResultTodo
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultTodo overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultTodo
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultTodo(v SessionStructuredToolResultTodo) error {
+ v.Kind = "todo"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultTodo performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultTodo
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultTodo(v SessionStructuredToolResultTodo) error {
+ v.Kind = "todo"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultPlan returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultPlan
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultPlan() (SessionStructuredToolResultPlan, error) {
+ var body SessionStructuredToolResultPlan
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultPlan overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultPlan
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultPlan(v SessionStructuredToolResultPlan) error {
+ v.Kind = "plan"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultPlan performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultPlan
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultPlan(v SessionStructuredToolResultPlan) error {
+ v.Kind = "plan"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultQuestion returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultQuestion
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultQuestion() (SessionStructuredToolResultQuestion, error) {
+ var body SessionStructuredToolResultQuestion
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultQuestion overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultQuestion
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultQuestion(v SessionStructuredToolResultQuestion) error {
+ v.Kind = "question"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultQuestion performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultQuestion
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultQuestion(v SessionStructuredToolResultQuestion) error {
+ v.Kind = "question"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultStdin returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultStdin
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultStdin() (SessionStructuredToolResultStdin, error) {
+ var body SessionStructuredToolResultStdin
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultStdin overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultStdin
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultStdin(v SessionStructuredToolResultStdin) error {
+ v.Kind = "stdin"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultStdin performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultStdin
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultStdin(v SessionStructuredToolResultStdin) error {
+ v.Kind = "stdin"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultTask returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultTask
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultTask() (SessionStructuredToolResultTask, error) {
+ var body SessionStructuredToolResultTask
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultTask overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultTask
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultTask(v SessionStructuredToolResultTask) error {
+ v.Kind = "task"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultTask performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultTask
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultTask(v SessionStructuredToolResultTask) error {
+ v.Kind = "task"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultWrite returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultWrite
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultWrite() (SessionStructuredToolResultWrite, error) {
+ var body SessionStructuredToolResultWrite
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultWrite overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultWrite
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultWrite(v SessionStructuredToolResultWrite) error {
+ v.Kind = "write"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultWrite performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultWrite
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultWrite(v SessionStructuredToolResultWrite) error {
+ v.Kind = "write"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultEdit returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultEdit
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultEdit() (SessionStructuredToolResultEdit, error) {
+ var body SessionStructuredToolResultEdit
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultEdit overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultEdit
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultEdit(v SessionStructuredToolResultEdit) error {
+ v.Kind = "edit"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultEdit performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultEdit
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultEdit(v SessionStructuredToolResultEdit) error {
+ v.Kind = "edit"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionStructuredToolResultText returns the union data inside the SessionStructuredToolResult as a SessionStructuredToolResultText
+func (t SessionStructuredToolResult) AsSessionStructuredToolResultText() (SessionStructuredToolResultText, error) {
+ var body SessionStructuredToolResultText
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionStructuredToolResultText overwrites any union data inside the SessionStructuredToolResult as the provided SessionStructuredToolResultText
+func (t *SessionStructuredToolResult) FromSessionStructuredToolResultText(v SessionStructuredToolResultText) error {
+ v.Kind = "text"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionStructuredToolResultText performs a merge with any union data inside the SessionStructuredToolResult, using the provided SessionStructuredToolResultText
+func (t *SessionStructuredToolResult) MergeSessionStructuredToolResultText(v SessionStructuredToolResultText) error {
+ v.Kind = "text"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t SessionStructuredToolResult) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"kind"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t SessionStructuredToolResult) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "bash":
+ return t.AsSessionStructuredToolResultBash()
+ case "edit":
+ return t.AsSessionStructuredToolResultEdit()
+ case "fetch":
+ return t.AsSessionStructuredToolResultFetch()
+ case "glob":
+ return t.AsSessionStructuredToolResultGlob()
+ case "grep":
+ return t.AsSessionStructuredToolResultGrep()
+ case "plan":
+ return t.AsSessionStructuredToolResultPlan()
+ case "python":
+ return t.AsSessionStructuredToolResultPython()
+ case "question":
+ return t.AsSessionStructuredToolResultQuestion()
+ case "read":
+ return t.AsSessionStructuredToolResultRead()
+ case "search":
+ return t.AsSessionStructuredToolResultSearch()
+ case "stdin":
+ return t.AsSessionStructuredToolResultStdin()
+ case "task":
+ return t.AsSessionStructuredToolResultTask()
+ case "text":
+ return t.AsSessionStructuredToolResultText()
+ case "todo":
+ return t.AsSessionStructuredToolResultTodo()
+ case "unknown":
+ return t.AsSessionStructuredToolResultUnknown()
+ case "write":
+ return t.AsSessionStructuredToolResultWrite()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t SessionStructuredToolResult) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *SessionStructuredToolResult) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
+// AsSessionTranscriptConversationResponse returns the union data inside the SessionTranscriptGetResponse as a SessionTranscriptConversationResponse
+func (t SessionTranscriptGetResponse) AsSessionTranscriptConversationResponse() (SessionTranscriptConversationResponse, error) {
+ var body SessionTranscriptConversationResponse
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionTranscriptConversationResponse overwrites any union data inside the SessionTranscriptGetResponse as the provided SessionTranscriptConversationResponse
+func (t *SessionTranscriptGetResponse) FromSessionTranscriptConversationResponse(v SessionTranscriptConversationResponse) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionTranscriptConversationResponse performs a merge with any union data inside the SessionTranscriptGetResponse, using the provided SessionTranscriptConversationResponse
+func (t *SessionTranscriptGetResponse) MergeSessionTranscriptConversationResponse(v SessionTranscriptConversationResponse) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionTranscriptRawResponse returns the union data inside the SessionTranscriptGetResponse as a SessionTranscriptRawResponse
+func (t SessionTranscriptGetResponse) AsSessionTranscriptRawResponse() (SessionTranscriptRawResponse, error) {
+ var body SessionTranscriptRawResponse
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionTranscriptRawResponse overwrites any union data inside the SessionTranscriptGetResponse as the provided SessionTranscriptRawResponse
+func (t *SessionTranscriptGetResponse) FromSessionTranscriptRawResponse(v SessionTranscriptRawResponse) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionTranscriptRawResponse performs a merge with any union data inside the SessionTranscriptGetResponse, using the provided SessionTranscriptRawResponse
+func (t *SessionTranscriptGetResponse) MergeSessionTranscriptRawResponse(v SessionTranscriptRawResponse) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsSessionTranscriptStructuredResponse returns the union data inside the SessionTranscriptGetResponse as a SessionTranscriptStructuredResponse
+func (t SessionTranscriptGetResponse) AsSessionTranscriptStructuredResponse() (SessionTranscriptStructuredResponse, error) {
+ var body SessionTranscriptStructuredResponse
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromSessionTranscriptStructuredResponse overwrites any union data inside the SessionTranscriptGetResponse as the provided SessionTranscriptStructuredResponse
+func (t *SessionTranscriptGetResponse) FromSessionTranscriptStructuredResponse(v SessionTranscriptStructuredResponse) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeSessionTranscriptStructuredResponse performs a merge with any union data inside the SessionTranscriptGetResponse, using the provided SessionTranscriptStructuredResponse
+func (t *SessionTranscriptGetResponse) MergeSessionTranscriptStructuredResponse(v SessionTranscriptStructuredResponse) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t SessionTranscriptGetResponse) Discriminator() (string, error) {
+ var discriminator struct {
+ Discriminator string `json:"format"`
+ }
+ err := json.Unmarshal(t.union, &discriminator)
+ return discriminator.Discriminator, err
+}
+
+func (t SessionTranscriptGetResponse) ValueByDiscriminator() (interface{}, error) {
+ discriminator, err := t.Discriminator()
+ if err != nil {
+ return nil, err
+ }
+ switch discriminator {
+ case "conversation":
+ return t.AsSessionTranscriptConversationResponse()
+ case "raw":
+ return t.AsSessionTranscriptRawResponse()
+ case "structured":
+ return t.AsSessionTranscriptStructuredResponse()
+ case "text":
+ return t.AsSessionTranscriptConversationResponse()
+ default:
+ return nil, errors.New("unknown discriminator value: " + discriminator)
+ }
+}
+
+func (t SessionTranscriptGetResponse) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *SessionTranscriptGetResponse) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
// AsTypedEventStreamEnvelopeBeadClaimRejected returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeBeadClaimRejected
func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeBeadClaimRejected() (TypedEventStreamEnvelopeBeadClaimRejected, error) {
var body TypedEventStreamEnvelopeBeadClaimRejected
@@ -27051,7 +29802,370 @@ func NewPostV0CityByCityNameSessionByIdKillRequest(server string, cityName strin
return nil, err
}
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/kill", pathParam0, pathParam1)
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/kill", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if params != nil {
+
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-GC-Request", headerParam0)
+
+ }
+
+ return req, nil
+}
+
+// NewSendSessionMessageRequest calls the generic SendSessionMessage builder with application/json body
+func NewSendSessionMessageRequest(server string, cityName string, id string, params *SendSessionMessageParams, body SendSessionMessageJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewSendSessionMessageRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
+}
+
+// NewSendSessionMessageRequestWithBody generates requests for SendSessionMessage with any type of body
+func NewSendSessionMessageRequestWithBody(server string, cityName string, id string, params *SendSessionMessageParams, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/messages", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ if params != nil {
+
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-GC-Request", headerParam0)
+
+ }
+
+ return req, nil
+}
+
+// NewGetV0CityByCityNameSessionByIdPendingRequest generates requests for GetV0CityByCityNameSessionByIdPending
+func NewGetV0CityByCityNameSessionByIdPendingRequest(server string, cityName string, id string) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/pending", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
+// NewPostV0CityByCityNameSessionByIdPermissionModeRequest calls the generic PostV0CityByCityNameSessionByIdPermissionMode builder with application/json body
+func NewPostV0CityByCityNameSessionByIdPermissionModeRequest(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdPermissionModeParams, body PostV0CityByCityNameSessionByIdPermissionModeJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewPostV0CityByCityNameSessionByIdPermissionModeRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
+}
+
+// NewPostV0CityByCityNameSessionByIdPermissionModeRequestWithBody generates requests for PostV0CityByCityNameSessionByIdPermissionMode with any type of body
+func NewPostV0CityByCityNameSessionByIdPermissionModeRequestWithBody(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdPermissionModeParams, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/permission-mode", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ if params != nil {
+
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-GC-Request", headerParam0)
+
+ }
+
+ return req, nil
+}
+
+// NewPostV0CityByCityNameSessionByIdRenameRequest calls the generic PostV0CityByCityNameSessionByIdRename builder with application/json body
+func NewPostV0CityByCityNameSessionByIdRenameRequest(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdRenameParams, body PostV0CityByCityNameSessionByIdRenameJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewPostV0CityByCityNameSessionByIdRenameRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
+}
+
+// NewPostV0CityByCityNameSessionByIdRenameRequestWithBody generates requests for PostV0CityByCityNameSessionByIdRename with any type of body
+func NewPostV0CityByCityNameSessionByIdRenameRequestWithBody(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdRenameParams, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/rename", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ if params != nil {
+
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-GC-Request", headerParam0)
+
+ }
+
+ return req, nil
+}
+
+// NewRespondSessionRequest calls the generic RespondSession builder with application/json body
+func NewRespondSessionRequest(server string, cityName string, id string, params *RespondSessionParams, body RespondSessionJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewRespondSessionRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
+}
+
+// NewRespondSessionRequestWithBody generates requests for RespondSession with any type of body
+func NewRespondSessionRequestWithBody(server string, cityName string, id string, params *RespondSessionParams, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/respond", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ if params != nil {
+
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-GC-Request", headerParam0)
+
+ }
+
+ return req, nil
+}
+
+// NewPostV0CityByCityNameSessionByIdStopRequest generates requests for PostV0CityByCityNameSessionByIdStop
+func NewPostV0CityByCityNameSessionByIdStopRequest(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdStopParams) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/stop", pathParam0, pathParam1)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
@@ -27082,194 +30196,8 @@ func NewPostV0CityByCityNameSessionByIdKillRequest(server string, cityName strin
return req, nil
}
-// NewSendSessionMessageRequest calls the generic SendSessionMessage builder with application/json body
-func NewSendSessionMessageRequest(server string, cityName string, id string, params *SendSessionMessageParams, body SendSessionMessageJSONRequestBody) (*http.Request, error) {
- var bodyReader io.Reader
- buf, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
- bodyReader = bytes.NewReader(buf)
- return NewSendSessionMessageRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
-}
-
-// NewSendSessionMessageRequestWithBody generates requests for SendSessionMessage with any type of body
-func NewSendSessionMessageRequestWithBody(server string, cityName string, id string, params *SendSessionMessageParams, contentType string, body io.Reader) (*http.Request, error) {
- var err error
-
- var pathParam0 string
-
- pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- var pathParam1 string
-
- pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/messages", pathParam0, pathParam1)
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("POST", queryURL.String(), body)
- if err != nil {
- return nil, err
- }
-
- req.Header.Add("Content-Type", contentType)
-
- if params != nil {
-
- var headerParam0 string
-
- headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("X-GC-Request", headerParam0)
-
- }
-
- return req, nil
-}
-
-// NewGetV0CityByCityNameSessionByIdPendingRequest generates requests for GetV0CityByCityNameSessionByIdPending
-func NewGetV0CityByCityNameSessionByIdPendingRequest(server string, cityName string, id string) (*http.Request, error) {
- var err error
-
- var pathParam0 string
-
- pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- var pathParam1 string
-
- pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/pending", pathParam0, pathParam1)
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("GET", queryURL.String(), nil)
- if err != nil {
- return nil, err
- }
-
- return req, nil
-}
-
-// NewPostV0CityByCityNameSessionByIdPermissionModeRequest calls the generic PostV0CityByCityNameSessionByIdPermissionMode builder with application/json body
-func NewPostV0CityByCityNameSessionByIdPermissionModeRequest(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdPermissionModeParams, body PostV0CityByCityNameSessionByIdPermissionModeJSONRequestBody) (*http.Request, error) {
- var bodyReader io.Reader
- buf, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
- bodyReader = bytes.NewReader(buf)
- return NewPostV0CityByCityNameSessionByIdPermissionModeRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
-}
-
-// NewPostV0CityByCityNameSessionByIdPermissionModeRequestWithBody generates requests for PostV0CityByCityNameSessionByIdPermissionMode with any type of body
-func NewPostV0CityByCityNameSessionByIdPermissionModeRequestWithBody(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdPermissionModeParams, contentType string, body io.Reader) (*http.Request, error) {
- var err error
-
- var pathParam0 string
-
- pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- var pathParam1 string
-
- pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/permission-mode", pathParam0, pathParam1)
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("POST", queryURL.String(), body)
- if err != nil {
- return nil, err
- }
-
- req.Header.Add("Content-Type", contentType)
-
- if params != nil {
-
- var headerParam0 string
-
- headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("X-GC-Request", headerParam0)
-
- }
-
- return req, nil
-}
-
-// NewPostV0CityByCityNameSessionByIdRenameRequest calls the generic PostV0CityByCityNameSessionByIdRename builder with application/json body
-func NewPostV0CityByCityNameSessionByIdRenameRequest(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdRenameParams, body PostV0CityByCityNameSessionByIdRenameJSONRequestBody) (*http.Request, error) {
- var bodyReader io.Reader
- buf, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
- bodyReader = bytes.NewReader(buf)
- return NewPostV0CityByCityNameSessionByIdRenameRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
-}
-
-// NewPostV0CityByCityNameSessionByIdRenameRequestWithBody generates requests for PostV0CityByCityNameSessionByIdRename with any type of body
-func NewPostV0CityByCityNameSessionByIdRenameRequestWithBody(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdRenameParams, contentType string, body io.Reader) (*http.Request, error) {
+// NewStreamSessionRequest generates requests for StreamSession
+func NewStreamSessionRequest(server string, cityName string, id string, params *StreamSessionParams) (*http.Request, error) {
var err error
var pathParam0 string
@@ -27291,7 +30219,7 @@ func NewPostV0CityByCityNameSessionByIdRenameRequestWithBody(server string, city
return nil, err
}
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/rename", pathParam0, pathParam1)
+ operationPath := fmt.Sprintf("/v0/city/%s/session/%s/stream", pathParam0, pathParam1)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
@@ -27301,189 +30229,44 @@ func NewPostV0CityByCityNameSessionByIdRenameRequestWithBody(server string, city
return nil, err
}
- req, err := http.NewRequest("POST", queryURL.String(), body)
- if err != nil {
- return nil, err
- }
-
- req.Header.Add("Content-Type", contentType)
-
if params != nil {
+ queryValues := queryURL.Query()
- var headerParam0 string
-
- headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("X-GC-Request", headerParam0)
-
- }
-
- return req, nil
-}
-
-// NewRespondSessionRequest calls the generic RespondSession builder with application/json body
-func NewRespondSessionRequest(server string, cityName string, id string, params *RespondSessionParams, body RespondSessionJSONRequestBody) (*http.Request, error) {
- var bodyReader io.Reader
- buf, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
- bodyReader = bytes.NewReader(buf)
- return NewRespondSessionRequestWithBody(server, cityName, id, params, "application/json", bodyReader)
-}
-
-// NewRespondSessionRequestWithBody generates requests for RespondSession with any type of body
-func NewRespondSessionRequestWithBody(server string, cityName string, id string, params *RespondSessionParams, contentType string, body io.Reader) (*http.Request, error) {
- var err error
-
- var pathParam0 string
-
- pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- var pathParam1 string
-
- pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/respond", pathParam0, pathParam1)
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("POST", queryURL.String(), body)
- if err != nil {
- return nil, err
- }
-
- req.Header.Add("Content-Type", contentType)
-
- if params != nil {
+ if params.Format != nil {
- var headerParam0 string
+ if queryFrag, err := runtime.StyleParamWithOptions("form", false, "format", *params.Format, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
- headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
- if err != nil {
- return nil, err
}
- req.Header.Set("X-GC-Request", headerParam0)
-
- }
-
- return req, nil
-}
-
-// NewPostV0CityByCityNameSessionByIdStopRequest generates requests for PostV0CityByCityNameSessionByIdStop
-func NewPostV0CityByCityNameSessionByIdStopRequest(server string, cityName string, id string, params *PostV0CityByCityNameSessionByIdStopParams) (*http.Request, error) {
- var err error
-
- var pathParam0 string
-
- pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- var pathParam1 string
-
- pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/stop", pathParam0, pathParam1)
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("POST", queryURL.String(), nil)
- if err != nil {
- return nil, err
- }
-
- if params != nil {
+ if params.IncludeThinking != nil {
- var headerParam0 string
+ if queryFrag, err := runtime.StyleParamWithOptions("form", false, "include_thinking", *params.IncludeThinking, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
- headerParam0, err = runtime.StyleParamWithOptions("simple", false, "X-GC-Request", params.XGCRequest, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
- if err != nil {
- return nil, err
}
- req.Header.Set("X-GC-Request", headerParam0)
-
- }
-
- return req, nil
-}
-
-// NewStreamSessionRequest generates requests for StreamSession
-func NewStreamSessionRequest(server string, cityName string, id string, params *StreamSessionParams) (*http.Request, error) {
- var err error
-
- var pathParam0 string
-
- pathParam0, err = runtime.StyleParamWithOptions("simple", false, "cityName", cityName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- var pathParam1 string
-
- pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
- if err != nil {
- return nil, err
- }
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/v0/city/%s/session/%s/stream", pathParam0, pathParam1)
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- if params != nil {
- queryValues := queryURL.Query()
-
- if params.Format != nil {
+ if params.AfterCursor != nil {
- if queryFrag, err := runtime.StyleParamWithOptions("form", false, "format", *params.Format, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
+ if queryFrag, err := runtime.StyleParamWithOptions("form", false, "after_cursor", *params.AfterCursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
return nil, err
} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
return nil, err
@@ -27505,6 +30288,21 @@ func NewStreamSessionRequest(server string, cityName string, id string, params *
return nil, err
}
+ if params != nil {
+
+ if params.LastEventID != nil {
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Last-Event-ID", *params.LastEventID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Last-Event-ID", headerParam0)
+ }
+
+ }
+
return req, nil
}
@@ -27697,6 +30495,22 @@ func NewGetV0CityByCityNameSessionByIdTranscriptRequest(server string, cityName
}
+ if params.IncludeThinking != nil {
+
+ if queryFrag, err := runtime.StyleParamWithOptions("form", false, "include_thinking", *params.IncludeThinking, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
if params.Before != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", false, "before", *params.Before, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
@@ -33211,6 +36025,7 @@ type SendSessionMessageResponse struct {
ApplicationproblemJSON401 *ErrorModel
ApplicationproblemJSON403 *ErrorModel
ApplicationproblemJSON404 *ErrorModel
+ ApplicationproblemJSON409 *ErrorModel
ApplicationproblemJSON422 *ErrorModel
ApplicationproblemJSON500 *ErrorModel
ApplicationproblemJSON503 *ErrorModel
@@ -33408,6 +36223,7 @@ type SubmitSessionResponse struct {
ApplicationproblemJSON401 *ErrorModel
ApplicationproblemJSON403 *ErrorModel
ApplicationproblemJSON404 *ErrorModel
+ ApplicationproblemJSON409 *ErrorModel
ApplicationproblemJSON422 *ErrorModel
ApplicationproblemJSON500 *ErrorModel
ApplicationproblemJSON503 *ErrorModel
@@ -44304,6 +47120,13 @@ func ParseSendSessionMessageResponse(rsp *http.Response) (*SendSessionMessageRes
}
response.ApplicationproblemJSON404 = &dest
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409:
+ var dest ErrorModel
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.ApplicationproblemJSON409 = &dest
+
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422:
var dest ErrorModel
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
@@ -44787,6 +47610,13 @@ func ParseSubmitSessionResponse(rsp *http.Response) (*SubmitSessionResponse, err
}
response.ApplicationproblemJSON404 = &dest
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409:
+ var dest ErrorModel
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.ApplicationproblemJSON409 = &dest
+
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422:
var dest ErrorModel
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
diff --git a/internal/api/genclient_roundtrip_test.go b/internal/api/genclient_roundtrip_test.go
index 8d684ef55b..9d08edf62a 100644
--- a/internal/api/genclient_roundtrip_test.go
+++ b/internal/api/genclient_roundtrip_test.go
@@ -121,6 +121,36 @@ func TestGenClientRoundTripSessionList(t *testing.T) {
}
}
+func TestGenClientStreamSessionRequestSeparatesResumeCursorLocations(t *testing.T) {
+ afterCursor := "st1.snapshot+cursor/with=padding"
+ lastEventID := "st1.latest+cursor/with=padding"
+ req, err := genclient.NewStreamSessionRequest(
+ "https://example.test",
+ "alpha",
+ "gc-session-1",
+ &genclient.StreamSessionParams{
+ AfterCursor: &afterCursor,
+ LastEventID: &lastEventID,
+ },
+ )
+ if err != nil {
+ t.Fatalf("NewStreamSessionRequest: %v", err)
+ }
+
+ if got := req.URL.Query().Get("after_cursor"); got != afterCursor {
+ t.Fatalf("after_cursor query = %q, want %q", got, afterCursor)
+ }
+ if got := req.Header.Get("Last-Event-ID"); got != lastEventID {
+ t.Fatalf("Last-Event-ID header = %q, want %q", got, lastEventID)
+ }
+ if _, ok := req.URL.Query()["Last-Event-ID"]; ok {
+ t.Fatalf("Last-Event-ID unexpectedly encoded in query: %q", req.URL.RawQuery)
+ }
+ if got := req.Header.Get("after_cursor"); got != "" {
+ t.Fatalf("after_cursor unexpectedly encoded as header: %q", got)
+ }
+}
+
func TestGenClientRoundTripFormulaList(t *testing.T) {
client, state := newRoundTripClient(t)
kind := "city"
diff --git a/internal/api/global_stream_precheck_test.go b/internal/api/global_stream_precheck_test.go
index df20dd8b6e..759f0d8476 100644
--- a/internal/api/global_stream_precheck_test.go
+++ b/internal/api/global_stream_precheck_test.go
@@ -138,6 +138,16 @@ func TestResolveGlobalStreamCursors(t *testing.T) {
}
})
+ t.Run("explicit zero replays every current provider from zero", func(t *testing.T) {
+ cursors, err := resolveGlobalStreamCursors(newMux(), "0")
+ if err != nil {
+ t.Fatalf("resolveGlobalStreamCursors: %v", err)
+ }
+ if cursors["alpha"] != 0 || cursors["beta"] != 0 {
+ t.Fatalf("cursors = %v, want alpha=0 beta=0", cursors)
+ }
+ })
+
t.Run("resume preserves present cities and floors omitted cities to latest", func(t *testing.T) {
// A resume cursor that names alpha at 2 but omits the registered beta.
resume := events.FormatCursor(map[string]uint64{"alpha": 2})
@@ -153,6 +163,16 @@ func TestResolveGlobalStreamCursors(t *testing.T) {
}
})
+ t.Run("malformed sequences floor providers to latest instead of replaying from zero", func(t *testing.T) {
+ cursors, err := resolveGlobalStreamCursors(newMux(), "alpha:nope,beta:nope")
+ if err != nil {
+ t.Fatalf("resolveGlobalStreamCursors: %v", err)
+ }
+ if cursors["alpha"] != 5 || cursors["beta"] != 3 {
+ t.Fatalf("cursors = %v, want alpha=5 beta=3", cursors)
+ }
+ })
+
t.Run("fails closed when latest cursor errors", func(t *testing.T) {
mux := events.NewMultiplexer()
mux.Add("alpha", &afterSeqRecordingProvider{latestErr: errors.New("boom")})
diff --git a/internal/api/handler_agent_output_test.go b/internal/api/handler_agent_output_test.go
index 1b12c921f4..2df92713ff 100644
--- a/internal/api/handler_agent_output_test.go
+++ b/internal/api/handler_agent_output_test.go
@@ -15,8 +15,26 @@ import (
"github.com/gastownhall/gascity/internal/events"
"github.com/gastownhall/gascity/internal/runtime"
"github.com/gastownhall/gascity/internal/session"
+ "github.com/gastownhall/gascity/internal/worker"
)
+func TestHistorySnapshotRawMessagesEmitsEachProviderRecordOnce(t *testing.T) {
+ repeated := json.RawMessage(`{"type":"ToolResults"}`)
+ snapshot := &worker.HistorySnapshot{Entries: []worker.HistoryEntry{
+ {ID: "child-1", Provenance: worker.Provenance{Raw: repeated, RawRecordID: "record-1"}},
+ {ID: "child-2", Provenance: worker.Provenance{Raw: repeated, RawRecordID: "record-1"}},
+ {ID: "child-3", Provenance: worker.Provenance{Raw: repeated, RawRecordID: "record-2"}},
+ }}
+
+ rawMessages, ids := historySnapshotRawMessages(snapshot)
+ if len(rawMessages) != 2 {
+ t.Fatalf("raw messages = %d, want two repeated source records", len(rawMessages))
+ }
+ if got, want := strings.Join(ids, ","), "child-2,child-3"; got != want {
+ t.Fatalf("raw cursor IDs = %q, want final child of each source record %q", got, want)
+ }
+}
+
// writeSessionJSONL creates a JSONL session file at the slug path for
// the given workDir.
func writeSessionJSONL(t *testing.T, searchBase, workDir string, lines ...string) {
diff --git a/internal/api/handler_agent_output_turns.go b/internal/api/handler_agent_output_turns.go
index 106f31db5e..f23e10aa6c 100644
--- a/internal/api/handler_agent_output_turns.go
+++ b/internal/api/handler_agent_output_turns.go
@@ -149,10 +149,18 @@ func historySnapshotRawMessages(snapshot *worker.HistorySnapshot) ([]json.RawMes
}
rawMessages := make([]json.RawMessage, 0, len(snapshot.Entries))
ids := make([]string, 0, len(snapshot.Entries))
+ recordIndexes := make(map[string]int)
for _, entry := range snapshot.Entries {
if len(entry.Provenance.Raw) == 0 {
continue
}
+ if recordID := entry.Provenance.RawRecordID; recordID != "" {
+ if index, seen := recordIndexes[recordID]; seen {
+ ids[index] = entry.ID
+ continue
+ }
+ recordIndexes[recordID] = len(rawMessages)
+ }
rawMessages = append(rawMessages, entry.Provenance.Raw)
ids = append(ids, entry.ID)
}
diff --git a/internal/api/handler_agents.go b/internal/api/handler_agents.go
index 12e0ee118f..e9e6f7986a 100644
--- a/internal/api/handler_agents.go
+++ b/internal/api/handler_agents.go
@@ -373,7 +373,7 @@ func providerPathCheck(providerName string, cfg *config.City) string {
return spec.PathCheck
}
if resolved.Command != "" {
- return resolved.Command
+ return config.BinaryName(resolved.Command)
}
}
if spec, ok := cfg.Providers[providerName]; ok {
@@ -381,7 +381,7 @@ func providerPathCheck(providerName string, cfg *config.City) string {
return spec.PathCheck
}
if spec.Command != "" {
- return spec.Command
+ return config.BinaryName(spec.Command)
}
}
builtins := config.BuiltinProviders()
@@ -389,7 +389,7 @@ func providerPathCheck(providerName string, cfg *config.City) string {
if spec.PathCheck != "" {
return spec.PathCheck
}
- return spec.Command
+ return config.BinaryName(spec.Command)
}
return providerName
}
diff --git a/internal/api/handler_agents_test.go b/internal/api/handler_agents_test.go
index f30232e0e3..257ce1418e 100644
--- a/internal/api/handler_agents_test.go
+++ b/internal/api/handler_agents_test.go
@@ -1195,6 +1195,21 @@ func TestProviderPathCheck_FallsBackToRawWhenNoCache(t *testing.T) {
}
}
+// TestProviderPathCheck_StripsCommandArgs mirrors the config-side
+// pathCheckBinary behavior: an unset PathCheck with an args-bearing
+// Command must resolve to the bare executable token, so PATH detection
+// checks "my-agent" rather than the whole "my-agent --agent coder" string.
+func TestProviderPathCheck_StripsCommandArgs(t *testing.T) {
+ cfg := &config.City{
+ Providers: map[string]config.ProviderSpec{
+ "custom": {Command: "my-agent --agent coder"},
+ },
+ }
+ if got := providerPathCheck("custom", cfg); got != "my-agent" {
+ t.Errorf("providerPathCheck = %q, want my-agent", got)
+ }
+}
+
// TestWaitForAgentVisibilityIn_ReturnsImmediatelyOnHit covers the happy
// path: the freshly created agent is already visible in the snapshot
// and the wait returns without sleeping.
diff --git a/internal/api/handler_beads_test.go b/internal/api/handler_beads_test.go
index 5d741ec3e5..4a003d764e 100644
--- a/internal/api/handler_beads_test.go
+++ b/internal/api/handler_beads_test.go
@@ -256,6 +256,14 @@ func (s *prefixedAliasStore) SetMetadataBatch(id string, kvs map[string]string)
return s.base.SetMetadataBatch(s.aliasToBase(id), kvs)
}
+func (s *prefixedAliasStore) SetLocalString(id, key, value string) error {
+ return s.base.SetLocalString(s.aliasToBase(id), key, value)
+}
+
+func (s *prefixedAliasStore) GetLocalString(id, key string) (string, error) {
+ return s.base.GetLocalString(s.aliasToBase(id), key)
+}
+
func (s *prefixedAliasStore) Tx(commitMsg string, fn func(beads.Tx) error) error {
if fn == nil {
return s.base.Tx(commitMsg, nil)
diff --git a/internal/api/handler_events_keyset_test.go b/internal/api/handler_events_keyset_test.go
new file mode 100644
index 0000000000..2e7d862b4c
--- /dev/null
+++ b/internal/api/handler_events_keyset_test.go
@@ -0,0 +1,389 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/events"
+)
+
+// S3 of the keyset-cursor track: the city event list speaks one order —
+// seq DESC (newest first) on BOTH the cursor-less and cursor paths — with
+// sq-kind keyset tokens. The old contract had a window flip: no cursor
+// returned the newest-N while any cursor walked oldest-first from the head,
+// so walking history coherently was impossible.
+
+func seedEvents(t *testing.T, state *fakeState, n int) {
+ t.Helper()
+ for i := 0; i < n; i++ {
+ state.eventProv.Record(events.Event{Type: "e.t", Actor: "a", Subject: fmt.Sprintf("s-%02d", i)})
+ }
+}
+
+func decodeEventList(t *testing.T, rec *httptest.ResponseRecorder) (items []WireEvent, total int, next string) {
+ t.Helper()
+ var body struct {
+ Items []WireEvent `json:"items"`
+ Total int `json:"total"`
+ NextCursor string `json:"next_cursor"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return body.Items, body.Total, body.NextCursor
+}
+
+// TestEventListSeqDescBothPaths pins the window-flip fix: page 1 (no cursor)
+// is the NEWEST events in seq DESC order, and following the cursor continues
+// DESC into strictly older events — one coherent order end to end.
+func TestEventListSeqDescBothPaths(t *testing.T) {
+ state := newFakeState(t)
+ h := newTestCityHandler(t, state)
+ seedEvents(t, state, 10)
+
+ rec := getList(t, h, cityURL(state, "/events?limit=4"))
+ items, _, next := decodeEventList(t, rec)
+ if len(items) != 4 {
+ t.Fatalf("page1 len = %d, want 4", len(items))
+ }
+ for i := 1; i < len(items); i++ {
+ if items[i].Seq >= items[i-1].Seq {
+ t.Fatalf("page1 not seq DESC: %d then %d", items[i-1].Seq, items[i].Seq)
+ }
+ }
+ if items[0].Seq != 10 {
+ t.Fatalf("page1 must start at the newest event (seq 10), got %d", items[0].Seq)
+ }
+ if !strings.HasPrefix(next, "v1:") {
+ t.Fatalf("truncated page1 must mint a v1 cursor, got %q", next)
+ }
+
+ rec2 := getList(t, h, cityURL(state, "/events?limit=4&cursor=")+next)
+ items2, _, _ := decodeEventList(t, rec2)
+ if len(items2) != 4 {
+ t.Fatalf("page2 len = %d, want 4", len(items2))
+ }
+ if items2[0].Seq != items[len(items)-1].Seq-1 {
+ t.Fatalf("page2 must continue strictly below the boundary: got seq %d after boundary %d",
+ items2[0].Seq, items[len(items)-1].Seq)
+ }
+ for i := 1; i < len(items2); i++ {
+ if items2[i].Seq >= items2[i-1].Seq {
+ t.Fatalf("page2 not seq DESC: %d then %d", items2[i-1].Seq, items2[i].Seq)
+ }
+ }
+}
+
+// TestEventListKeysetWalkNoSkipNoDup drives a full walk with concurrent
+// appends between pages: every pre-walk event is seen exactly once, and the
+// mid-walk appends (newer seqs, above the boundary) never shift the walk.
+func TestEventListKeysetWalkNoSkipNoDup(t *testing.T) {
+ state := newFakeState(t)
+ h := newTestCityHandler(t, state)
+ const n = 11
+ seedEvents(t, state, n)
+
+ seen := map[uint64]int{}
+ cursor := ""
+ pages := 0
+ for {
+ url := cityURL(state, "/events?limit=4")
+ if cursor != "" {
+ url += "&cursor=" + cursor
+ }
+ rec := getList(t, h, url)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("page %d: status %d body %s", pages, rec.Code, rec.Body.String())
+ }
+ items, _, next := decodeEventList(t, rec)
+ for _, e := range items {
+ seen[e.Seq]++
+ }
+ if pages++; pages > 10 {
+ t.Fatal("walk did not terminate")
+ }
+ if next == "" {
+ break
+ }
+ cursor = next
+ // Concurrent append mid-walk: newer seq, sorts above the boundary.
+ state.eventProv.Record(events.Event{Type: "e.t", Actor: "a", Subject: "mid"})
+ }
+
+ for seq := uint64(1); seq <= n; seq++ {
+ if seen[seq] != 1 {
+ t.Errorf("pre-walk event seq %d seen %d times, want exactly 1", seq, seen[seq])
+ }
+ }
+ for seq, c := range seen {
+ if c > 1 {
+ t.Errorf("event seq %d duplicated (%d times)", seq, c)
+ }
+ }
+}
+
+func TestEventListInvalidCursorReturns400(t *testing.T) {
+ state := newFakeState(t)
+ h := newTestCityHandler(t, state)
+ for _, cursor := range []string{
+ "NTA", // legacy offset token
+ encodeKeysetCursor(keysetCursor{Kind: cursorKindCreatedID, ID: "x"}), // wrong kind (cb)
+ // Crafted sq token with seq 0: the server never mints it (seqs start
+ // at 1), and beforeSeq==0 means "first page" internally — accepting it
+ // would hand a cursor-following client the first page again, forever.
+ encodeKeysetCursor(keysetCursor{Kind: cursorKindSeq, Seq: 0}),
+ } {
+ rec := getList(t, h, cityURL(state, "/events?cursor=")+cursor)
+ if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid-cursor") {
+ t.Fatalf("cursor %q: status = %d body = %s, want 400 invalid-cursor", cursor, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+// TestEventListFilteredKeysetWalk: type/actor filters compose with the seq
+// boundary — the walk sees exactly the matching pre-walk events once each.
+func TestEventListFilteredKeysetWalk(t *testing.T) {
+ state := newFakeState(t)
+ h := newTestCityHandler(t, state)
+ for i := 0; i < 12; i++ {
+ typ := "keep.me"
+ if i%3 == 0 {
+ typ = "drop.me"
+ }
+ state.eventProv.Record(events.Event{Type: typ, Actor: "a"})
+ }
+
+ seen := map[uint64]int{}
+ cursor := ""
+ pages := 0
+ for {
+ url := cityURL(state, "/events?limit=3&type=keep.me")
+ if cursor != "" {
+ url += "&cursor=" + cursor
+ }
+ rec := getList(t, h, url)
+ items, _, next := decodeEventList(t, rec)
+ for _, e := range items {
+ if e.Type != "keep.me" {
+ t.Fatalf("filter leaked event type %q", e.Type)
+ }
+ seen[e.Seq]++
+ }
+ if pages++; pages > 10 {
+ t.Fatal("walk did not terminate")
+ }
+ if next == "" {
+ break
+ }
+ cursor = next
+ }
+ if len(seen) != 8 { // 12 events, every 3rd is drop.me -> 8 keep.me
+ t.Fatalf("walk saw %d matching events, want 8", len(seen))
+ }
+ for seq, c := range seen {
+ if c != 1 {
+ t.Errorf("seq %d seen %d times", seq, c)
+ }
+ }
+}
+
+// TestEventListLastPageOmitsCursor: exhausting the log ends the walk cleanly.
+func TestEventListLastPageOmitsCursor(t *testing.T) {
+ state := newFakeState(t)
+ h := newTestCityHandler(t, state)
+ seedEvents(t, state, 3)
+
+ rec := getList(t, h, cityURL(state, "/events?limit=10"))
+ items, _, next := decodeEventList(t, rec)
+ if len(items) != 3 || next != "" {
+ t.Fatalf("items=%d next=%q, want 3/empty", len(items), next)
+ }
+}
+
+// archiveBlindTailProvider simulates the production FileRecorder split:
+// ListTail is a backward scan of the active events.jsonl only (Seq >=
+// activeFloor here), while List reads full history (archives + active).
+type archiveBlindTailProvider struct {
+ *events.Fake
+ activeFloor uint64
+}
+
+func (p *archiveBlindTailProvider) ListTail(filter events.Filter, limit int) ([]events.Event, error) {
+ all, err := p.List(filter)
+ if err != nil {
+ return nil, err
+ }
+ var active []events.Event
+ for _, e := range all {
+ if e.Seq >= p.activeFloor {
+ active = append(active, e)
+ }
+ }
+ if limit > 0 && len(active) > limit {
+ active = active[len(active)-limit:]
+ }
+ return active, nil
+}
+
+// TestEventListWalkCrossesArchiveBoundary pins the red-team major: the
+// ListTail fast path reads only the active log, so its result may be trusted
+// only when it fills the whole limit+1 probe. A short active file (the
+// normal state right after any rotation) must fall through to the
+// archive-aware scan — otherwise the first page under-fills, mints no
+// cursor, and the entire archived history is silently unreachable.
+func TestEventListWalkCrossesArchiveBoundary(t *testing.T) {
+ state := newFakeState(t)
+ fake := events.NewFake()
+ state.eventProv = &archiveBlindTailProvider{Fake: fake, activeFloor: 13}
+ h := newTestCityHandler(t, state)
+ for i := 0; i < 15; i++ { // seqs 1..15; 1..12 "archived", 13..15 "active"
+ fake.Record(events.Event{Type: "e.t", Actor: "a"})
+ }
+
+ rec := getList(t, h, cityURL(state, "/events?limit=10"))
+ items, total, next := decodeEventList(t, rec)
+ if len(items) != 10 {
+ t.Fatalf("page1 len = %d, want 10 (must cross the active/archive boundary, not stop at 3 active rows)", len(items))
+ }
+ if items[0].Seq != 15 || items[9].Seq != 6 {
+ t.Fatalf("page1 range = [%d..%d], want [15..6]", items[0].Seq, items[9].Seq)
+ }
+ if total != 15 {
+ t.Fatalf("total = %d, want 15 (LatestSeq)", total)
+ }
+ if !strings.HasPrefix(next, "v1:") {
+ t.Fatalf("truncated page1 must mint a cursor, got %q", next)
+ }
+
+ // The rest of the walk drains the archived history exactly once.
+ seen := map[uint64]int{}
+ for _, e := range items {
+ seen[e.Seq]++
+ }
+ cursor := next
+ for pages := 0; cursor != ""; pages++ {
+ if pages > 5 {
+ t.Fatal("walk did not terminate")
+ }
+ rec := getList(t, h, cityURL(state, "/events?limit=10&cursor=")+cursor)
+ items, _, nxt := decodeEventList(t, rec)
+ for _, e := range items {
+ seen[e.Seq]++
+ }
+ cursor = nxt
+ }
+ for seq := uint64(1); seq <= 15; seq++ {
+ if seen[seq] != 1 {
+ t.Errorf("seq %d seen %d times, want exactly 1", seq, seen[seq])
+ }
+ }
+}
+
+// rotationBlindProvider models the production FileRecorder during a rotation's
+// asynchronous compression window. Three seq bands live on disk at once:
+// ListTail scans only the ACTIVE file (Seq >= activeFloor); plain List reads
+// canonical .gz archives + active but CANNOT see the in-flight .rotating-*
+// segment [rotatingLow, activeFloor) (that is exactly what ReadFiltered
+// misses); ListInFlight folds that segment back in (ReadFilteredWithInFlight).
+// A descending keyset walk that fell through to List would serve rows above the
+// segment, then jump below it, silently skipping the whole band — the fast path
+// can't see it either — so the handler must use the in-flight-aware read.
+type rotationBlindProvider struct {
+ *events.Fake
+ activeFloor uint64 // Seq >= activeFloor lives in the active file
+ rotatingLow uint64 // [rotatingLow, activeFloor) lives ONLY in the in-flight file
+}
+
+// List models ReadFiltered: canonical archives + active, MISSING the in-flight
+// rotating segment.
+func (p *rotationBlindProvider) List(filter events.Filter) ([]events.Event, error) {
+ all, err := p.Fake.List(filter)
+ if err != nil {
+ return nil, err
+ }
+ var visible []events.Event
+ for _, e := range all {
+ if e.Seq >= p.rotatingLow && e.Seq < p.activeFloor {
+ continue // stranded in the .rotating-* file ReadFiltered can't read
+ }
+ visible = append(visible, e)
+ }
+ return visible, nil
+}
+
+// ListInFlight models ReadFilteredWithInFlight: the complete history including
+// the in-flight rotating segment.
+func (p *rotationBlindProvider) ListInFlight(filter events.Filter) ([]events.Event, error) {
+ return p.Fake.List(filter)
+}
+
+// ListTail models the active-file-only backward scan.
+func (p *rotationBlindProvider) ListTail(filter events.Filter, limit int) ([]events.Event, error) {
+ all, err := p.Fake.List(filter)
+ if err != nil {
+ return nil, err
+ }
+ var active []events.Event
+ for _, e := range all {
+ if e.Seq >= p.activeFloor {
+ active = append(active, e)
+ }
+ }
+ if limit > 0 && len(active) > limit {
+ active = active[len(active)-limit:]
+ }
+ return active, nil
+}
+
+// TestEventListWalkCrossesInFlightRotation pins the in-flight rotation gap that
+// the archive-boundary test misses: during a rotation's compression window the
+// just-rotated segment lives ONLY in the .rotating-* file, which neither the
+// active-file tail fast path nor the plain archive-aware scan can see. A keyset
+// walk that fell through to the plain scan would jump straight from the active
+// band to the archived band, silently skipping the in-flight segment. The
+// handler must route the fallback through the in-flight-aware read so the walk
+// covers every seq exactly once.
+func TestEventListWalkCrossesInFlightRotation(t *testing.T) {
+ state := newFakeState(t)
+ fake := events.NewFake()
+ // seqs 1..15: 1..6 archived (.gz), 7..12 in-flight (.rotating-*), 13..15 active.
+ state.eventProv = &rotationBlindProvider{Fake: fake, activeFloor: 13, rotatingLow: 7}
+ h := newTestCityHandler(t, state)
+ for i := 0; i < 15; i++ {
+ fake.Record(events.Event{Type: "e.t", Actor: "a"})
+ }
+
+ seen := map[uint64]int{}
+ cursor := ""
+ for pages := 0; ; pages++ {
+ if pages > 5 {
+ t.Fatal("walk did not terminate")
+ }
+ url := cityURL(state, "/events?limit=10")
+ if cursor != "" {
+ url += "&cursor=" + cursor
+ }
+ rec := getList(t, h, url)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("page %d: status %d body %s", pages, rec.Code, rec.Body.String())
+ }
+ items, _, next := decodeEventList(t, rec)
+ for _, e := range items {
+ seen[e.Seq]++
+ }
+ if next == "" {
+ break
+ }
+ cursor = next
+ }
+
+ for seq := uint64(1); seq <= 15; seq++ {
+ if seen[seq] != 1 {
+ t.Errorf("seq %d seen %d times, want exactly 1 (in-flight rotation band 7..12 must not be skipped)", seq, seen[seq])
+ }
+ }
+}
diff --git a/internal/api/handler_lists_keyset_test.go b/internal/api/handler_lists_keyset_test.go
index 59e933a965..ced4d77126 100644
--- a/internal/api/handler_lists_keyset_test.go
+++ b/internal/api/handler_lists_keyset_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"strings"
"testing"
+ "time"
"github.com/gastownhall/gascity/internal/beads"
)
@@ -188,6 +189,56 @@ func TestMailListInvalidCursorReturns400(t *testing.T) {
// --- Sessions ---
+// tiedCreatedAtSessionStore forces every bead it lists to share one
+// whole-second created_at, so a keyset walk over the sessions endpoint
+// exercises the (created_at DESC, id DESC) id tie-break end to end. The
+// sessions handler is the only keyset list that does not sort its own result —
+// it trusts the read model's total order — so the id tie-break surviving
+// ListAllWithResponses -> enrichment is load-bearing whenever same-second
+// sessions exist (bd-backed stores stamp whole-second created_at, so they are
+// the norm). Only List is overridden; CachedList is deliberately absent so the
+// CacheFirst read-model peek misses and the walk drives the real direct-union
+// SortBeads path rather than a cache shortcut.
+type tiedCreatedAtSessionStore struct {
+ beads.Store
+ tied time.Time
+}
+
+func (s tiedCreatedAtSessionStore) List(query beads.ListQuery) ([]beads.Bead, error) {
+ rows, err := s.Store.List(query)
+ for i := range rows {
+ rows[i].CreatedAt = s.tied
+ }
+ return rows, err
+}
+
+// TestSessionListKeysetWalkNoSkipNoDup pins the sessions keyset walk against a
+// dropped id tie-break: N > page-size sessions sharing one created_at must page
+// to exhaustion returning every session exactly once. Convoys and mail already
+// have this shape; sessions did not, so a regression that reordered the
+// read-model union or dropped SortBeads' id tie-break would silently
+// skip/duplicate same-second sessions with no failing sessions-handler test.
+func TestSessionListKeysetWalkNoSkipNoDup(t *testing.T) {
+ fs := newSessionFakeState(t)
+
+ const n = 9
+ for i := 0; i < n; i++ {
+ createTestSession(t, fs.cityBeadStore, fs.sp, "s")
+ }
+ // Route the read path through a store that collapses every session onto one
+ // whole-second created_at, so the page order rests entirely on the id
+ // tie-break the sessions handler's index-keyed comment relies on.
+ fs.sessionsBeadStore = tiedCreatedAtSessionStore{
+ Store: fs.cityBeadStore,
+ tied: time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC),
+ }
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+
+ seen := walkKeysetList(t, h, cityURL(fs, "/sessions?limit=4"), "&", n)
+ assertExactlyOnce(t, seen, n)
+}
+
func TestSessionListTruncationMintsCursor(t *testing.T) {
fs := newSessionFakeState(t)
srv := New(fs)
diff --git a/internal/api/handler_orders.go b/internal/api/handler_orders.go
index dcb06fa0bf..99d8355158 100644
--- a/internal/api/handler_orders.go
+++ b/internal/api/handler_orders.go
@@ -18,25 +18,27 @@ var (
)
type orderResponse struct {
- Name string `json:"name"`
- ScopedName string `json:"scoped_name"`
- Description string `json:"description,omitempty"`
- Type string `json:"type"`
- Trigger string `json:"trigger,omitempty"`
- Gate string `json:"gate,omitempty" deprecated:"true"`
- Interval string `json:"interval,omitempty"`
- Schedule string `json:"schedule,omitempty"`
- Check string `json:"check,omitempty"`
- On string `json:"on,omitempty"`
- Formula string `json:"formula,omitempty"`
- Exec string `json:"exec,omitempty"`
- Pool string `json:"pool,omitempty"`
- Timeout string `json:"timeout,omitempty"`
- TimeoutMs int64 `json:"timeout_ms"`
- Enabled bool `json:"enabled"`
- Rig string `json:"rig,omitempty"`
- CaptureOutput bool `json:"capture_output"`
- Env map[string]string `json:"env,omitempty"`
+ Name string `json:"name"`
+ ScopedName string `json:"scoped_name"`
+ Description string `json:"description,omitempty"`
+ Type string `json:"type"`
+ Trigger string `json:"trigger,omitempty"`
+ Gate string `json:"gate,omitempty" deprecated:"true"`
+ Interval string `json:"interval,omitempty"`
+ Schedule string `json:"schedule,omitempty"`
+ Check string `json:"check,omitempty"`
+ On string `json:"on,omitempty"`
+ Formula string `json:"formula,omitempty"`
+ Exec string `json:"exec,omitempty"`
+ Pool string `json:"pool,omitempty"`
+ Timeout string `json:"timeout,omitempty"`
+ TimeoutMs int64 `json:"timeout_ms"`
+ CheckTimeout string `json:"check_timeout,omitempty"`
+ CheckTimeoutMs int64 `json:"check_timeout_ms,omitempty"`
+ Enabled bool `json:"enabled"`
+ Rig string `json:"rig,omitempty"`
+ CaptureOutput bool `json:"capture_output"`
+ Env map[string]string `json:"env,omitempty"`
}
func resolveOrder(aa []orders.Order, name string) (*orders.Order, error) {
@@ -72,7 +74,7 @@ func toOrderResponse(a orders.Order) orderResponse {
if a.IsExec() {
typ = "exec"
}
- return orderResponse{
+ resp := orderResponse{
Name: a.Name,
ScopedName: a.ScopedName(),
Description: a.Description,
@@ -88,9 +90,17 @@ func toOrderResponse(a orders.Order) orderResponse {
Pool: a.Pool,
Timeout: a.Timeout,
TimeoutMs: a.TimeoutOrDefault().Milliseconds(),
+ CheckTimeout: a.CheckTimeout,
Enabled: a.IsEnabled(),
Rig: a.Rig,
CaptureOutput: a.IsExec(), // exec orders capture output
Env: a.Env,
}
+ // check_timeout bounds only a condition trigger's check command, so surface
+ // its effective millisecond deadline only for condition orders. Other
+ // triggers have no check and would otherwise report a phantom 10s default.
+ if a.Trigger == "condition" {
+ resp.CheckTimeoutMs = a.CheckTimeoutOrDefault().Milliseconds()
+ }
+ return resp
}
diff --git a/internal/api/handler_orders_test.go b/internal/api/handler_orders_test.go
index d23f24aa62..938f8d77d2 100644
--- a/internal/api/handler_orders_test.go
+++ b/internal/api/handler_orders_test.go
@@ -187,6 +187,55 @@ func TestHandleOrderGet_ExposesTriggerAndLegacyGateAlias(t *testing.T) {
}
}
+func TestToOrderResponseSurfacesCheckTimeout(t *testing.T) {
+ // Regression (PR #4190 iter-4): check_timeout is honored by dispatch but was
+ // invisible on the typed HTTP/dashboard projection, so an operator could not
+ // confirm the effective condition deadline they configured. Pin the raw
+ // value, the effective millisecond deadline, and the on-the-wire keys.
+ cond := toOrderResponse(orders.Order{
+ Name: "slow", Trigger: "condition", Check: "true", Exec: "true", CheckTimeout: "120s",
+ })
+ if cond.CheckTimeout != "120s" {
+ t.Errorf("CheckTimeout = %q, want %q", cond.CheckTimeout, "120s")
+ }
+ if cond.CheckTimeoutMs != 120000 {
+ t.Errorf("CheckTimeoutMs = %d, want 120000", cond.CheckTimeoutMs)
+ }
+ wire := map[string]any{}
+ b, err := json.Marshal(cond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(b, &wire); err != nil {
+ t.Fatal(err)
+ }
+ if wire["check_timeout"] != "120s" {
+ t.Errorf("wire check_timeout = %#v, want %q", wire["check_timeout"], "120s")
+ }
+ if wire["check_timeout_ms"] != float64(120000) {
+ t.Errorf("wire check_timeout_ms = %#v, want 120000", wire["check_timeout_ms"])
+ }
+
+ // A condition order without an explicit check_timeout still reports the
+ // effective 10s default deadline while leaving the raw string empty.
+ def := toOrderResponse(orders.Order{Name: "d", Trigger: "condition", Check: "true", Exec: "true"})
+ if def.CheckTimeout != "" {
+ t.Errorf("default CheckTimeout = %q, want empty", def.CheckTimeout)
+ }
+ if def.CheckTimeoutMs != 10000 {
+ t.Errorf("default CheckTimeoutMs = %d, want 10000", def.CheckTimeoutMs)
+ }
+
+ // Non-condition triggers have no check command, so the effective ms deadline
+ // must not be projected even if check_timeout was mistakenly configured.
+ noncond := toOrderResponse(orders.Order{
+ Name: "c", Trigger: "cooldown", Interval: "5m", Exec: "true", CheckTimeout: "120s",
+ })
+ if noncond.CheckTimeoutMs != 0 {
+ t.Errorf("non-condition CheckTimeoutMs = %d, want 0 (gated to condition orders)", noncond.CheckTimeoutMs)
+ }
+}
+
func TestHandleOrderGet_ScopedName(t *testing.T) {
fs := newFakeState(t)
fs.autos = []orders.Order{
diff --git a/internal/api/handler_session_create.go b/internal/api/handler_session_create.go
index 839598b9d9..e51b12c486 100644
--- a/internal/api/handler_session_create.go
+++ b/internal/api/handler_session_create.go
@@ -179,7 +179,7 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) {
// starts the agent process on the next tick. This avoids blocking the
// HTTP response for 10-30s while the agent boots in tmux, and lets real-world apps
// show the session in the sidebar immediately via optimistic UI.
- resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
+ resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), configuredWorkspaceSessionEnv(s.state.Config()), alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
if err != nil {
s.idem.unreserve(idemKey)
writeSessionManagerError(w, err)
@@ -366,7 +366,7 @@ func (s *Server) createProviderSession(w http.ResponseWriter, r *http.Request, s
}
}
- resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
+ resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), configuredWorkspaceSessionEnv(s.state.Config()), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
if err != nil {
s.idem.unreserve(idemKey)
writeSessionManagerError(w, err)
diff --git a/internal/api/handler_session_stream.go b/internal/api/handler_session_stream.go
index a72a50a478..6ec905effa 100644
--- a/internal/api/handler_session_stream.go
+++ b/internal/api/handler_session_stream.go
@@ -21,7 +21,7 @@ import (
type SessionStreamMessageEvent struct {
ID string `json:"id"`
Template string `json:"template"`
- Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, open-code, etc.)."`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.)."`
Format string `json:"format"`
Turns []outputTurn `json:"turns"`
Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
@@ -32,7 +32,7 @@ type SessionStreamMessageEvent struct {
type SessionStreamRawMessageEvent struct {
ID string `json:"id"`
Template string `json:"template"`
- Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing."`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing."`
Format string `json:"format"`
Messages []SessionRawMessageFrame `json:"messages" doc:"Provider-native transcript frames, emitted verbatim as the provider wrote them."`
Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
@@ -64,6 +64,25 @@ func runtimePendingInteraction(pending *worker.PendingInteraction) runtime.Pendi
}
}
+func pendingInteractionKey(pending *worker.PendingInteraction) string {
+ if pending == nil {
+ return ""
+ }
+ encoded, err := json.Marshal(runtimePendingInteraction(pending))
+ if err != nil {
+ log.Printf("session stream: pending interaction key encode failed for %s: %v", pending.RequestID, err)
+ return pending.RequestID
+ }
+ return string(encoded)
+}
+
+func sessionStreamResumeToken(lastEventID, afterCursor string) string {
+ if token := strings.TrimSpace(lastEventID); token != "" {
+ return token
+ }
+ return strings.TrimSpace(afterCursor)
+}
+
func (s *Server) handleSessionStream(w http.ResponseWriter, r *http.Request) {
store := s.state.SessionsBeadStore()
if store.Store == nil {
@@ -88,6 +107,8 @@ func (s *Server) handleSessionStream(w http.ResponseWriter, r *http.Request) {
return
}
format := r.URL.Query().Get("format")
+ includeThinking := queryBoolParam(r, "include_thinking")
+ resumeToken := sessionStreamResumeToken(r.Header.Get("Last-Event-ID"), r.URL.Query().Get("after_cursor"))
handle, err := s.workerHandleForSession(store.Store, id)
if err != nil {
writeSessionManagerError(w, err)
@@ -100,7 +121,7 @@ func (s *Server) handleSessionStream(w http.ResponseWriter, r *http.Request) {
history, historyErr := handle.History(worker.WithoutOperationEvents(r.Context()), historyReq)
hasHistory := historyErr == nil && history != nil
if historyErr != nil && !errors.Is(historyErr, worker.ErrHistoryUnavailable) {
- writeError(w, http.StatusInternalServerError, "internal", "reading session history: "+historyErr.Error())
+ writeTranscriptReadError(w, historyErr, "reading session history")
return
}
@@ -110,7 +131,7 @@ func (s *Server) handleSessionStream(w http.ResponseWriter, r *http.Request) {
return
}
running := workerPhaseHasLiveOutput(state.Phase)
- if !hasHistory && !running {
+ if !hasHistory && !running && format != "structured" {
writeError(w, http.StatusNotFound, "not_found", "session "+id+" has no live output")
return
}
@@ -141,24 +162,38 @@ func (s *Server) handleSessionStream(w http.ResponseWriter, r *http.Request) {
writeSSE(w, "message", 0, data)
}
if info.Closed {
- if format == "raw" {
+ switch format {
+ case "raw":
s.emitClosedSessionSnapshotRaw(w, info, history)
- } else {
+ case "structured":
+ s.emitClosedSessionSnapshotStructured(w, info, history, includeThinking, resumeToken)
+ default:
s.emitClosedSessionSnapshot(w, info, history)
}
return
}
+ if format == "structured" && !hasHistory && !running {
+ s.emitStructuredFallbackSnapshot(w, info, "", includeThinking, resumeToken)
+ return
+ }
switch {
case hasHistory:
- if format == "raw" {
+ switch format {
+ case "raw":
s.streamSessionTranscriptHistoryRaw(ctx, w, info, handle, history, historyReq)
- } else {
+ case "structured":
+ s.streamSessionTranscriptHistoryStructured(ctx, w, info, handle, history, includeThinking, resumeToken, "", "")
+ default:
s.streamSessionTranscriptHistory(ctx, w, info, handle, history)
}
+ case format == "structured":
+ s.streamSessionPeekStructured(ctx, w, info, handle, includeThinking, resumeToken)
+ return
case format == "raw":
// No log file yet. If the session is running, poll tmux pane content
- // and wrap it as a fake raw JSONL assistant message so a real-world app's existing
- // rendering pipeline shows terminal output (e.g. OAuth prompts).
+ // and wrap that live output as a synthetic raw JSONL assistant message
+ // so a real-world app's existing rendering pipeline shows terminal
+ // output (e.g. OAuth prompts).
s.streamSessionPeekRaw(ctx, w, info, handle)
return
default:
@@ -223,6 +258,53 @@ func (s *Server) emitClosedSessionSnapshotRaw(w http.ResponseWriter, info sessio
writeSSE(w, "activity", 2, actData)
}
+func (s *Server) emitClosedSessionSnapshotStructured(w http.ResponseWriter, info session.Info, history *worker.HistorySnapshot, includeThinking bool, resumeToken string) {
+ if history == nil {
+ s.emitStructuredFallbackSnapshot(w, info, "", includeThinking, resumeToken)
+ return
+ }
+ messages, _ := historySnapshotStructuredMessages(history, includeThinking)
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredHistoryFromSnapshot(history),
+ StructuredMessages: messages,
+ Pagination: history.Pagination,
+ }
+ writeStructuredSSEUpdate(w, buildStructuredStreamUpdate(resumeToken, projection, includeThinking))
+ actData, _ := json.Marshal(sessionStreamActivityPayload{Activity: "idle"})
+ writeSSEWithoutID(w, "activity", actData)
+}
+
+func (s *Server) emitStructuredFallbackSnapshot(w http.ResponseWriter, info session.Info, output string, includeThinking bool, resumeToken string) {
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, string(worker.TailActivityIdle)),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }
+ writeStructuredSSEUpdate(w, buildStructuredStreamUpdate(resumeToken, projection, includeThinking))
+ actData, _ := json.Marshal(sessionStreamActivityPayload{Activity: "idle"})
+ writeSSEWithoutID(w, "activity", actData)
+}
+
+func writeStructuredSSEUpdate(w http.ResponseWriter, update *SessionStreamStructuredMessageEvent) {
+ if update == nil || update.History == nil {
+ return
+ }
+ data, err := json.Marshal(update)
+ if err != nil {
+ return
+ }
+ writeSSE(w, "structured", update.History.Cursor.ResumeToken, data)
+}
+
func (s *Server) streamSessionTranscriptHistoryRaw(ctx context.Context, w http.ResponseWriter, info session.Info, handle interface {
worker.HistoryHandle
worker.InteractionHandle
@@ -241,6 +323,7 @@ func (s *Server) streamSessionTranscriptHistoryRaw(ctx context.Context, w http.R
var seq uint64
var lastActivity string
var lastPendingID string
+ var lastPendingKey string
lastProgress := time.Now()
sentIDs := make(map[string]struct{})
currentActivity := historySnapshotActivity(initial)
@@ -287,6 +370,7 @@ func (s *Server) streamSessionTranscriptHistoryRaw(ctx context.Context, w http.R
writeSSE(w, "message", seq, data)
lastProgress = time.Now()
lastPendingID = ""
+ lastPendingKey = ""
emitted = true
}
}
@@ -314,6 +398,7 @@ func (s *Server) streamSessionTranscriptHistoryRaw(ctx context.Context, w http.R
if err != nil || pending == nil {
if lastPendingID != "" {
lastPendingID = ""
+ lastPendingKey = ""
activity := currentActivity
if activity == "" {
activity = "in-turn"
@@ -325,10 +410,12 @@ func (s *Server) streamSessionTranscriptHistoryRaw(ctx context.Context, w http.R
}
return false
}
- if pending.RequestID == lastPendingID {
+ pendingKey := pendingInteractionKey(pending)
+ if pendingKey == lastPendingKey {
return false
}
lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
seq++
pendingData, _ := json.Marshal(pending)
writeSSE(w, "pending", seq, pendingData)
@@ -505,6 +592,182 @@ func (s *Server) streamSessionTranscriptHistory(ctx context.Context, w http.Resp
}
}
+func (s *Server) streamSessionTranscriptHistoryStructured(ctx context.Context, w http.ResponseWriter, info session.Info, handle interface {
+ worker.HistoryHandle
+ worker.InteractionHandle
+ worker.PeekHandle
+}, initial *worker.HistorySnapshot, includeThinking bool, resumeToken, pendingRequestID, pendingKey string,
+) {
+ logPath := sessionStreamTranscriptPath(ctx, handle)
+ poll := time.NewTicker(outputStreamPollInterval)
+ keepalive := time.NewTicker(sseKeepalive)
+ workerOps := s.watchSessionWorkerOperationSignals(ctx, info)
+ if logPath == "" {
+ defer poll.Stop()
+ defer keepalive.Stop()
+ }
+
+ var lastActivity string
+ lastPendingID := strings.TrimSpace(pendingRequestID)
+ lastPendingKey := pendingKey
+ lastProgress := time.Now()
+ currentActivity := historySnapshotActivity(initial)
+ currentResumeToken := resumeToken
+ var hasStructuredProjection bool
+
+ emitStructuredFallback := func() {
+ if hasStructuredProjection {
+ return
+ }
+ output, err := handle.Peek(ctx, 100)
+ if errors.Is(err, session.ErrSessionInactive) {
+ return
+ }
+ if err != nil {
+ log.Printf("session stream structured: fallback peek failed for %s: %v", info.ID, err)
+ output = ""
+ }
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, string(worker.TailActivityInTurn)),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }
+ if update := buildStructuredStreamUpdate(currentResumeToken, projection, includeThinking); update != nil {
+ currentResumeToken = update.History.Cursor.ResumeToken
+ writeStructuredSSEUpdate(w, update)
+ }
+ hasStructuredProjection = true
+ }
+
+ emitSnapshot := func(snapshot *worker.HistorySnapshot) bool {
+ emitted := false
+ if snapshot == nil {
+ return false
+ }
+ currentActivity = historySnapshotActivity(snapshot)
+ hasStructuredProjection = true
+ messages, _ := historySnapshotStructuredMessages(snapshot, includeThinking)
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredHistoryFromSnapshot(snapshot),
+ StructuredMessages: messages,
+ Pagination: snapshot.Pagination,
+ }
+ if update := buildStructuredStreamUpdate(currentResumeToken, projection, includeThinking); update != nil {
+ currentResumeToken = update.History.Cursor.ResumeToken
+ writeStructuredSSEUpdate(w, update)
+ lastProgress = time.Now()
+ emitted = true
+ }
+ activity := currentActivity
+ if activity != "" && activity != lastActivity {
+ lastActivity = activity
+ actData, _ := json.Marshal(sessionStreamActivityPayload{Activity: activity})
+ writeSSEWithoutID(w, "activity", actData)
+ lastProgress = time.Now()
+ emitted = true
+ }
+ return emitted
+ }
+ emitPending := func(force bool) bool {
+ if !force && lastPendingID == "" && time.Since(lastProgress) < sessionStreamPendingStallTimeout {
+ return false
+ }
+ pending, err := handle.Pending(ctx)
+ if err != nil {
+ log.Printf("session stream structured: pending read failed for %s: %v", info.ID, err)
+ return false
+ }
+ if pending == nil {
+ if lastPendingID == "" {
+ return false
+ }
+ clearedData, _ := json.Marshal(SessionPendingClearedEvent{RequestID: lastPendingID})
+ writeSSEWithoutID(w, "pending_cleared", clearedData)
+ lastPendingID = ""
+ lastPendingKey = ""
+ activity := currentActivity
+ if activity == "" {
+ activity = "in-turn"
+ }
+ actData, _ := json.Marshal(sessionStreamActivityPayload{Activity: activity})
+ writeSSEWithoutID(w, "activity", actData)
+ return true
+ }
+ pendingKey := pendingInteractionKey(pending)
+ if pendingKey == lastPendingKey {
+ return false
+ }
+ lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
+ pendingData, _ := json.Marshal(runtimePendingInteraction(pending))
+ writeSSEWithoutID(w, "pending", pendingData)
+ return true
+ }
+
+ var lw *logFileWatcher
+ reloadSnapshot := func() bool {
+ emitted := false
+ snapshot, err := handle.History(worker.WithoutOperationEvents(ctx), worker.HistoryRequest{})
+ switch {
+ case err == nil:
+ emitted = emitSnapshot(snapshot)
+ case errors.Is(err, worker.ErrHistoryUnavailable):
+ default:
+ log.Printf("session stream structured: history reload failed for %s: %v", info.ID, err)
+ }
+ emitted = emitPending(false) || emitted
+ if lw != nil {
+ lw.UpdatePath(sessionStreamTranscriptPath(ctx, handle))
+ }
+ return emitted
+ }
+
+ if logPath != "" {
+ poll.Stop()
+ keepalive.Stop()
+ lw = newLogFileWatcher(logPath)
+ defer lw.Close()
+ _ = emitSnapshot(initial)
+ emitStructuredFallback()
+ _ = emitPending(true)
+ lw.Run(ctx, reloadSnapshot, func() { writeSSEComment(w) }, RunOpts{
+ OnStall: func() { _ = emitPending(false) },
+ StallTimeout: sessionStreamPendingStallTimeout,
+ Wake: workerOps,
+ })
+ return
+ }
+
+ _ = emitSnapshot(initial)
+ emitStructuredFallback()
+ _ = emitPending(true)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-poll.C:
+ reloadSnapshot()
+ case _, ok := <-workerOps:
+ if !ok {
+ workerOps = nil
+ continue
+ }
+ reloadSnapshot()
+ case <-keepalive.C:
+ writeSSEComment(w)
+ }
+ }
+}
+
// streamSessionPeekRaw polls tmux pane content and wraps it as format=raw
// messages so a real-world app's JSONL rendering pipeline can display terminal output
// (e.g. OAuth prompts, startup screens) when no transcript log exists yet.
@@ -522,16 +785,20 @@ func (s *Server) streamSessionPeekRaw(ctx context.Context, w http.ResponseWriter
var lastOutput string
var seq uint64
var lastPeekPendingID string
+ var lastPeekPendingKey string
emitPending := func() {
pending, pErr := handle.Pending(ctx)
- if pErr == nil && pending != nil && pending.RequestID != lastPeekPendingID {
+ pendingKey := pendingInteractionKey(pending)
+ if pErr == nil && pending != nil && pendingKey != lastPeekPendingKey {
lastPeekPendingID = pending.RequestID
+ lastPeekPendingKey = pendingKey
seq++
pendingData, _ := json.Marshal(pending)
writeSSE(w, "pending", seq, pendingData)
} else if pending == nil && lastPeekPendingID != "" {
lastPeekPendingID = ""
+ lastPeekPendingKey = ""
}
}
@@ -547,7 +814,7 @@ func (s *Server) streamSessionPeekRaw(ctx context.Context, w http.ResponseWriter
lastOutput = output
seq++
if output != "" {
- fakeMsg, _ := json.Marshal(syntheticAssistantFrame{
+ syntheticMsg, _ := json.Marshal(syntheticAssistantFrame{
Role: "assistant",
Content: []syntheticContentBlock{{Type: "text", Text: output}},
})
@@ -556,7 +823,7 @@ func (s *Server) streamSessionPeekRaw(ctx context.Context, w http.ResponseWriter
Template: info.Template,
Provider: info.Provider,
Format: "raw",
- Messages: wrapRawFrameBytes([]json.RawMessage{fakeMsg}),
+ Messages: wrapRawFrameBytes([]json.RawMessage{syntheticMsg}),
})
if err == nil {
writeSSE(w, "message", seq, data)
@@ -565,7 +832,6 @@ func (s *Server) streamSessionPeekRaw(ctx context.Context, w http.ResponseWriter
}
emitPending()
}
-
emitPeek()
for {
@@ -586,6 +852,113 @@ func (s *Server) streamSessionPeekRaw(ctx context.Context, w http.ResponseWriter
}
}
+func (s *Server) streamSessionPeekStructured(ctx context.Context, w http.ResponseWriter, info session.Info, handle worker.Handle, includeThinking bool, resumeToken string,
+) {
+ poll := time.NewTicker(outputStreamPollInterval)
+ defer poll.Stop()
+ pollC := poll.C
+ if s.structuredPeekPoll != nil {
+ pollC = s.structuredPeekPoll
+ }
+ keepalive := time.NewTicker(sseKeepalive)
+ defer keepalive.Stop()
+ workerOps := s.watchSessionWorkerOperationSignals(ctx, info)
+
+ var lastOutput string
+ var emitted bool
+ var lastPendingID string
+ var lastPendingKey string
+ currentResumeToken := resumeToken
+
+ emitPending := func() {
+ pending, err := handle.Pending(ctx)
+ if err != nil {
+ log.Printf("session stream structured: pending read failed for %s: %v", info.ID, err)
+ return
+ }
+ pendingKey := pendingInteractionKey(pending)
+ if pending != nil && pendingKey != lastPendingKey {
+ lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
+ pendingData, _ := json.Marshal(runtimePendingInteraction(pending))
+ writeSSEWithoutID(w, "pending", pendingData)
+ } else if pending == nil && lastPendingID != "" {
+ clearedData, _ := json.Marshal(SessionPendingClearedEvent{RequestID: lastPendingID})
+ writeSSEWithoutID(w, "pending_cleared", clearedData)
+ lastPendingID = ""
+ lastPendingKey = ""
+ }
+ }
+
+ emitPeek := func() {
+ output, err := handle.Peek(ctx, 100)
+ if errors.Is(err, session.ErrSessionInactive) {
+ return
+ }
+ if err != nil || (emitted && output == lastOutput) {
+ emitPending()
+ return
+ }
+ lastOutput = output
+ emitted = true
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, string(worker.TailActivityInTurn)),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }
+ if update := buildStructuredStreamUpdate(currentResumeToken, projection, includeThinking); update != nil {
+ currentResumeToken = update.History.Cursor.ResumeToken
+ writeStructuredSSEUpdate(w, update)
+ }
+ emitPending()
+ }
+ promoteToHistory := func() bool {
+ snapshot, err := handle.History(worker.WithoutOperationEvents(ctx), worker.HistoryRequest{})
+ switch {
+ case err == nil:
+ s.streamSessionTranscriptHistoryStructured(ctx, w, info, handle, snapshot, includeThinking, currentResumeToken, lastPendingID, lastPendingKey)
+ return true
+ case errors.Is(err, worker.ErrHistoryUnavailable):
+ return false
+ default:
+ log.Printf("session stream structured: history promotion failed for %s: %v", info.ID, err)
+ return false
+ }
+ }
+
+ emitPeek()
+ if promoteToHistory() {
+ return
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-pollC:
+ if promoteToHistory() {
+ return
+ }
+ emitPeek()
+ case _, ok := <-workerOps:
+ if !ok {
+ workerOps = nil
+ continue
+ }
+ if promoteToHistory() {
+ return
+ }
+ emitPeek()
+ case <-keepalive.C:
+ writeSSEComment(w)
+ }
+ }
+}
+
func (s *Server) streamSessionPeek(ctx context.Context, w http.ResponseWriter, info session.Info, handle worker.PeekHandle) {
poll := time.NewTicker(outputStreamPollInterval)
defer poll.Stop()
@@ -666,6 +1039,7 @@ func (s *Server) streamSessionTranscriptLogRawHuma(ctx context.Context, send sse
var seq int
var lastActivity string
var lastPendingID string
+ var lastPendingKey string
lastProgress := time.Now()
sentIDs := make(map[string]struct{})
currentActivity := historySnapshotActivity(initial)
@@ -710,6 +1084,7 @@ func (s *Server) streamSessionTranscriptLogRawHuma(ctx context.Context, send sse
}})
lastProgress = time.Now()
lastPendingID = ""
+ lastPendingKey = ""
emitted = true
}
lastSentID = ids[len(ids)-1]
@@ -735,6 +1110,7 @@ func (s *Server) streamSessionTranscriptLogRawHuma(ctx context.Context, send sse
if err != nil || pending == nil {
if lastPendingID != "" {
lastPendingID = ""
+ lastPendingKey = ""
activity := currentActivity
if activity == "" {
activity = "in-turn"
@@ -745,10 +1121,12 @@ func (s *Server) streamSessionTranscriptLogRawHuma(ctx context.Context, send sse
}
return false
}
- if pending.RequestID == lastPendingID {
+ pendingKey := pendingInteractionKey(pending)
+ if pendingKey == lastPendingKey {
return false
}
lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
seq++
_ = send(sse.Message{ID: seq, Data: runtimePendingInteraction(pending)})
return true
@@ -930,6 +1308,185 @@ func (s *Server) streamSessionTranscriptLogHuma(ctx context.Context, send sse.Se
}
}
+func (s *Server) streamSessionTranscriptLogStructuredHuma(ctx context.Context, send StringIDSender, info session.Info, handle interface {
+ worker.HistoryHandle
+ worker.InteractionHandle
+ worker.PeekHandle
+}, initial *worker.HistorySnapshot, includeThinking bool, resumeToken, pendingRequestID, pendingKey string,
+) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ send = cancelOnStringIDSendError(send, cancel)
+
+ logPath := sessionStreamTranscriptPath(ctx, handle)
+ poll := time.NewTicker(outputStreamPollInterval)
+ keepalive := time.NewTicker(sseKeepalive)
+ workerOps := s.watchSessionWorkerOperationSignals(ctx, info)
+ if logPath == "" {
+ defer poll.Stop()
+ defer keepalive.Stop()
+ }
+
+ var lastActivity string
+ lastPendingID := strings.TrimSpace(pendingRequestID)
+ lastPendingKey := pendingKey
+ lastProgress := time.Now()
+ currentActivity := historySnapshotActivity(initial)
+ currentResumeToken := resumeToken
+ var hasStructuredProjection bool
+
+ emitStructuredFallback := func() {
+ if hasStructuredProjection {
+ return
+ }
+ output, err := handle.Peek(ctx, 100)
+ if errors.Is(err, session.ErrSessionInactive) {
+ return
+ }
+ if err != nil {
+ log.Printf("session stream structured: fallback peek failed for %s: %v", info.ID, err)
+ output = ""
+ }
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, string(worker.TailActivityInTurn)),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }
+ if update := buildStructuredStreamUpdate(currentResumeToken, projection, includeThinking); update != nil {
+ currentResumeToken = update.History.Cursor.ResumeToken
+ _ = send(StringIDMessage{ID: currentResumeToken, Data: *update})
+ }
+ hasStructuredProjection = true
+ }
+
+ emitSnapshot := func(snapshot *worker.HistorySnapshot) bool {
+ emitted := false
+ if snapshot == nil {
+ return false
+ }
+ currentActivity = historySnapshotActivity(snapshot)
+ hasStructuredProjection = true
+ messages, _ := historySnapshotStructuredMessages(snapshot, includeThinking)
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredHistoryFromSnapshot(snapshot),
+ StructuredMessages: messages,
+ Pagination: snapshot.Pagination,
+ }
+ if update := buildStructuredStreamUpdate(currentResumeToken, projection, includeThinking); update != nil {
+ currentResumeToken = update.History.Cursor.ResumeToken
+ _ = send(StringIDMessage{ID: currentResumeToken, Data: *update})
+ lastProgress = time.Now()
+ emitted = true
+ }
+
+ activity := currentActivity
+ if activity != "" && activity != lastActivity {
+ lastActivity = activity
+ _ = send(StringIDMessage{Data: SessionActivityEvent{Activity: activity}})
+ lastProgress = time.Now()
+ emitted = true
+ }
+ return emitted
+ }
+ emitPending := func(force bool) bool {
+ if !force && lastPendingID == "" && time.Since(lastProgress) < sessionStreamPendingStallTimeout {
+ return false
+ }
+ pending, err := handle.Pending(ctx)
+ if err != nil {
+ log.Printf("session stream structured: pending read failed for %s: %v", info.ID, err)
+ return false
+ }
+ if pending == nil {
+ if lastPendingID == "" {
+ return false
+ }
+ _ = send(StringIDMessage{Data: SessionPendingClearedEvent{RequestID: lastPendingID}})
+ lastPendingID = ""
+ lastPendingKey = ""
+ activity := currentActivity
+ if activity == "" {
+ activity = "in-turn"
+ }
+ _ = send(StringIDMessage{Data: SessionActivityEvent{Activity: activity}})
+ return true
+ }
+ pendingKey := pendingInteractionKey(pending)
+ if pendingKey == lastPendingKey {
+ return false
+ }
+ lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
+ _ = send(StringIDMessage{Data: runtimePendingInteraction(pending)})
+ return true
+ }
+
+ var lw *logFileWatcher
+ reloadSnapshot := func() bool {
+ emitted := false
+ snapshot, err := handle.History(worker.WithoutOperationEvents(ctx), worker.HistoryRequest{})
+ switch {
+ case err == nil:
+ emitted = emitSnapshot(snapshot)
+ case errors.Is(err, worker.ErrHistoryUnavailable):
+ default:
+ log.Printf("session stream structured: history reload failed for %s: %v", info.ID, err)
+ }
+ emitted = emitPending(false) || emitted
+ if lw != nil {
+ lw.UpdatePath(sessionStreamTranscriptPath(ctx, handle))
+ }
+ return emitted
+ }
+
+ if logPath != "" {
+ poll.Stop()
+ keepalive.Stop()
+ lw = newLogFileWatcher(logPath)
+ defer lw.Close()
+ _ = emitSnapshot(initial)
+ emitStructuredFallback()
+ _ = emitPending(true)
+ lw.Run(ctx, reloadSnapshot, func() {
+ _ = send(StringIDMessage{Data: HeartbeatEvent{Timestamp: time.Now().UTC().Format(time.RFC3339)}})
+ }, RunOpts{
+ OnStall: func() { _ = emitPending(false) },
+ StallTimeout: sessionStreamPendingStallTimeout,
+ Wake: workerOps,
+ })
+ return
+ }
+
+ _ = emitSnapshot(initial)
+ emitStructuredFallback()
+ _ = emitPending(true)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-poll.C:
+ reloadSnapshot()
+ case _, ok := <-workerOps:
+ if !ok {
+ workerOps = nil
+ continue
+ }
+ reloadSnapshot()
+ case <-keepalive.C:
+ _ = send(StringIDMessage{Data: HeartbeatEvent{Timestamp: time.Now().UTC().Format(time.RFC3339)}})
+ }
+ }
+}
+
func (s *Server) streamSessionPeekRawHuma(ctx context.Context, send sse.Sender, info session.Info) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -948,15 +1505,19 @@ func (s *Server) streamSessionPeekRawHuma(ctx context.Context, send sse.Sender,
var lastOutput string
var seq int
var lastPendingID string
+ var lastPendingKey string
emitPending := func() {
pending, err := handle.Pending(ctx)
- if err == nil && pending != nil && pending.RequestID != lastPendingID {
+ pendingKey := pendingInteractionKey(pending)
+ if err == nil && pending != nil && pendingKey != lastPendingKey {
lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
seq++
_ = send(sse.Message{ID: seq, Data: runtimePendingInteraction(pending)})
} else if pending == nil && lastPendingID != "" {
lastPendingID = ""
+ lastPendingKey = ""
}
}
@@ -972,7 +1533,7 @@ func (s *Server) streamSessionPeekRawHuma(ctx context.Context, send sse.Sender,
lastOutput = output
if output != "" {
- fakeMsg, err := json.Marshal(syntheticAssistantFrame{
+ syntheticMsg, err := json.Marshal(syntheticAssistantFrame{
Role: "assistant",
Content: []syntheticContentBlock{{Type: "text", Text: output}},
})
@@ -983,7 +1544,7 @@ func (s *Server) streamSessionPeekRawHuma(ctx context.Context, send sse.Sender,
Template: info.Template,
Provider: info.Provider,
Format: "raw",
- Messages: wrapRawFrameBytes([]json.RawMessage{fakeMsg}),
+ Messages: wrapRawFrameBytes([]json.RawMessage{syntheticMsg}),
}})
}
}
@@ -1011,6 +1572,115 @@ func (s *Server) streamSessionPeekRawHuma(ctx context.Context, send sse.Sender,
}
}
+func (s *Server) streamSessionPeekStructuredHuma(ctx context.Context, send StringIDSender, info session.Info, handle worker.Handle, includeThinking bool, resumeToken string) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ send = cancelOnStringIDSendError(send, cancel)
+ poll := time.NewTicker(outputStreamPollInterval)
+ defer poll.Stop()
+ pollC := poll.C
+ if s.structuredPeekPoll != nil {
+ pollC = s.structuredPeekPoll
+ }
+ keepalive := time.NewTicker(sseKeepalive)
+ defer keepalive.Stop()
+ workerOps := s.watchSessionWorkerOperationSignals(ctx, info)
+
+ var lastOutput string
+ var emitted bool
+ var lastPendingID string
+ var lastPendingKey string
+ currentResumeToken := resumeToken
+
+ emitPending := func() {
+ pending, err := handle.Pending(ctx)
+ if err != nil {
+ log.Printf("session stream structured: pending read failed for %s: %v", info.ID, err)
+ return
+ }
+ pendingKey := pendingInteractionKey(pending)
+ if pending != nil && pendingKey != lastPendingKey {
+ lastPendingID = pending.RequestID
+ lastPendingKey = pendingKey
+ _ = send(StringIDMessage{Data: runtimePendingInteraction(pending)})
+ } else if pending == nil && lastPendingID != "" {
+ _ = send(StringIDMessage{Data: SessionPendingClearedEvent{RequestID: lastPendingID}})
+ lastPendingID = ""
+ lastPendingKey = ""
+ }
+ }
+
+ emitPeek := func() {
+ output, err := handle.Peek(ctx, 100)
+ if errors.Is(err, session.ErrSessionInactive) {
+ return
+ }
+ if err != nil || (emitted && output == lastOutput) {
+ emitPending()
+ return
+ }
+ lastOutput = output
+ emitted = true
+
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, string(worker.TailActivityInTurn)),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }
+ if update := buildStructuredStreamUpdate(currentResumeToken, projection, includeThinking); update != nil {
+ currentResumeToken = update.History.Cursor.ResumeToken
+ _ = send(StringIDMessage{ID: currentResumeToken, Data: *update})
+ }
+
+ emitPending()
+ }
+ promoteToHistory := func() bool {
+ snapshot, err := handle.History(worker.WithoutOperationEvents(ctx), worker.HistoryRequest{})
+ switch {
+ case err == nil:
+ s.streamSessionTranscriptLogStructuredHuma(ctx, send, info, handle, snapshot, includeThinking, currentResumeToken, lastPendingID, lastPendingKey)
+ return true
+ case errors.Is(err, worker.ErrHistoryUnavailable):
+ return false
+ default:
+ log.Printf("session stream structured: history promotion failed for %s: %v", info.ID, err)
+ return false
+ }
+ }
+
+ emitPeek()
+ if promoteToHistory() {
+ return
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-pollC:
+ if promoteToHistory() {
+ return
+ }
+ emitPeek()
+ case _, ok := <-workerOps:
+ if !ok {
+ workerOps = nil
+ continue
+ }
+ if promoteToHistory() {
+ return
+ }
+ emitPeek()
+ case <-keepalive.C:
+ _ = send(StringIDMessage{Data: HeartbeatEvent{Timestamp: time.Now().UTC().Format(time.RFC3339)}})
+ }
+ }
+}
+
func (s *Server) streamSessionPeekHuma(ctx context.Context, send sse.Sender, info session.Info) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
diff --git a/internal/api/handler_session_stream_promotion_test.go b/internal/api/handler_session_stream_promotion_test.go
new file mode 100644
index 0000000000..7feb25afed
--- /dev/null
+++ b/internal/api/handler_session_stream_promotion_test.go
@@ -0,0 +1,267 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/events"
+ "github.com/gastownhall/gascity/internal/session"
+ "github.com/gastownhall/gascity/internal/testutil"
+ "github.com/gastownhall/gascity/internal/worker"
+)
+
+func TestSessionStreamResumeTokenPrefersLastEventID(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ lastEventID string
+ afterCursor string
+ want string
+ }{
+ {name: "query only", afterCursor: "query-cursor", want: "query-cursor"},
+ {name: "header only", lastEventID: "header-cursor", want: "header-cursor"},
+ {name: "header wins", lastEventID: " header-cursor ", afterCursor: "query-cursor", want: "header-cursor"},
+ {name: "blank header falls back", lastEventID: " ", afterCursor: " query-cursor ", want: "query-cursor"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := sessionStreamResumeToken(tc.lastEventID, tc.afterCursor); got != tc.want {
+ t.Fatalf("sessionStreamResumeToken(%q, %q) = %q, want %q", tc.lastEventID, tc.afterCursor, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestStructuredPeekPromotionClearsResolvedPendingInteraction(t *testing.T) {
+ for _, transport := range []string{"legacy", "huma"} {
+ t.Run(transport, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ handle := &structuredPromotionHandle{}
+ info := session.Info{
+ ID: "session-1",
+ SessionKey: "provider-session-1",
+ SessionName: "worker-1",
+ Template: "worker",
+ Provider: "test",
+ }
+ srv := &Server{}
+ done := make(chan struct{})
+ cleared := make(chan struct{}, 1)
+
+ switch transport {
+ case "legacy":
+ rec := newSyncResponseRecorder()
+ go func() {
+ srv.streamSessionPeekStructured(ctx, rec, info, handle, false, "")
+ close(done)
+ }()
+ go func() {
+ deadline := time.NewTicker(5 * time.Millisecond)
+ defer deadline.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-deadline.C:
+ if strings.Contains(rec.BodyString(), "event: pending_cleared") {
+ cleared <- struct{}{}
+ return
+ }
+ }
+ }
+ }()
+ case "huma":
+ go func() {
+ srv.streamSessionPeekStructuredHuma(ctx, func(msg StringIDMessage) error {
+ if _, ok := msg.Data.(SessionPendingClearedEvent); ok {
+ cleared <- struct{}{}
+ }
+ return nil
+ }, info, handle, false, "")
+ close(done)
+ }()
+ }
+
+ select {
+ case <-cleared:
+ case <-time.After(250 * time.Millisecond):
+ t.Fatal("fallback-to-history promotion did not clear the resolved pending interaction")
+ }
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("structured stream did not stop after cancellation")
+ }
+ })
+ }
+}
+
+func TestStructuredPeekEmitsSameRequestPendingUpdates(t *testing.T) {
+ for _, transport := range []string{"legacy", "huma"} {
+ t.Run(transport, func(t *testing.T) {
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ info := session.Info{ID: "session-1", SessionName: "worker-1", Template: "worker", Provider: "test"}
+ handle := &mutableStructuredPendingHandle{output: "fallback output"}
+ handle.SetPending(&worker.PendingInteraction{RequestID: "request-1", Kind: "approval", Prompt: "Proceed?"})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan struct{})
+ var waitForPrompt func(string)
+
+ switch transport {
+ case "legacy":
+ rec := newSyncResponseRecorder()
+ go func() {
+ srv.streamSessionPeekStructured(ctx, rec, info, handle, false, "")
+ close(done)
+ }()
+ waitForPrompt = func(prompt string) {
+ if body := waitForRecorderSubstring(t, rec, prompt, testutil.GoroutineRaceTimeout); !strings.Contains(body, prompt) {
+ t.Fatalf("structured stream body missing pending prompt %q: %s", prompt, body)
+ }
+ }
+ case "huma":
+ prompts := make(chan string, 2)
+ go func() {
+ srv.streamSessionPeekStructuredHuma(ctx, func(msg StringIDMessage) error {
+ raw, _ := json.Marshal(msg.Data)
+ var pending struct {
+ Prompt string `json:"prompt"`
+ }
+ if json.Unmarshal(raw, &pending) == nil && pending.Prompt != "" {
+ prompts <- pending.Prompt
+ }
+ return nil
+ }, info, handle, false, "")
+ close(done)
+ }()
+ waitForPrompt = func(want string) {
+ select {
+ case got := <-prompts:
+ if got != want {
+ t.Fatalf("pending prompt = %q, want %q", got, want)
+ }
+ case <-time.After(testutil.GoroutineRaceTimeout):
+ t.Fatalf("structured stream missing pending prompt %q", want)
+ }
+ }
+ }
+
+ waitForPrompt("Proceed?")
+ handle.SetPending(&worker.PendingInteraction{RequestID: "request-1", Kind: "approval", Prompt: "Updated prompt"})
+ fs.eventProv.(*events.Fake).Record(events.Event{Type: events.WorkerOperation, Subject: info.ID})
+ waitForPrompt("Updated prompt")
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(testutil.GoroutineRaceTimeout):
+ t.Fatal("structured stream did not stop after cancellation")
+ }
+ })
+ }
+}
+
+type structuredPromotionHandle struct {
+ worker.Handle
+
+ mu sync.Mutex
+ pendingReads int
+}
+
+func (h *structuredPromotionHandle) Peek(context.Context, int) (string, error) {
+ return "fallback output", nil
+}
+
+func (h *structuredPromotionHandle) History(context.Context, worker.HistoryRequest) (*worker.HistorySnapshot, error) {
+ return &worker.HistorySnapshot{
+ GCSessionID: "provider-session-1",
+ LogicalConversationID: "provider-session-1",
+ ProviderSessionID: "provider-session-1",
+ TranscriptStreamID: "stream-1",
+ Generation: worker.Generation{ID: "generation-1"},
+ Cursor: worker.Cursor{AfterEntryID: "history-1"},
+ Continuity: worker.Continuity{Status: worker.ContinuityStatusContinuous},
+ TailState: worker.TailState{
+ Activity: worker.TailActivityIdle,
+ LastEntryID: "history-1",
+ },
+ Entries: []worker.HistoryEntry{{
+ ID: "history-1",
+ Kind: "assistant",
+ Actor: worker.ActorAssistant,
+ Status: worker.ResultStatusFinal,
+ Text: "history output",
+ }},
+ }, nil
+}
+
+func (h *structuredPromotionHandle) Pending(context.Context) (*worker.PendingInteraction, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.pendingReads++
+ if h.pendingReads == 1 {
+ return &worker.PendingInteraction{RequestID: "request-1", Kind: "approval", Prompt: "Proceed?"}, nil
+ }
+ return nil, nil
+}
+
+func (h *structuredPromotionHandle) TranscriptPath(context.Context) (string, error) {
+ return "", errors.New("no transcript path for synthetic promotion test")
+}
+
+var _ worker.Handle = (*structuredPromotionHandle)(nil)
+
+type mutableStructuredPendingHandle struct {
+ worker.Handle
+
+ mu sync.Mutex
+ output string
+ pending *worker.PendingInteraction
+}
+
+func (h *mutableStructuredPendingHandle) Peek(context.Context, int) (string, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.output, nil
+}
+
+func (h *mutableStructuredPendingHandle) History(context.Context, worker.HistoryRequest) (*worker.HistorySnapshot, error) {
+ return nil, worker.ErrHistoryUnavailable
+}
+
+func (h *mutableStructuredPendingHandle) Pending(context.Context) (*worker.PendingInteraction, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return clonePendingInteraction(h.pending), nil
+}
+
+func (h *mutableStructuredPendingHandle) TranscriptPath(context.Context) (string, error) {
+ return "", worker.ErrHistoryUnavailable
+}
+
+func (h *mutableStructuredPendingHandle) SetPending(pending *worker.PendingInteraction) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.pending = clonePendingInteraction(pending)
+}
+
+func clonePendingInteraction(pending *worker.PendingInteraction) *worker.PendingInteraction {
+ if pending == nil {
+ return nil
+ }
+ cloned := *pending
+ cloned.Options = append([]string(nil), pending.Options...)
+ cloned.Metadata = cloneStringMap(pending.Metadata)
+ return &cloned
+}
+
+var _ worker.Handle = (*mutableStructuredPendingHandle)(nil)
diff --git a/internal/api/handler_session_transcript.go b/internal/api/handler_session_transcript.go
index 68d4b066ff..a548dfd1a9 100644
--- a/internal/api/handler_session_transcript.go
+++ b/internal/api/handler_session_transcript.go
@@ -1,11 +1,13 @@
package api
import (
- "encoding/json"
+ "context"
"errors"
"net/http"
"strconv"
+ "strings"
+ "github.com/gastownhall/gascity/internal/api/apierr"
"github.com/gastownhall/gascity/internal/session"
"github.com/gastownhall/gascity/internal/worker"
)
@@ -22,7 +24,7 @@ type sessionRawTranscriptResponse struct {
ID string `json:"id"`
Template string `json:"template"`
Format string `json:"format"`
- Messages []json.RawMessage `json:"messages"`
+ Messages []SessionRawMessageFrame `json:"messages"`
Pagination *worker.TranscriptPagination `json:"pagination,omitempty"`
}
@@ -60,7 +62,22 @@ func (s *Server) handleSessionTranscript(w http.ResponseWriter, r *http.Request)
return
}
- wantRaw := r.URL.Query().Get("format") == "raw"
+ format := r.URL.Query().Get("format")
+ wantRaw := format == "raw"
+ wantStructured := format == "structured"
+ includeThinking := wantStructured && queryBoolParam(r, "include_thinking")
+ before := strings.TrimSpace(r.URL.Query().Get("before"))
+ after := strings.TrimSpace(r.URL.Query().Get("after"))
+ if before != "" && after != "" {
+ writeError(w, http.StatusUnprocessableEntity, "invalid_params", "before and after are mutually exclusive")
+ return
+ }
+ if path == "" {
+ if cursorErr := transcriptCursorAbsentError(before, after); cursorErr != nil {
+ writeTranscriptReadError(w, cursorErr, "reading session log")
+ return
+ }
+ }
if path != "" {
tail := 0
@@ -69,11 +86,32 @@ func (s *Server) handleSessionTranscript(w http.ResponseWriter, r *http.Request)
tail = n
}
}
- before := r.URL.Query().Get("before")
- after := r.URL.Query().Get("after")
-
- if before != "" && after != "" {
- writeError(w, http.StatusUnprocessableEntity, "invalid_params", "before and after are mutually exclusive")
+ if wantStructured {
+ history, historyErr := handle.History(worker.WithoutOperationEvents(r.Context()), worker.HistoryRequest{
+ TailCompactions: tail,
+ BeforeEntryID: before,
+ AfterEntryID: after,
+ })
+ if historyErr != nil {
+ if errors.Is(historyErr, worker.ErrHistoryUnavailable) {
+ writeJSON(w, http.StatusOK, legacyStructuredFallbackTranscriptResponse(r.Context(), info, handle, includeThinking))
+ return
+ }
+ writeTranscriptReadError(w, historyErr, "reading session history")
+ return
+ }
+ messages, _ := historySnapshotStructuredMessages(history, includeThinking)
+ projection := structuredSnapshotProjection(SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredHistoryFromSnapshot(history),
+ StructuredMessages: messages,
+ Pagination: history.Pagination,
+ }, includeThinking)
+ writeJSON(w, http.StatusOK, structuredTranscriptResponseFromEvent(projection))
return
}
@@ -85,14 +123,14 @@ func (s *Server) handleSessionTranscript(w http.ResponseWriter, r *http.Request)
Raw: true,
})
if err != nil {
- writeError(w, http.StatusInternalServerError, "internal", "reading session log: "+err.Error())
+ writeTranscriptReadError(w, err, "reading session log")
return
}
writeJSON(w, http.StatusOK, sessionRawTranscriptResponse{
ID: info.ID,
Template: info.Template,
Format: "raw",
- Messages: transcript.RawMessages,
+ Messages: wrapRawFrameBytes(transcript.RawMessages),
Pagination: transcript.Session.Pagination,
})
return
@@ -104,7 +142,7 @@ func (s *Server) handleSessionTranscript(w http.ResponseWriter, r *http.Request)
AfterEntryID: after,
})
if err != nil {
- writeError(w, http.StatusInternalServerError, "internal", "reading session log: "+err.Error())
+ writeTranscriptReadError(w, err, "reading session log")
return
}
sess := transcript.Session
@@ -132,11 +170,16 @@ func (s *Server) handleSessionTranscript(w http.ResponseWriter, r *http.Request)
ID: info.ID,
Template: info.Template,
Format: "raw",
- Messages: []json.RawMessage{},
+ Messages: []SessionRawMessageFrame{},
})
return
}
+ if wantStructured {
+ writeJSON(w, http.StatusOK, legacyStructuredFallbackTranscriptResponse(r.Context(), info, handle, includeThinking))
+ return
+ }
+
output, peekErr := handle.Peek(r.Context(), 100)
if peekErr != nil && !errors.Is(peekErr, session.ErrSessionInactive) {
writeError(w, http.StatusInternalServerError, "internal", peekErr.Error())
@@ -163,3 +206,59 @@ func (s *Server) handleSessionTranscript(w http.ResponseWriter, r *http.Request)
Turns: []outputTurn{},
})
}
+
+func transcriptCursorInvalidatedProblem(err error, action string) *apierr.ErrorModel {
+ if !errors.Is(err, worker.ErrTranscriptCursorNotFound) && !errors.Is(err, worker.ErrTranscriptDuplicateEntryID) {
+ return nil
+ }
+ return apierr.TranscriptCursorInvalidated.Msg(action + ": " + err.Error())
+}
+
+func transcriptCursorAbsentError(before, after string) error {
+ if before != "" {
+ return &worker.TranscriptCursorNotFoundError{
+ Direction: worker.TranscriptCursorDirectionBefore,
+ EntryID: before,
+ }
+ }
+ if after != "" {
+ return &worker.TranscriptCursorNotFoundError{
+ Direction: worker.TranscriptCursorDirectionAfter,
+ EntryID: after,
+ }
+ }
+ return nil
+}
+
+func writeTranscriptReadError(w http.ResponseWriter, err error, action string) {
+ if problem := transcriptCursorInvalidatedProblem(err, action); problem != nil {
+ writeJSONWithType(w, problem.Status, "application/problem+json", problem)
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "internal", action+": "+err.Error())
+}
+
+func legacyStructuredFallbackTranscriptResponse(ctx context.Context, info session.Info, handle worker.PeekHandle, includeThinking bool) sessionTranscriptGetResponse {
+ activity := string(worker.TailActivityIdle)
+ output := ""
+ peekOutput, peekErr := handle.Peek(ctx, 100)
+ if peekErr == nil {
+ activity = string(worker.TailActivityInTurn)
+ output = peekOutput
+ }
+ projection := structuredSnapshotProjection(SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, activity),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }, includeThinking)
+ return structuredTranscriptResponseFromEvent(projection)
+}
+
+func queryBoolParam(r *http.Request, name string) bool {
+ value := strings.ToLower(strings.TrimSpace(r.URL.Query().Get(name)))
+ return value == "1" || value == "true" || value == "yes" || value == "on"
+}
diff --git a/internal/api/handler_sessions.go b/internal/api/handler_sessions.go
index 9afd46cb8b..23e5385b81 100644
--- a/internal/api/handler_sessions.go
+++ b/internal/api/handler_sessions.go
@@ -253,32 +253,51 @@ func (s *Server) handleSessionList(w http.ResponseWriter, r *http.Request) {
}
sessions, responseByID := filterEnrichReadModel(mgr, listings, stateFilter, templateFilter)
- items := make([]sessionResponse, len(sessions))
+ // Resolve the legacy offset page before runtime/transcript enrichment so
+ // off-page sessions do not perform filesystem discovery on every list poll.
+ pp := parsePagination(r, maxPaginationLimit)
+ rowIdx := make([]int, len(sessions))
+ for i := range rowIdx {
+ rowIdx[i] = i
+ }
+ pageIdx := rowIdx
+ var total int
+ nextCursor := ""
+ if !pp.IsPaging {
+ if pp.Limit < len(pageIdx) {
+ pageIdx = pageIdx[:pp.Limit]
+ }
+ total = len(pageIdx)
+ } else {
+ pageIdx, total, nextCursor = paginate(rowIdx, pp)
+ if pageIdx == nil {
+ pageIdx = []int{}
+ }
+ }
+
+ pageSessions := make([]session.Info, len(pageIdx))
+ for i, row := range pageIdx {
+ pageSessions[i] = sessions[row]
+ }
+ keyedTranscriptPaths := session.ResolveKeyedTranscriptPaths(sessionTranscriptLookupCandidates(pageSessions), s.sessionLogPaths(), sessionTranscriptProviderFallback(cfg))
+ items := make([]sessionResponse, len(pageSessions))
hasDeferredQueue := strings.TrimSpace(s.state.CityPath()) != ""
- for i, sess := range sessions {
+ for i, sess := range pageSessions {
items[i] = sessionResponseWithReason(sess, responseByID[sess.ID], cfg, s.state.SessionProvider(), hasDeferredQueue)
- s.enrichSessionResponse(&items[i], sess, cfg, s.runtimeSessionResponseHandle(sess), wantPeek, false, false, 0)
+ s.enrichSessionResponseWithKeyedPaths(&items[i], sess, cfg, s.runtimeSessionResponseHandle(sess), wantPeek, false, false, 0, keyedTranscriptPaths)
}
- pp := parsePagination(r, maxPaginationLimit)
if !pp.IsPaging {
- if pp.Limit < len(items) {
- items = items[:pp.Limit]
- }
writeJSON(w, http.StatusOK, listResponse{
Items: items,
- Total: len(items),
+ Total: total,
Partial: len(partialErrors) > 0,
PartialErrors: partialErrors,
})
return
}
- page, total, nextCursor := paginate(items, pp)
- if page == nil {
- page = []sessionResponse{}
- }
writeJSON(w, http.StatusOK, listResponse{
- Items: page,
+ Items: items,
Total: total,
NextCursor: nextCursor,
Partial: len(partialErrors) > 0,
@@ -286,6 +305,23 @@ func (s *Server) handleSessionList(w http.ResponseWriter, r *http.Request) {
})
}
+func sessionTranscriptLookupCandidates(infos []session.Info) []session.Info {
+ candidates := make([]session.Info, 0, len(infos))
+ for _, info := range infos {
+ if info.State == session.StateActive && strings.TrimSpace(info.WorkDir) != "" && strings.TrimSpace(info.SessionKey) != "" {
+ candidates = append(candidates, info)
+ }
+ }
+ return candidates
+}
+
+func sessionTranscriptProviderFallback(cfg *config.City) string {
+ if cfg == nil {
+ return ""
+ }
+ return strings.TrimSpace(cfg.Workspace.Provider)
+}
+
func (s *Server) handleSessionGet(w http.ResponseWriter, r *http.Request) {
store := s.state.SessionsBeadStore()
if store.Store == nil {
@@ -545,34 +581,17 @@ const defaultSessionPeekLines = 5
// peekLines controls the line count for the preview when wantPeek is true.
// Zero means "use default" (defaultSessionPeekLines).
func (s *Server) enrichSessionResponse(resp *sessionResponse, info session.Info, cfg *config.City, runtimeHandle any, wantPeek, liveActiveBead, allowWorkdirTranscriptDiscovery bool, peekLines int) {
+ s.enrichSessionResponseWithKeyedPaths(resp, info, cfg, runtimeHandle, wantPeek, liveActiveBead, allowWorkdirTranscriptDiscovery, peekLines, nil)
+}
+
+// enrichSessionResponseWithKeyedPaths accepts an optional page-level map of
+// exact transcript paths. A non-nil map is authoritative, including misses,
+// so list callers can batch Codex discovery once instead of scanning per row.
+func (s *Server) enrichSessionResponseWithKeyedPaths(resp *sessionResponse, info session.Info, cfg *config.City, runtimeHandle any, wantPeek, liveActiveBead, allowWorkdirTranscriptDiscovery bool, peekLines int, keyedTranscriptPaths map[string]string) {
if info.State != session.StateActive {
return
}
- var (
- stateHandle worker.StateHandle
- peekHandle worker.PeekHandle
- )
- switch v := runtimeHandle.(type) {
- case worker.Handle:
- stateHandle = v
- peekHandle = v
- case sessionResponseHandle:
- stateHandle = v
- peekHandle = v
- case runtime.Provider:
- store := s.state.SessionsBeadStore()
- if store.Store == nil {
- return
- }
- resolved, err := s.workerHandleForSession(store.Store, info.ID)
- if err != nil {
- return
- }
- stateHandle = resolved
- peekHandle = resolved
- default:
- return
- }
+ stateHandle, peekHandle := s.sessionRuntimeHandles(runtimeHandle, info)
if stateHandle == nil {
return
}
@@ -612,48 +631,90 @@ func (s *Server) enrichSessionResponse(resp *sessionResponse, info session.Info,
}
}
- // Model + context usage (best-effort).
- if resp.Running && info.WorkDir != "" {
- workDir := info.WorkDir
- if abs, err := filepath.Abs(workDir); err == nil {
- workDir = abs
+ s.applySessionModelContext(resp, info, cfg, allowWorkdirTranscriptDiscovery, keyedTranscriptPaths)
+}
+
+// sessionRuntimeHandles resolves the state and peek handles for an active
+// session from its runtime handle. It returns nil handles when none is usable
+// (unsupported handle type, missing bead store, or a worker lookup error); the
+// caller treats a nil state handle as "nothing to enrich".
+func (s *Server) sessionRuntimeHandles(runtimeHandle any, info session.Info) (worker.StateHandle, worker.PeekHandle) {
+ switch v := runtimeHandle.(type) {
+ case worker.Handle:
+ return v, v
+ case sessionResponseHandle:
+ return v, v
+ case runtime.Provider:
+ store := s.state.SessionsBeadStore()
+ if store.Store == nil {
+ return nil, nil
}
- factory, err := s.workerFactory(s.state.SessionsBeadStore().Store)
+ resolved, err := s.workerHandleForSession(store.Store, info.ID)
if err != nil {
- return
- }
- // Prefer session-key lookup to avoid cross-reading another session's transcript.
- // Cache the resolved file path — session files don't move once created.
- provider := info.Provider
- if strings.TrimSpace(provider) == "" && cfg != nil {
- provider, _ = resolveProviderInfo(provider, cfg)
- }
- if !allowWorkdirTranscriptDiscovery && !canUseCheapTranscriptLookup(provider, info.SessionKey) {
- return
- }
- sessionFile := factory.DiscoverTranscript(provider, workDir, info.SessionKey)
- if sessionFile != "" {
- if meta, err := factory.TailMeta(sessionFile); err == nil && meta != nil {
- resp.Model = meta.Model
- if meta.ContextUsage != nil {
- resp.ContextPct = &meta.ContextUsage.Percentage
- resp.ContextWindow = &meta.ContextUsage.ContextWindow
- }
- resp.Activity = meta.Activity
- }
+ return nil, nil
}
+ return resolved, resolved
+ default:
+ return nil, nil
}
}
-func canUseCheapTranscriptLookup(provider, sessionKey string) bool {
- if strings.TrimSpace(sessionKey) == "" {
- return false
+// applySessionModelContext fills the best-effort model and context-occupancy
+// fields on a running session response from its transcript tail metadata.
+func (s *Server) applySessionModelContext(resp *sessionResponse, info session.Info, cfg *config.City, allowWorkdirTranscriptDiscovery bool, keyedTranscriptPaths map[string]string) {
+ if !resp.Running || info.WorkDir == "" {
+ return
}
- p := strings.ToLower(strings.TrimSpace(provider))
- if strings.Contains(p, "codex") || strings.Contains(p, "gemini") {
- return false
+ workDir := info.WorkDir
+ if abs, err := filepath.Abs(workDir); err == nil {
+ workDir = abs
+ }
+ factory, err := s.workerFactory(s.state.SessionsBeadStore().Store)
+ if err != nil {
+ return
+ }
+ // Prefer session-key lookup to avoid cross-reading another session's transcript.
+ provider := info.Provider
+ if strings.TrimSpace(provider) == "" && cfg != nil {
+ provider, _ = resolveProviderInfo(provider, cfg)
+ }
+ transcriptProvider := session.ProviderFamilyFromInfo(info, provider)
+ sessionFile := s.resolveSessionTranscriptFile(info, workDir, transcriptProvider, factory, allowWorkdirTranscriptDiscovery, keyedTranscriptPaths)
+ if sessionFile == "" {
+ return
+ }
+ meta, err := factory.TailMetaForProvider(transcriptProvider, sessionFile)
+ if err != nil || meta == nil {
+ return
+ }
+ resp.Model = meta.Model
+ if meta.ContextUsage != nil {
+ resp.ContextPct = &meta.ContextUsage.Percentage
+ resp.ContextWindow = &meta.ContextUsage.ContextWindow
+ }
+ resp.Activity = meta.Activity
+}
+
+// resolveSessionTranscriptFile picks the exact transcript file for one session.
+// Get/create callers allow same-workdir discovery; list callers pass a
+// pre-batched keyed map where a missing entry is an authoritative miss.
+func (s *Server) resolveSessionTranscriptFile(info session.Info, workDir, transcriptProvider string, factory *worker.Factory, allowWorkdirTranscriptDiscovery bool, keyedTranscriptPaths map[string]string) string {
+ switch {
+ case allowWorkdirTranscriptDiscovery:
+ return factory.DiscoverTranscript(transcriptProvider, workDir, info.SessionKey)
+ case keyedTranscriptPaths != nil:
+ return keyedTranscriptPaths[info.ID]
+ default:
+ // Defensive exact-attribution path for a not-yet-existing caller that
+ // wants keyed telemetry without a prebuilt page map. No current handler
+ // reaches this branch: list callers pass a non-nil keyed map and
+ // get/create callers pass allowWorkdirTranscriptDiscovery=true. It is
+ // kept so any future caller resolves one exact session instead of
+ // silently getting "", and never falls back to a same-workdir file.
+ lookupInfo := info
+ lookupInfo.WorkDir = workDir
+ return session.ResolveKeyedTranscriptPath(lookupInfo, s.sessionLogPaths())
}
- return true
}
// handleSessionPatch handles PATCH /v0/session/{id}. Title and alias are mutable.
diff --git a/internal/api/handler_sessions_test.go b/internal/api/handler_sessions_test.go
index c620d490b1..6edfcacb0e 100644
--- a/internal/api/handler_sessions_test.go
+++ b/internal/api/handler_sessions_test.go
@@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
+ "crypto/md5" //nolint:gosec // Kimi uses MD5 as its documented workdir storage key.
"encoding/json"
"errors"
"fmt"
@@ -37,6 +38,38 @@ func newSessionFakeState(t *testing.T) *fakeState {
const testEventTimeout = 5 * time.Second
+func sameCanonicalTestPath(got, want string) bool {
+ canonicalGot, gotErr := filepath.EvalSymlinks(got)
+ canonicalWant, wantErr := filepath.EvalSymlinks(want)
+ if gotErr != nil || wantErr != nil {
+ return filepath.Clean(got) == filepath.Clean(want)
+ }
+ return canonicalGot == canonicalWant
+}
+
+func TestSameCanonicalTestPathResolvesSymlinkAliases(t *testing.T) {
+ realDir := filepath.Join(t.TempDir(), "real")
+ if err := os.Mkdir(realDir, 0o755); err != nil {
+ t.Fatalf("mkdir real dir: %v", err)
+ }
+ realPath := filepath.Join(realDir, "context.jsonl")
+ if err := os.WriteFile(realPath, []byte("transcript\n"), 0o600); err != nil {
+ t.Fatalf("write transcript: %v", err)
+ }
+ aliasDir := filepath.Join(filepath.Dir(realDir), "alias")
+ if err := os.Symlink(realDir, aliasDir); err != nil {
+ t.Fatalf("symlink real dir: %v", err)
+ }
+ aliasPath := filepath.Join(aliasDir, filepath.Base(realPath))
+
+ if !sameCanonicalTestPath(aliasPath, realPath) {
+ t.Fatalf("symlink aliases should identify the same path: got %q, want %q", aliasPath, realPath)
+ }
+ if sameCanonicalTestPath(aliasPath, filepath.Join(realDir, "missing.jsonl")) {
+ t.Fatal("distinct paths must not compare equal")
+ }
+}
+
func decodeAsyncAccepted(t *testing.T, body io.Reader) asyncAcceptedBody {
t.Helper()
@@ -833,6 +866,47 @@ func TestHandleSessionListPagination(t *testing.T) {
}
}
+func TestHandleSessionListEnrichesOnlyRequestedPage(t *testing.T) {
+ fs := newSessionFakeState(t)
+ createTestSession(t, fs.cityBeadStore, fs.sp, "S1")
+ createTestSession(t, fs.cityBeadStore, fs.sp, "S2")
+ createTestSession(t, fs.cityBeadStore, fs.sp, "S3")
+ counting := &getCountingStore{Store: fs.cityBeadStore}
+ fs.cityBeadStore = counting
+ fs.sp.Calls = nil
+
+ h := newTestCityHandler(t, fs)
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/sessions?limit=1&peek=true"), nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ var resp struct {
+ Items []sessionResponse `json:"items"`
+ Total int `json:"total"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Items) != 1 || resp.Total != 3 {
+ t.Fatalf("items/total = %d/%d, want 1/3", len(resp.Items), resp.Total)
+ }
+ peekCalls := 0
+ for _, call := range fs.sp.SnapshotCalls() {
+ if call.Method == "Peek" {
+ peekCalls++
+ }
+ }
+ if peekCalls != 1 {
+ t.Fatalf("Peek calls = %d, want 1 for the requested page", peekCalls)
+ }
+ if got := counting.gets.Load(); got != 0 {
+ t.Fatalf("store.Get calls = %d, want 0 for the Huma session-list read model", got)
+ }
+}
+
func TestHandleSessionGet(t *testing.T) {
fs := newSessionFakeState(t)
srv := New(fs)
@@ -951,6 +1025,141 @@ func newHermeticCodexSessionSearchPath(t *testing.T) string {
return t.TempDir()
}
+const codexTestContextWindow = 258_400
+
+// writeCanonicalCodexTelemetryRollout writes the three real Codex rollout
+// records needed by session telemetry: session_meta identifies the rollout,
+// turn_context carries the model, and event_msg/token_count carries the latest
+// prompt usage and provider-reported context window. Codex input_tokens already
+// includes cached_input_tokens, so cached tokens are deliberately non-zero to
+// catch callers that incorrectly add or subtract them when computing context
+// occupancy.
+func writeCanonicalCodexTelemetryRollout(t *testing.T, root string, ts time.Time, sessionKey, workDir, model string, inputTokens, cachedInputTokens int) {
+ t.Helper()
+
+ local := ts.In(time.Local)
+ dir := filepath.Join(root, local.Format("2006"), local.Format("01"), local.Format("02"))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("MkdirAll Codex rollout dir: %v", err)
+ }
+ path := filepath.Join(dir, "rollout-"+local.Format("2006-01-02T15-04-05")+"-"+sessionKey+".jsonl")
+ outputTokens := 400
+ reasoningTokens := 100
+ lastTotalTokens := inputTokens + outputTokens
+ // Keep cumulative usage far above the current request so the assertions
+ // catch code that mistakes lifetime spend for current context occupancy.
+ cumulativeInputTokens := inputTokens + 600_000
+ cumulativeCachedInputTokens := cachedInputTokens + 300_000
+ cumulativeOutputTokens := outputTokens + 25_000
+ cumulativeTotalTokens := cumulativeInputTokens + cumulativeOutputTokens
+ lines := []string{
+ fmt.Sprintf(`{"timestamp":%q,"type":"session_meta","payload":{"id":%q,"timestamp":%q,"cwd":%q,"originator":"codex-tui","cli_version":"0.121.0","source":"cli","model_provider":"openai"}}`, ts.UTC().Format(time.RFC3339Nano), sessionKey, ts.UTC().Format(time.RFC3339Nano), workDir),
+ fmt.Sprintf(`{"timestamp":%q,"type":"turn_context","payload":{"turn_id":"019d9845-45f6-70d2-86e8-53d8a44a830f","cwd":%q,"current_date":%q,"timezone":"Etc/UTC","approval_policy":"never","sandbox_policy":{"type":"danger-full-access"},"model":%q,"personality":"pragmatic"}}`, ts.Add(100*time.Millisecond).UTC().Format(time.RFC3339Nano), workDir, ts.Format("2006-01-02"), model),
+ fmt.Sprintf(`{"timestamp":%q,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":%d,"cached_input_tokens":%d,"output_tokens":%d,"reasoning_output_tokens":%d,"total_tokens":%d},"last_token_usage":{"input_tokens":%d,"cached_input_tokens":%d,"output_tokens":%d,"reasoning_output_tokens":%d,"total_tokens":%d},"model_context_window":%d},"rate_limits":{"limit_id":"codex","limit_name":null,"primary":{"used_percent":0.0,"window_minutes":300,"resets_at":1776394093},"secondary":{"used_percent":0.0,"window_minutes":10080,"resets_at":1776980893},"credits":null,"plan_type":"pro"}}}`, ts.Add(200*time.Millisecond).UTC().Format(time.RFC3339Nano), cumulativeInputTokens, cumulativeCachedInputTokens, cumulativeOutputTokens, reasoningTokens, cumulativeTotalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningTokens, lastTotalTokens, codexTestContextWindow),
+ }
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatalf("WriteFile Codex rollout: %v", err)
+ }
+}
+
+func TestHandleSessionListIncludesKeyedCodexTelemetryWithoutPerSessionGets(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := newHermeticCodexSessionSearchPath(t)
+ workDir := t.TempDir()
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+
+ type wantTelemetry struct {
+ info session.Info
+ sessionKey string
+ model string
+ pct int
+ }
+ wants := []wantTelemetry{
+ {sessionKey: "019e9966-aaaa-7000-8000-26a2dd7e15b3", model: "gpt-5.4", pct: 10},
+ {sessionKey: "019e9966-bbbb-7000-8000-26a2dd7e15b3", model: "gpt-5.5", pct: 50},
+ }
+ inputTokens := []int{25_840, 129_200}
+ cachedInputTokens := []int{5_840, 29_200}
+ now := time.Now()
+ for i := range wants {
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: fmt.Sprintf("Codex Chat %d", i+1),
+ Command: "codex",
+ WorkDir: workDir,
+ // The concrete configured name need not contain "codex"; the
+ // persisted provider_kind is the canonical transcript family.
+ Provider: "remote-openai",
+ Env: nil,
+ Resume: session.ProviderResume{},
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ "provider_kind": "codex",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create Codex session %d: %v", i+1, err)
+ }
+ if err := mgr.PersistSessionKey(info.ID, wants[i].sessionKey); err != nil {
+ t.Fatalf("PersistSessionKey(%s): %v", info.ID, err)
+ }
+ wants[i].info = info
+ writeCanonicalCodexTelemetryRollout(t, searchBase, now, wants[i].sessionKey, workDir, wants[i].model, inputTokens[i], cachedInputTokens[i])
+ }
+
+ // Wrap only after all session setup so any Get call below belongs to the
+ // session-list read path under test, not fixture creation or key capture.
+ counting := &getCountingStore{Store: fs.cityBeadStore}
+ fs.cityBeadStore = counting
+ srv := New(fs)
+ srv.sessionLogSearchPaths = []string{searchBase}
+ h := newTestCityHandlerWith(t, fs, srv)
+
+ req := httptest.NewRequest("GET", cityURL(fs, "/sessions?template=myrig%2Fworker"), nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Items []sessionResponse `json:"items"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Items) != len(wants) {
+ t.Fatalf("got %d items, want %d: %#v", len(resp.Items), len(wants), resp.Items)
+ }
+ byID := make(map[string]sessionResponse, len(resp.Items))
+ for _, item := range resp.Items {
+ byID[item.ID] = item
+ }
+ for _, want := range wants {
+ got, ok := byID[want.info.ID]
+ if !ok {
+ t.Errorf("missing session %s in list response", want.info.ID)
+ continue
+ }
+ if !got.Running {
+ t.Errorf("session %s Running = false, want true", want.info.ID)
+ }
+ if got.Model != want.model {
+ t.Errorf("session %s Model = %q, want %q", want.info.ID, got.Model, want.model)
+ }
+ if got.ContextPct == nil || *got.ContextPct != want.pct {
+ t.Errorf("session %s ContextPct = %v, want %d", want.info.ID, got.ContextPct, want.pct)
+ }
+ if got.ContextWindow == nil || *got.ContextWindow != codexTestContextWindow {
+ t.Errorf("session %s ContextWindow = %v, want %d", want.info.ID, got.ContextWindow, codexTestContextWindow)
+ }
+ }
+ if got := counting.gets.Load(); got != 0 {
+ t.Fatalf("store.Get calls = %d, want 0 for keyed Codex session-list telemetry", got)
+ }
+}
+
func TestHandleSessionListSkipsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T) {
fs := newSessionFakeState(t)
searchBase := newHermeticCodexSessionSearchPath(t)
@@ -968,17 +1177,7 @@ func TestHandleSessionListSkipsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T)
t.Fatalf("SessionKey = %q, want empty for codex provider without SessionIDFlag", info.SessionKey)
}
- codexDir := filepath.Join(searchBase, "2026", "04", "18")
- if err := os.MkdirAll(codexDir, 0o755); err != nil {
- t.Fatalf("MkdirAll: %v", err)
- }
- codexPayload := strings.Join([]string{
- fmt.Sprintf(`{"type":"session_meta","payload":{"cwd":%q}}`, workDir),
- `{"type":"assistant","message":{"model":"gpt-5.5","usage":{"input_tokens":1000}}}`,
- }, "\n") + "\n"
- if err := os.WriteFile(filepath.Join(codexDir, "session.jsonl"), []byte(codexPayload), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
+ writeCanonicalCodexTelemetryRollout(t, searchBase, time.Now(), "019e9966-cccc-7000-8000-26a2dd7e15b3", workDir, "gpt-5.5", 25_840, 5_840)
req := httptest.NewRequest("GET", cityURL(fs, "/sessions?template=myrig%2Fworker"), nil)
rec := httptest.NewRecorder()
@@ -996,8 +1195,8 @@ func TestHandleSessionListSkipsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T)
if len(resp.Items) != 1 || resp.Items[0].ID != info.ID {
t.Fatalf("items = %#v, want session %s", resp.Items, info.ID)
}
- if resp.Items[0].Model != "" || resp.Items[0].ContextPct != nil {
- t.Fatalf("session list used workdir-only Codex transcript discovery: model=%q context=%v", resp.Items[0].Model, resp.Items[0].ContextPct)
+ if got := resp.Items[0]; got.Model != "" || got.ContextPct != nil || got.ContextWindow != nil || got.Activity != "" {
+ t.Fatalf("session list used foreign workdir-only Codex telemetry: model=%q context_pct=%v context_window=%v activity=%q", got.Model, got.ContextPct, got.ContextWindow, got.Activity)
}
}
@@ -1015,17 +1214,7 @@ func TestHandleSessionGetAllowsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T)
t.Fatalf("Create: %v", err)
}
- codexDir := filepath.Join(searchBase, "2026", "04", "18")
- if err := os.MkdirAll(codexDir, 0o755); err != nil {
- t.Fatalf("MkdirAll: %v", err)
- }
- codexPayload := strings.Join([]string{
- fmt.Sprintf(`{"type":"session_meta","payload":{"cwd":%q}}`, workDir),
- `{"type":"assistant","message":{"model":"gpt-5.5","usage":{"input_tokens":1000}}}`,
- }, "\n") + "\n"
- if err := os.WriteFile(filepath.Join(codexDir, "session.jsonl"), []byte(codexPayload), 0o644); err != nil {
- t.Fatalf("WriteFile: %v", err)
- }
+ writeCanonicalCodexTelemetryRollout(t, searchBase, time.Now(), "019e9966-dddd-7000-8000-26a2dd7e15b3", workDir, "gpt-5.5", 25_840, 5_840)
req := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID, nil)
rec := httptest.NewRecorder()
@@ -1044,6 +1233,12 @@ func TestHandleSessionGetAllowsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T)
if resp.Model != "gpt-5.5" {
t.Fatalf("model = %q, want gpt-5.5", resp.Model)
}
+ if resp.ContextPct == nil || *resp.ContextPct != 10 {
+ t.Fatalf("context_pct = %v, want 10", resp.ContextPct)
+ }
+ if resp.ContextWindow == nil || *resp.ContextWindow != codexTestContextWindow {
+ t.Fatalf("context_window = %v, want %d", resp.ContextWindow, codexTestContextWindow)
+ }
}
func TestHandleSessionListActiveBeadUsesCachedListWhenAvailable(t *testing.T) {
@@ -5509,6 +5704,91 @@ func TestHandleSessionTranscriptAfterCursorRaw(t *testing.T) {
}
}
+func TestHandleSessionTranscriptCursorPaginationMetadata(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ resume := session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"1","parentUuid":"","type":"user","message":"{\"role\":\"user\",\"content\":\"first\"}","timestamp":"2025-01-01T00:00:00Z"}`,
+ `{"uuid":"2","parentUuid":"1","type":"assistant","message":"{\"role\":\"assistant\",\"content\":\"second\"}","timestamp":"2025-01-01T00:00:01Z"}`,
+ `{"uuid":"3","parentUuid":"2","type":"user","message":"{\"role\":\"user\",\"content\":\"third\"}","timestamp":"2025-01-01T00:00:02Z"}`,
+ `{"uuid":"4","parentUuid":"3","type":"assistant","message":"{\"role\":\"assistant\",\"content\":\"fourth\"}","timestamp":"2025-01-01T00:00:03Z"}`,
+ )
+
+ surfaces := []struct {
+ name string
+ path string
+ handler http.Handler
+ }{
+ {
+ name: "city-huma",
+ path: cityURL(fs, "/session/") + info.ID + "/transcript",
+ handler: h,
+ },
+ {
+ name: "legacy",
+ path: "/v0/session/" + info.ID + "/transcript",
+ handler: srv.legacySessionHandler(),
+ },
+ }
+ directions := []struct {
+ name string
+ query string
+ wantOlder bool
+ wantNewer bool
+ }{
+ {name: "before", query: "before=3", wantNewer: true},
+ {name: "after", query: "after=2", wantOlder: true},
+ }
+
+ for _, surface := range surfaces {
+ for _, format := range []string{"conversation", "raw", "structured"} {
+ for _, direction := range directions {
+ t.Run(surface.name+"/"+format+"/"+direction.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ path := surface.path + "?format=" + format + "&" + direction.query
+ r := httptest.NewRequest(http.MethodGet, path, nil)
+ surface.handler.ServeHTTP(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ var response struct {
+ Pagination *sessionlog.PaginationInfo `json:"pagination"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+ t.Fatalf("decode transcript response: %v", err)
+ }
+ if response.Pagination == nil {
+ t.Fatal("pagination metadata is nil")
+ }
+ if response.Pagination.TotalMessageCount != 4 || response.Pagination.ReturnedMessageCount != 2 {
+ t.Fatalf("pagination = %+v, want total=4 returned=2", response.Pagination)
+ }
+ if response.Pagination.HasOlderMessages != direction.wantOlder || response.Pagination.HasNewerMessages != direction.wantNewer {
+ t.Fatalf("pagination flags = older:%t newer:%t, want older:%t newer:%t", response.Pagination.HasOlderMessages, response.Pagination.HasNewerMessages, direction.wantOlder, direction.wantNewer)
+ }
+ })
+ }
+ }
+ }
+}
+
func TestHandleSessionTranscriptBeforeAndAfterExclusive(t *testing.T) {
fs := newSessionFakeState(t)
searchBase := t.TempDir()
@@ -5542,12 +5822,11 @@ func TestHandleSessionTranscriptBeforeAndAfterExclusive(t *testing.T) {
}
}
-func TestHandleSessionTranscriptAfterCursorNotFound(t *testing.T) {
+func TestHandleSessionTranscriptMissingCursorReturnsConflict(t *testing.T) {
fs := newSessionFakeState(t)
searchBase := t.TempDir()
srv := New(fs)
h := newTestCityHandlerWith(t, fs, srv)
- _ = h
srv.sessionLogSearchPaths = []string{searchBase}
mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
@@ -5567,20 +5846,388 @@ func TestHandleSessionTranscriptAfterCursorNotFound(t *testing.T) {
`{"uuid":"2","parentUuid":"1","type":"assistant","message":"{\"role\":\"assistant\",\"content\":\"world\"}","timestamp":"2025-01-01T00:00:01Z"}`,
)
- w := httptest.NewRecorder()
- r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?after=nonexistent", nil)
- h.ServeHTTP(w, r)
+ surfaces := []struct {
+ name string
+ path string
+ handler http.Handler
+ }{
+ {
+ name: "city-huma",
+ path: cityURL(fs, "/session/") + info.ID + "/transcript",
+ handler: h,
+ },
+ {
+ name: "legacy",
+ path: "/v0/session/" + info.ID + "/transcript",
+ handler: srv.legacySessionHandler(),
+ },
+ }
+ formats := []string{"conversation", "raw", "structured"}
+ directions := []string{"before", "after"}
+
+ for _, surface := range surfaces {
+ for _, format := range formats {
+ for _, direction := range directions {
+ t.Run(surface.name+"/"+format+"/"+direction, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ path := surface.path + "?format=" + format + "&" + direction + "=nonexistent"
+ r := httptest.NewRequest(http.MethodGet, path, nil)
+ surface.handler.ServeHTTP(w, r)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
+ }
+ if got := strings.Split(w.Header().Get("Content-Type"), ";")[0]; got != "application/problem+json" {
+ t.Fatalf("Content-Type = %q, want application/problem+json", w.Header().Get("Content-Type"))
+ }
+
+ var problem struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status int `json:"status"`
+ Detail string `json:"detail"`
+ Code string `json:"code"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&problem); err != nil {
+ t.Fatalf("decode problem details: %v", err)
+ }
+ if problem.Type != "urn:gascity:error:transcript-cursor-invalidated" {
+ t.Errorf("type = %q, want transcript cursor invalidation URN", problem.Type)
+ }
+ if problem.Code != "transcript-cursor-invalidated" {
+ t.Errorf("code = %q, want transcript-cursor-invalidated", problem.Code)
+ }
+ if problem.Title != "Transcript Cursor Invalidated" {
+ t.Errorf("title = %q, want Transcript Cursor Invalidated", problem.Title)
+ }
+ if problem.Status != http.StatusConflict {
+ t.Errorf("problem status = %d, want %d", problem.Status, http.StatusConflict)
+ }
+ if !strings.Contains(problem.Detail, "nonexistent") {
+ t.Errorf("detail = %q, want missing cursor", problem.Detail)
+ }
+ })
+ }
+ }
+ }
+}
- if w.Code != http.StatusOK {
- t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+func TestSessionTranscriptAndStreamDuplicateEntryIDReturnsConflict(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Chat",
+ Command: "copilot",
+ WorkDir: workDir,
+ Provider: "copilot",
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
}
- var resp SessionStreamMessageEvent
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decode: %v", err)
+ path := filepath.Join(searchBase, "copilot-session", "events.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir Copilot fixture: %v", err)
}
- if len(resp.Turns) != 2 {
- t.Fatalf("got %d turns, want 2 (cursor not found = full set)", len(resp.Turns))
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"type":"session.start","data":{"cwd":%q}}`, workDir),
+ `{"type":"user.message","data":{"content":"zero"},"id":"duplicate"}`,
+ `{"type":"assistant.message","data":{"content":"one"},"id":"duplicate"}`,
+ `{"type":"user.message","data":{"content":"two"},"id":"copilot-2"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatalf("write Copilot fixture: %v", err)
+ }
+
+ surfaces := []struct {
+ name string
+ path string
+ handler http.Handler
+ }{
+ {name: "city-huma", path: cityURL(fs, "/session/") + info.ID + "/transcript", handler: h},
+ {name: "legacy", path: "/v0/session/" + info.ID + "/transcript", handler: srv.legacySessionHandler()},
+ }
+
+ for _, surface := range surfaces {
+ for _, format := range []string{"conversation", "raw", "structured"} {
+ for _, direction := range []string{"before", "after"} {
+ t.Run(surface.name+"/"+format+"/"+direction, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, surface.path+"?format="+format+"&"+direction+"=duplicate", nil)
+ surface.handler.ServeHTTP(w, r)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
+ }
+ if got := strings.Split(w.Header().Get("Content-Type"), ";")[0]; got != "application/problem+json" {
+ t.Fatalf("Content-Type = %q, want application/problem+json", w.Header().Get("Content-Type"))
+ }
+ var problem struct {
+ Code string `json:"code"`
+ Detail string `json:"detail"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&problem); err != nil {
+ t.Fatalf("decode problem details: %v", err)
+ }
+ if problem.Code != "transcript-cursor-invalidated" {
+ t.Fatalf("problem code = %q, want transcript-cursor-invalidated", problem.Code)
+ }
+ if !strings.Contains(problem.Detail, "duplicate") {
+ t.Fatalf("detail = %q, want duplicate entry ID", problem.Detail)
+ }
+ })
+ }
+ }
+ }
+
+ streamSurfaces := []struct {
+ name string
+ path string
+ handler http.Handler
+ }{
+ {name: "city-huma", path: cityURL(fs, "/session/") + info.ID + "/stream", handler: h},
+ {name: "legacy", path: "/v0/session/" + info.ID + "/stream", handler: srv.legacySessionHandler()},
+ }
+ for _, surface := range streamSurfaces {
+ t.Run(surface.name+"/stream", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, surface.path+"?format=structured", nil)
+ surface.handler.ServeHTTP(w, r)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
+ }
+ if got := strings.Split(w.Header().Get("Content-Type"), ";")[0]; got != "application/problem+json" {
+ t.Fatalf("Content-Type = %q, want application/problem+json", w.Header().Get("Content-Type"))
+ }
+ var problem struct {
+ Code string `json:"code"`
+ Detail string `json:"detail"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&problem); err != nil {
+ t.Fatalf("decode problem details: %v", err)
+ }
+ if problem.Code != "transcript-cursor-invalidated" {
+ t.Fatalf("problem code = %q, want transcript-cursor-invalidated", problem.Code)
+ }
+ if !strings.Contains(problem.Detail, "duplicate") {
+ t.Fatalf("detail = %q, want duplicate entry ID", problem.Detail)
+ }
+ })
+ }
+}
+
+func TestHandleSessionTranscriptSyntheticCursorSurvivesTruncationAndInvalidatesOnRewrite(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Chat",
+ Command: "kimi",
+ WorkDir: workDir,
+ Provider: "kimi",
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ workHash := fmt.Sprintf("%x", md5.Sum([]byte(filepath.Clean(workDir))))
+ sessionDir := info.SessionKey
+ if sessionDir == "" {
+ sessionDir = "kimi-session"
+ }
+ path := filepath.Join(searchBase, workHash, sessionDir, "context.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir Kimi fixture: %v", err)
+ }
+ write := func(lines ...string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
+ t.Fatalf("write Kimi fixture: %v", err)
+ }
+ }
+ initialLines := []string{
+ `{"role":"user","content":"zero"}`,
+ `{"role":"assistant","content":"one"}`,
+ `{"role":"user","content":"two"}`,
+ }
+ write(initialLines...)
+ initial, err := sessionlog.ReadProviderFile("kimi", path, 0)
+ if err != nil {
+ t.Fatalf("read initial Kimi fixture: %v", err)
+ }
+ if len(initial.Messages) != 3 {
+ t.Fatalf("initial Kimi messages = %d, want 3", len(initial.Messages))
+ }
+ handle, err := srv.workerHandleForSession(fs.cityBeadStore, info.ID)
+ if err != nil {
+ t.Fatalf("workerHandleForSession: %v", err)
+ }
+ discoveredPath, err := handle.TranscriptPath(context.Background())
+ if err != nil {
+ t.Fatalf("TranscriptPath: %v", err)
+ }
+ if !sameCanonicalTestPath(discoveredPath, path) {
+ t.Fatalf("discovered transcript path = %q, want %q", discoveredPath, path)
+ }
+
+ surfaces := []struct {
+ name string
+ path string
+ handler http.Handler
+ }{
+ {name: "city-huma", path: cityURL(fs, "/session/") + info.ID + "/transcript", handler: h},
+ {name: "legacy", path: "/v0/session/" + info.ID + "/transcript", handler: srv.legacySessionHandler()},
+ }
+ directions := []struct {
+ name string
+ cursor string
+ wantOlder bool
+ wantNewer bool
+ }{
+ {name: "before", cursor: initial.Messages[2].UUID, wantNewer: true},
+ {name: "after", cursor: initial.Messages[1].UUID, wantOlder: true},
+ }
+ replacementLines := []string{
+ `{"role":"user","content":"replacement zero"}`,
+ `{"role":"assistant","content":"replacement one"}`,
+ `{"role":"user","content":"replacement two"}`,
+ }
+
+ for _, surface := range surfaces {
+ for _, format := range []string{"conversation", "raw", "structured"} {
+ for _, direction := range directions {
+ t.Run(surface.name+"/"+format+"/"+direction.name, func(t *testing.T) {
+ write(initialLines[1:]...)
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, surface.path+"?format="+format+"&"+direction.name+"="+direction.cursor, nil)
+ surface.handler.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("truncated transcript status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+ var response struct {
+ Pagination *sessionlog.PaginationInfo `json:"pagination"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+ t.Fatalf("decode truncated transcript: %v", err)
+ }
+ if response.Pagination == nil || response.Pagination.TotalMessageCount != 2 || response.Pagination.ReturnedMessageCount != 1 {
+ t.Fatalf("truncated pagination = %+v, want total=2 returned=1", response.Pagination)
+ }
+ if response.Pagination.HasOlderMessages != direction.wantOlder || response.Pagination.HasNewerMessages != direction.wantNewer {
+ t.Fatalf("truncated pagination flags = older:%t newer:%t, want older:%t newer:%t", response.Pagination.HasOlderMessages, response.Pagination.HasNewerMessages, direction.wantOlder, direction.wantNewer)
+ }
+
+ write(replacementLines...)
+ w = httptest.NewRecorder()
+ r = httptest.NewRequest(http.MethodGet, surface.path+"?format="+format+"&"+direction.name+"="+direction.cursor, nil)
+ surface.handler.ServeHTTP(w, r)
+ if w.Code != http.StatusConflict {
+ t.Fatalf("rewritten transcript status = %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
+ }
+ var problem struct {
+ Code string `json:"code"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&problem); err != nil {
+ t.Fatalf("decode rewritten transcript problem: %v", err)
+ }
+ if problem.Code != "transcript-cursor-invalidated" {
+ t.Fatalf("rewritten transcript problem code = %q, want transcript-cursor-invalidated", problem.Code)
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestHandleSessionTranscriptNoHistoryStillValidatesCursors(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{t.TempDir()}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Chat",
+ Command: "claude",
+ WorkDir: t.TempDir(),
+ Provider: "claude",
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ surfaces := []struct {
+ name string
+ path string
+ handler http.Handler
+ }{
+ {name: "city-huma", path: cityURL(fs, "/session/") + info.ID + "/transcript", handler: h},
+ {name: "legacy", path: "/v0/session/" + info.ID + "/transcript", handler: srv.legacySessionHandler()},
+ }
+ cases := []struct {
+ name string
+ query string
+ wantStatus int
+ }{
+ {name: "conflicting", query: "before=older&after=newer", wantStatus: http.StatusUnprocessableEntity},
+ {name: "missing-before", query: "before=missing", wantStatus: http.StatusConflict},
+ {name: "missing-after", query: "after=missing", wantStatus: http.StatusConflict},
+ }
+
+ for _, surface := range surfaces {
+ for _, format := range []string{"conversation", "raw", "structured"} {
+ for _, tc := range cases {
+ t.Run(surface.name+"/"+format+"/"+tc.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, surface.path+"?format="+format+"&"+tc.query, nil)
+ surface.handler.ServeHTTP(w, r)
+ if w.Code != tc.wantStatus {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, tc.wantStatus, w.Body.String())
+ }
+ if tc.wantStatus == http.StatusConflict {
+ var problem struct {
+ Code string `json:"code"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&problem); err != nil {
+ t.Fatalf("decode problem details: %v", err)
+ }
+ if problem.Code != "transcript-cursor-invalidated" {
+ t.Fatalf("problem code = %q, want transcript-cursor-invalidated", problem.Code)
+ }
+ }
+ })
+ }
+ }
}
}
@@ -5889,14 +6536,12 @@ func TestHandleSessionMessageRejectsClosedNamedSession(t *testing.T) {
req := newPostRequest(cityURL(fs, "/session/sky/messages"), strings.NewReader(`{"message":"hello"}`))
h.ServeHTTP(rec, req)
- if rec.Code != http.StatusAccepted {
- t.Fatalf("message status = %d, want %d; body: %s", rec.Code, http.StatusAccepted, rec.Body.String())
- }
-
- accepted := decodeAsyncAccepted(t, rec.Body)
- _, failure := waitForSessionMessageResult(t, fs.eventProv, accepted.RequestID)
- if failure == nil {
- t.Fatalf("expected session message to fail for closed session, got success")
+ // The deliverability gate rejects undeliverable targets synchronously
+ // now: a closed, non-configured session can never receive the message,
+ // so the caller gets 404 instead of a 202 whose failure surfaces only
+ // as an async event (the black-holed-delivery bug, 2026-07-18).
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("message status = %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
@@ -6766,6 +7411,100 @@ func TestHandleSessionStreamRawStallEmitsPendingEventOnCityRoute(t *testing.T) {
}
}
+func TestSessionStreamStructuredHistoryStallEmitsPending(t *testing.T) {
+ prevStallTimeout := sessionStreamPendingStallTimeout
+ sessionStreamPendingStallTimeout = 10 * time.Second
+ defer func() {
+ sessionStreamPendingStallTimeout = prevStallTimeout
+ }()
+
+ for _, route := range []struct {
+ name string
+ city bool
+ }{
+ {name: "legacy"},
+ {name: "huma-city", city: true},
+ } {
+ t.Run(route.name, func(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ srv.sessionLogSearchPaths = []string{searchBase}
+ var handler http.Handler = srv
+ if route.city {
+ handler = newTestCityHandlerWith(t, fs, srv)
+ }
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ resume := session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"1","parentUuid":"","type":"user","message":"{\"role\":\"user\",\"content\":\"hello\"}","timestamp":"2025-01-01T00:00:00Z"}`,
+ `{"uuid":"2","parentUuid":"1","type":"assistant","message":"{\"role\":\"assistant\",\"content\":\"world\"}","timestamp":"2025-01-01T00:00:01Z"}`,
+ )
+ fs.sp.SetPendingInteraction(info.SessionName, &runtime.PendingInteraction{
+ RequestID: "req-structured-1",
+ Kind: "approval",
+ Prompt: "Proceed?",
+ })
+
+ path := "/v0/session/" + info.ID + "/stream?format=structured"
+ if route.city {
+ path = cityURL(fs, "/session/") + info.ID + "/stream?format=structured"
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
+ rec := newSyncResponseRecorder()
+ done := make(chan struct{})
+ go func() {
+ handler.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ if body := waitForRecorderSubstring(t, rec, `"structured_messages"`, time.Second); !strings.Contains(body, `"operation":"snapshot"`) {
+ t.Fatalf("structured stream body missing initial history snapshot: %s", body)
+ }
+ _ = waitForRecorderSubstring(t, rec, "req-structured-1", time.Second)
+ fs.sp.SetPendingInteraction(info.SessionName, nil)
+ logPath := filepath.Join(searchBase, sessionlog.ProjectSlug(workDir), info.SessionKey+".jsonl")
+ logFile, openErr := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if openErr != nil {
+ t.Fatalf("open transcript for pending resolution: %v", openErr)
+ }
+ _, writeErr := fmt.Fprintln(logFile, `{"uuid":"3","parentUuid":"2","type":"user","message":"{\"role\":\"user\",\"content\":\"resolved\"}","timestamp":"2025-01-01T00:00:02Z"}`)
+ closeErr := logFile.Close()
+ if writeErr != nil {
+ t.Fatalf("append resolved transcript entry: %v", writeErr)
+ }
+ if closeErr != nil {
+ t.Fatalf("close resolved transcript entry: %v", closeErr)
+ }
+ body := waitForRecorderSubstring(t, rec, "event: pending_cleared", time.Second)
+ cancel()
+ <-done
+
+ if !strings.Contains(body, "event: pending") {
+ t.Fatalf("structured history stream missing pending SSE event: %s", body)
+ }
+ if !strings.Contains(body, "event: pending_cleared") {
+ t.Fatalf("structured history stream missing pending-cleared SSE event: %s", body)
+ }
+ if !strings.Contains(body, `"request_id":"req-structured-1"`) {
+ t.Fatalf("structured history stream pending-cleared event missing request ID: %s", body)
+ }
+ })
+ }
+}
+
func TestHandleSessionStreamRawRunningSessionWithoutTranscriptOpensImmediately(t *testing.T) {
fs := newSessionFakeState(t)
srv := New(fs)
@@ -7036,6 +7775,14 @@ func TestHandleSessionTranscriptRawIncludesAllTypes(t *testing.T) {
}
}
+func codexFixtureFilename(sessionKey string) string {
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" {
+ return "rollout-2026-05-02T00-00-00-test.jsonl"
+ }
+ return "rollout-2026-05-02T00-00-00-" + sessionKey + ".jsonl"
+}
+
func TestHandleSessionTranscriptRawIncludesCodexCustomToolCalls(t *testing.T) {
fs := newSessionFakeState(t)
searchBase := newHermeticCodexSessionSearchPath(t)
@@ -7063,9 +7810,10 @@ func TestHandleSessionTranscriptRawIncludesCodexCustomToolCalls(t *testing.T) {
codexPayload := strings.Join([]string{
fmt.Sprintf(`{"timestamp":"2025-01-01T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir),
`{"timestamp":"2025-01-01T00:00:04Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"call-edit","name":"apply_patch","input":"*** Begin Patch\n*** Update File: city.toml\n@@\n+# Created by Chris Sells\n [workspace]\n*** End Patch\n"}}`,
+ `{"timestamp":"2025-01-01T00:00:05Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call-edit","stdout":"Success. Updated the following files:\nM city.toml\n","stderr":"","success":true,"changes":{"city.toml":{"type":"update","unified_diff":"@@\n+# Created by Chris Sells\n [workspace]\n","move_path":null}},"status":"completed"}}`,
`{"timestamp":"2025-01-01T00:00:05Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call-edit","output":"{\"output\":\"Success. Updated the following files:\\nM city.toml\\n\"}"}}`,
}, "\n") + "\n"
- if err := os.WriteFile(filepath.Join(codexDir, "rollout-2026-05-02T00-00-00-test.jsonl"), []byte(codexPayload), 0o644); err != nil {
+ if err := os.WriteFile(filepath.Join(codexDir, codexFixtureFilename(info.SessionKey)), []byte(codexPayload), 0o644); err != nil {
t.Fatalf("WriteFile codex session: %v", err)
}
@@ -7100,7 +7848,119 @@ func TestHandleSessionTranscriptRawIncludesCodexCustomToolCalls(t *testing.T) {
}
}
-func TestHandleSessionTranscriptConversationIncludesCodexErrorFrame(t *testing.T) {
+func TestHandleSessionTranscriptStructuredIncludesCodexCustomToolBlocks(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ _ = h
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ resume := session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "codex", WorkDir: workDir, Provider: "codex", Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ codexDir := filepath.Join(searchBase, "2026", "05", "02")
+ if err := os.MkdirAll(codexDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll codex session dir: %v", err)
+ }
+ codexPayload := strings.Join([]string{
+ fmt.Sprintf(`{"timestamp":"2025-01-01T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir),
+ `{"timestamp":"2025-01-01T00:00:04Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"call-edit","name":"apply_patch","input":"*** Begin Patch\n*** Update File: city.toml\n@@\n+# Created by Chris Sells\n [workspace]\n*** End Patch\n"}}`,
+ `{"timestamp":"2025-01-01T00:00:05Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call-edit","stdout":"Success. Updated the following files:\nM city.toml\n","stderr":"","success":true,"changes":{"city.toml":{"type":"update","unified_diff":"@@\n+# Created by Chris Sells\n [workspace]\n","move_path":null}},"status":"completed"}}`,
+ `{"timestamp":"2025-01-01T00:00:05Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call-edit","output":"{\"output\":\"Success. Updated the following files:\\nM city.toml\\n\"}"}}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(filepath.Join(codexDir, codexFixtureFilename(info.SessionKey)), []byte(codexPayload), 0o644); err != nil {
+ t.Fatalf("WriteFile codex session: %v", err)
+ }
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ var resp sessionTranscriptGetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Format != "structured" {
+ t.Fatalf("Format = %q, want structured; body: %s", resp.Format, w.Body.String())
+ }
+ if resp.SchemaVersion == "" {
+ t.Fatalf("structured transcript missing schema_version: %+v", resp)
+ }
+ if resp.History == nil || resp.History.TranscriptStreamID == "" {
+ t.Fatalf("structured transcript missing history envelope: %+v", resp.History)
+ }
+ if len(structuredTranscriptMessages(resp)) != 2 {
+ t.Fatalf("got %d structured messages, want 2; body: %s", len(structuredTranscriptMessages(resp)), w.Body.String())
+ }
+ first := structuredTranscriptMessages(resp)[0]
+ if len(first.Blocks) != 1 || first.Blocks[0].Type != "tool_use" || first.Blocks[0].Name != "apply_patch" {
+ t.Fatalf("first structured message blocks = %+v, want apply_patch tool_use", first.Blocks)
+ }
+ if first.Blocks[0].Input == nil || first.Blocks[0].Input.Kind != "patch" {
+ t.Fatalf("tool input = %+v, want provider-neutral patch input", first.Blocks[0].Input)
+ }
+ if first.Blocks[0].Input.FilePath != "city.toml" {
+ t.Fatalf("tool input file_path = %q, want city.toml", first.Blocks[0].Input.FilePath)
+ }
+ if !strings.Contains(first.Blocks[0].Input.Patch, "Created by Chris Sells") {
+ t.Fatalf("tool input lost patch payload: %+v", first.Blocks[0].Input)
+ }
+ second := structuredTranscriptMessages(resp)[1]
+ if len(second.Blocks) != 1 || second.Blocks[0].Type != "tool_result" {
+ t.Fatalf("second structured message blocks = %+v, want tool_result", second.Blocks)
+ }
+ if !strings.Contains(second.Blocks[0].Content, "Success. Updated the following files") {
+ t.Fatalf("tool result lost output payload: %+v", second.Blocks[0].Content)
+ }
+ if second.Blocks[0].ToolCallID != "call-edit" {
+ t.Fatalf("tool result tool_call_id = %q, want call-edit", second.Blocks[0].ToolCallID)
+ }
+ if second.Blocks[0].Structured == nil || second.Blocks[0].Structured.Kind != "edit" {
+ t.Fatalf("tool result structured = %+v, want provider-neutral edit result", second.Blocks[0].Structured)
+ }
+ if second.Blocks[0].Structured.FilePath != "city.toml" {
+ t.Fatalf("tool result structured file_path = %q, want city.toml", second.Blocks[0].Structured.FilePath)
+ }
+ if !strings.Contains(second.Blocks[0].Structured.Patch, "Created by Chris Sells") {
+ t.Fatalf("tool result structured patch lost result-side diff: %+v", second.Blocks[0].Structured)
+ }
+ if len(second.Blocks[0].Structured.PatchHunks) != 1 {
+ t.Fatalf("tool result structured patch_hunks = %#v, want one hunk", second.Blocks[0].Structured.PatchHunks)
+ }
+ hunk := second.Blocks[0].Structured.PatchHunks[0]
+ if hunk.FilePath != "city.toml" || !stringSliceContains(hunk.Lines, "+# Created by Chris Sells") {
+ t.Fatalf("tool result structured patch_hunks[0] = %+v, want city.toml created-by hunk", hunk)
+ }
+ if !strings.Contains(second.Blocks[0].Structured.Content, "Success. Updated the following files") {
+ t.Fatalf("tool result structured content lost output payload: %+v", second.Blocks[0].Structured)
+ }
+ wire, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatalf("marshal structured response: %v", err)
+ }
+ if strings.Contains(string(wire), "tool_use_id") {
+ t.Fatalf("structured response leaked provider-specific tool_use_id key: %s", wire)
+ }
+ if !strings.Contains(string(wire), "tool_call_id") {
+ t.Fatalf("structured response missing provider-neutral tool_call_id key: %s", wire)
+ }
+}
+
+func TestHandleSessionTranscriptConversationIncludesCodexSystemError(t *testing.T) {
fs := newSessionFakeState(t)
searchBase := newHermeticCodexSessionSearchPath(t)
srv := New(fs)
@@ -7128,7 +7988,7 @@ func TestHandleSessionTranscriptConversationIncludesCodexErrorFrame(t *testing.T
fmt.Sprintf(`{"timestamp":"2025-01-01T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir),
`{"timestamp":"2025-01-01T00:00:04Z","type":"event_msg","payload":{"type":"error","message":"You've hit your usage limit.","codex_error_info":"usage_limit_exceeded"}}`,
}, "\n") + "\n"
- if err := os.WriteFile(filepath.Join(codexDir, "rollout-2026-05-02T00-00-00-test.jsonl"), []byte(codexPayload), 0o644); err != nil {
+ if err := os.WriteFile(filepath.Join(codexDir, codexFixtureFilename(info.SessionKey)), []byte(codexPayload), 0o644); err != nil {
t.Fatalf("WriteFile codex session: %v", err)
}
@@ -7153,12 +8013,12 @@ func TestHandleSessionTranscriptConversationIncludesCodexErrorFrame(t *testing.T
if resp.Turns[0].Role != "system" {
t.Fatalf("turn role = %q, want system", resp.Turns[0].Role)
}
- if !strings.Contains(resp.Turns[0].Text, "usage_limit_exceeded") || !strings.Contains(resp.Turns[0].Text, "You've hit your usage limit.") {
- t.Fatalf("turn text = %q, want Codex error code and message", resp.Turns[0].Text)
+ if resp.Turns[0].Text != "You've hit your usage limit." {
+ t.Fatalf("turn text = %q, want normalized Codex system error message", resp.Turns[0].Text)
}
}
-func TestHandleSessionStreamConversationIncludesCodexErrorFrame(t *testing.T) {
+func TestHandleSessionStreamConversationIncludesCodexSystemError(t *testing.T) {
fs := newSessionFakeState(t)
searchBase := newHermeticCodexSessionSearchPath(t)
srv := New(fs)
@@ -7184,7 +8044,7 @@ func TestHandleSessionStreamConversationIncludesCodexErrorFrame(t *testing.T) {
fmt.Sprintf(`{"timestamp":"2025-01-01T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir),
`{"timestamp":"2025-01-01T00:00:04Z","type":"event_msg","payload":{"type":"error","message":"You've hit your usage limit.","codex_error_info":"usage_limit_exceeded"}}`,
}, "\n") + "\n"
- if err := os.WriteFile(filepath.Join(codexDir, "rollout-2026-05-02T00-00-00-test.jsonl"), []byte(codexPayload), 0o644); err != nil {
+ if err := os.WriteFile(filepath.Join(codexDir, codexFixtureFilename(info.SessionKey)), []byte(codexPayload), 0o644); err != nil {
t.Fatalf("WriteFile codex session: %v", err)
}
@@ -7196,8 +8056,11 @@ func TestHandleSessionStreamConversationIncludesCodexErrorFrame(t *testing.T) {
srv.ServeHTTP(rec, req)
body := rec.Body.String()
- if !strings.Contains(body, "usage_limit_exceeded") || !strings.Contains(body, "You've hit your usage limit.") {
- t.Fatalf("conversation stream body missing Codex error frame: %s", body)
+ if !strings.Contains(body, "You've hit your usage limit.") {
+ t.Fatalf("conversation stream body missing Codex system error: %s", body)
+ }
+ if strings.Contains(body, "codex_error_info") || strings.Contains(body, "event_msg") {
+ t.Fatalf("conversation stream leaked provider-native Codex error fields: %s", body)
}
}
@@ -7561,3 +8424,63 @@ func TestHandleSessionMessageQueuesWhenSuspended(t *testing.T) {
t.Fatalf("session message failed: %s: %s", failure.ErrorCode, failure.ErrorMessage)
}
}
+
+// The async command surfaces must refuse targets that can never deliver —
+// BEFORE returning 202. A typo'd session name used to be accepted with a
+// request_id while the message silently black-holed (the failure surfaced
+// only as an event nobody correlated; 2026-07-18: drifted Slack bindings
+// dropped cross-city wakes for days on exactly this).
+func TestSessionMessageAndSubmitRejectNonexistentTargetSynchronously(t *testing.T) {
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+
+ for _, path := range []string{"/session/no-such-session-xyz/messages", "/session/no-such-session-xyz/submit"} {
+ rec := httptest.NewRecorder()
+ req := newPostRequest(cityURL(fs, path), strings.NewReader(`{"message":"hello"}`))
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("%s status = %d, want 404; body=%s", path, rec.Code, rec.Body.String())
+ }
+ }
+
+ // A real live session still gets the async 202 accept.
+ info := createTestSession(t, fs.cityBeadStore, fs.sp, "Live")
+ rec := httptest.NewRecorder()
+ req := newPostRequest(cityURL(fs, "/session/")+info.ID+"/messages", strings.NewReader(`{"message":"hello"}`))
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("live session message status = %d, want 202; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestSessionMessageAndSubmitRejectAmbiguousTargetWith409 pins the error
+// contract of the deliverability gate: an ambiguous bare target (one that
+// matches multiple live sessions) is a deterministic client addressing error,
+// so the async message/submit surfaces must reject it synchronously with 409 --
+// matching /stop, /respond, and the synchronous message twin -- not the 500
+// that humaStoreError produced before the gate routed through humaResolveError.
+func TestSessionMessageAndSubmitRejectAmbiguousTargetWith409(t *testing.T) {
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+
+ // Two open live sessions share the bare alias "dup-target", so resolving it
+ // yields session.ErrAmbiguous rather than not-found.
+ for _, name := range []string{"s-dup-a", "s-dup-b"} {
+ createTestSessionBead(t, fs.cityBeadStore, map[string]string{
+ "session_name": name,
+ "alias": "dup-target",
+ "state": "active",
+ }, "")
+ }
+
+ for _, path := range []string{"/session/dup-target/messages", "/session/dup-target/submit"} {
+ rec := httptest.NewRecorder()
+ req := newPostRequest(cityURL(fs, path), strings.NewReader(`{"message":"hello"}`))
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("%s status = %d, want %d (409 for ambiguous target); body=%s", path, rec.Code, http.StatusConflict, rec.Body.String())
+ }
+ }
+}
diff --git a/internal/api/handler_sling.go b/internal/api/handler_sling.go
index 218f3bc6a3..e34c049134 100644
--- a/internal/api/handler_sling.go
+++ b/internal/api/handler_sling.go
@@ -33,6 +33,24 @@ type slingBody struct {
ScopeKind string `json:"scope_kind"`
ScopeRef string `json:"scope_ref"`
Force bool `json:"force"`
+ Reassign bool `json:"reassign"`
+ Merge string `json:"merge"`
+ NoConvoy bool `json:"no_convoy"`
+ Owned bool `json:"owned"`
+ NoFormula bool `json:"no_formula"`
+}
+
+// routeOptsFromBody builds the domain RouteOpts from the wire body for a plain
+// bead route (direct or default-formula), carrying every server-expressible flag.
+func routeOptsFromBody(body slingBody) sling.RouteOpts {
+ return sling.RouteOpts{
+ Force: body.Force,
+ Reassign: body.Reassign,
+ Merge: body.Merge,
+ NoConvoy: body.NoConvoy,
+ Owned: body.Owned,
+ NoFormula: body.NoFormula,
+ }
}
type slingResponse struct {
@@ -84,6 +102,19 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
message := fmt.Sprintf("bead prefix store %s is not registered; cannot verify bead %q", storeRef, storeBeadID)
return nil, http.StatusBadRequest, "missing_bead", message, nil
}
+ // Mirror the CLI's tolerant source-workflow scan: a non-source rig store
+ // whose live-root scan fails degrades to an operator-visible warning
+ // instead of aborting the sling. Without this sink the domain keeps every
+ // non-source scan failure fatal (internal/sling/sling_core.go), so a single
+ // schema-skewed rig store would abort every workflow-launching sling that
+ // routes through a running city. Dedup per store ref so one degraded rig
+ // warns once per request, and collect the ordered messages so they surface
+ // to the caller in the response `warnings` field, not only the server log: a
+ // running-city sling routes through this handler, so the invoking human or
+ // agent sees only the JSON response and would otherwise be blind to the
+ // degraded cross-store conflict coverage.
+ sourceWorkflowScanWarnings := make(map[string]struct{})
+ var sourceWorkflowScanMessages []string
deps := sling.SlingDeps{
CityName: s.state.CityName(),
CityPath: s.state.CityPath(),
@@ -94,6 +125,18 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
SourceWorkflowStores: func() ([]sling.SourceWorkflowStore, error) {
return s.sourceWorkflowStores(), nil
},
+ SourceWorkflowStoreScanWarning: func(scanStoreRef string, scanErr error) {
+ key := strings.TrimSpace(scanStoreRef)
+ if _, warned := sourceWorkflowScanWarnings[key]; warned {
+ return
+ }
+ sourceWorkflowScanWarnings[key] = struct{}{}
+ message := fmt.Sprintf(
+ "source-workflow singleton scan skipped unavailable store %s (%v); cross-store roots in that store are invisible",
+ scanStoreRef, scanErr)
+ sourceWorkflowScanMessages = append(sourceWorkflowScanMessages, message)
+ fmt.Fprintf(apiSlingStderr(), "warning: %s\n", message) //nolint:errcheck
+ },
Runner: s.slingRunner(),
Router: apiBeadRouter{server: s, store: store},
Resolver: apiAgentResolver{},
@@ -127,6 +170,10 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
ScopeKind: body.ScopeKind,
ScopeRef: body.ScopeRef,
Force: body.Force,
+ Reassign: body.Reassign,
+ Merge: body.Merge,
+ NoConvoy: body.NoConvoy,
+ Owned: body.Owned,
}
// Dispatch to the right intent-based method.
@@ -146,6 +193,7 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
result, err = sl.LaunchFormula(ctx, formulaName, agentCfg, formulaOpts)
case strings.TrimSpace(body.Bead) != "" &&
+ !body.NoFormula &&
agentCfg.EffectiveDefaultSlingFormula() != "" &&
(len(body.Vars) > 0 || body.Title != "" || body.ScopeKind != "" || body.ScopeRef != ""):
mode = "attached"
@@ -153,10 +201,10 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
attachedBeadID = strings.TrimSpace(body.Bead)
formulaName = agentCfg.EffectiveDefaultSlingFormula()
// Default formula: route the bead and let the domain apply the default.
- result, err = sl.RouteBead(ctx, attachedBeadID, agentCfg, sling.RouteOpts{Force: body.Force})
+ result, err = sl.RouteBead(ctx, attachedBeadID, agentCfg, routeOptsFromBody(body))
default:
- result, err = sl.RouteBead(ctx, body.Bead, agentCfg, sling.RouteOpts{Force: body.Force})
+ result, err = sl.RouteBead(ctx, body.Bead, agentCfg, routeOptsFromBody(body))
}
if err != nil {
@@ -184,12 +232,20 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
return nil, http.StatusBadRequest, "invalid", err.Error(), nil
}
+ // Surface both the domain's non-fatal metadata errors and the tolerated
+ // source-workflow scan warnings to the caller. The scan messages reach only
+ // the server log otherwise, leaving a remote caller blind to degraded
+ // cross-store conflict coverage.
+ warnings := result.MetadataErrors
+ if len(sourceWorkflowScanMessages) > 0 {
+ warnings = append(append([]string(nil), result.MetadataErrors...), sourceWorkflowScanMessages...)
+ }
resp := &slingResponse{
Status: "slung",
Target: body.Target,
Bead: body.Bead,
Mode: mode,
- Warnings: result.MetadataErrors,
+ Warnings: warnings,
}
if !workflowLaunch {
return resp, http.StatusOK, "", "", nil
diff --git a/internal/api/handler_sling_test.go b/internal/api/handler_sling_test.go
index fc55b84392..71e9aa7587 100644
--- a/internal/api/handler_sling_test.go
+++ b/internal/api/handler_sling_test.go
@@ -717,6 +717,117 @@ title = "Do work"
}
}
+// listFailBeadStore fails every List call, modeling a schema-skewed rig store
+// whose live-root scan errors lazily — the exact failure the tolerant
+// source-workflow scan is meant to survive.
+type listFailBeadStore struct {
+ beads.Store
+ err error
+}
+
+func (s listFailBeadStore) List(beads.ListQuery) ([]beads.Bead, error) {
+ return nil, s.err
+}
+
+// TestSlingToleratesDegradedNonSourceStoreScan proves the API sling path wires
+// SourceWorkflowStoreScanWarning so a running city's sling degrades a
+// non-source rig store's failed live-root scan to a warning instead of aborting
+// the launch. The domain keeps every non-source scan failure fatal when the
+// sink is nil (internal/sling/sling_core.go), so before the fix this graph.v2
+// launch failed on the skewed "rig:stale" store — the dominant production path,
+// since a running city routes `gc sling` through this API handler rather than
+// the CLI's local sling.
+func TestSlingToleratesDegradedNonSourceStoreScan(t *testing.T) {
+ // Same compile-time flag choreography as
+ // TestSlingGraphV2RejectsLegacySourceWorkflowConflict: flip the shared
+ // FormulaV2 + graph-apply flags only after New() has run so syncFeatureFlags
+ // cannot stomp them back.
+ setFormulaV2 := formulatest.LockV2ForTest(t)
+ prevGraphApply := molecule.IsGraphApplyEnabled()
+ t.Cleanup(func() {
+ molecule.SetGraphApplyEnabled(prevGraphApply)
+ })
+
+ var capturedStderr bytes.Buffer
+ origStderr := apiSlingStderr
+ apiSlingStderr = func() io.Writer { return &capturedStderr }
+ t.Cleanup(func() { apiSlingStderr = origStderr })
+
+ srv, state := newSlingTestServer(t)
+ setFormulaV2(true)
+ molecule.SetGraphApplyEnabled(true)
+ formulaDir := t.TempDir()
+ state.cfg.FormulaLayers.City = []string{formulaDir}
+ state.cfg.Agents = append(state.cfg.Agents,
+ config.Agent{Name: config.ControlDispatcherAgentName, MaxActiveSessions: intPtr(1)},
+ config.Agent{Name: config.ControlDispatcherAgentName, Dir: "myrig", MaxActiveSessions: intPtr(1)},
+ )
+ if err := os.WriteFile(filepath.Join(formulaDir, "graph-work.toml"), []byte(`
+formula = "graph-work"
+version = 2
+contract = "graph.v2"
+
+[[steps]]
+id = "step"
+title = "Do work"
+`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ store := state.stores["myrig"]
+ source, err := store.Create(beads.Bead{ID: "BL-42", Title: "test task", Type: "task", Status: "open"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A second, schema-skewed rig store that fails its live-root scan. It is not
+ // the selected source store (rig:myrig holds the source bead), so a wired
+ // sink must skip it with a warning rather than abort the singleton check.
+ scanErr := errors.New("schema v54 has no revision")
+ state.stores["stale"] = listFailBeadStore{Store: beads.NewMemStore(), err: scanErr}
+
+ body := `{"target":"myrig/worker","formula":"graph-work","attached_bead_id":"` + source.ID + `"}`
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, newPostRequest(cityURL(state, "/sling"), strings.NewReader(body)))
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (a degraded non-source store must not abort the sling); body = %s", rec.Code, rec.Body.String())
+ }
+ var resp struct {
+ Status string `json:"status"`
+ WorkflowID string `json:"workflow_id"`
+ Warnings []string `json:"warnings"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Status != "slung" {
+ t.Fatalf("status = %q, want slung", resp.Status)
+ }
+ if resp.WorkflowID == "" {
+ t.Fatal("workflow_id empty, want graph.v2 launch to mint a run root despite the degraded store")
+ }
+ warning := capturedStderr.String()
+ if !strings.Contains(warning, "rig:stale") || !strings.Contains(warning, "revision") {
+ t.Fatalf("scan warning = %q, want an operator warning naming the skipped rig:stale store and its scan error", warning)
+ }
+ // The degraded-scan warning must also reach the API caller through the
+ // response `warnings` field, not only the server log: a running city routes
+ // `gc sling` through this handler, so the invoking human/agent sees only the
+ // JSON response and would otherwise be blind to the coverage degradation.
+ var respWarning string
+ for _, w := range resp.Warnings {
+ if strings.Contains(w, "rig:stale") {
+ respWarning = w
+ break
+ }
+ }
+ if respWarning == "" {
+ t.Fatalf("response warnings = %v, want an entry naming the skipped rig:stale store", resp.Warnings)
+ }
+ if !strings.Contains(respWarning, "revision") {
+ t.Fatalf("response warning = %q, want it to name the rig:stale scan error", respWarning)
+ }
+}
+
// TestQualifySlingTarget covers the rig-aware target qualification
// helper. Given a rigContext (derived from scope_ref for UI dispatches
// or body.Rig for dashboard dispatches), the helper rewrites a bare
diff --git a/internal/api/handler_status.go b/internal/api/handler_status.go
index 5fba715f4b..a22d5805eb 100644
--- a/internal/api/handler_status.go
+++ b/internal/api/handler_status.go
@@ -35,6 +35,15 @@ var statusStoreReadTimeout = time.Second
// work, by the status endpoint's work-count buckets.
var statusWorkExcludedTypes = []string{"message", "convoy", "convergence"}
+type statusPartialReporter interface {
+ StatusPartial() bool
+}
+
+func statusProviderPartial(sp any) bool {
+ reporter, ok := sp.(statusPartialReporter)
+ return ok && reporter.StatusPartial()
+}
+
// StatusInput is the Huma input for GET /v0/status.
type StatusInput struct {
CityScope
@@ -219,6 +228,10 @@ func (s *Server) buildStatusBody(ctx context.Context, lite bool) StatusBody {
}
}
+ if statusProviderPartial(sp) {
+ partialErrors = append(partialErrors, "runtime status probe incomplete; non-running agent rows are unknown")
+ }
+
// Count rigs by state + collect per-rig detail rows.
rc := rigCounts{Total: len(cfg.Rigs)}
rigDetails := make([]StatusRigDetail, 0, len(cfg.Rigs))
@@ -292,11 +305,15 @@ func (s *Server) buildStatusBody(ctx context.Context, lite bool) StatusBody {
uptime := int(time.Since(s.state.StartedAt()).Seconds())
versions := s.resolveComponentVersions()
- // StoreHealth carries a full closed-history Dolt row scan (behind a 30s
+ // StoreHealth carries a full closed-history Dolt row scan (behind its
// sub-cache). Omitted in lite mode so a cold lite poll never triggers it.
var storeHealth *StatusStoreHealth
if !lite {
- storeHealth = s.cachedStoreHealth(ctx, time.Now())
+ var err error
+ storeHealth, err = s.cachedStoreHealth(ctx, time.Now())
+ if err != nil {
+ partialErrors = append(partialErrors, fmt.Sprintf("store health: %v", err))
+ }
}
return StatusBody{
diff --git a/internal/api/handler_status_count_test.go b/internal/api/handler_status_count_test.go
index 7da156a04d..76fb7553bb 100644
--- a/internal/api/handler_status_count_test.go
+++ b/internal/api/handler_status_count_test.go
@@ -167,6 +167,7 @@ func TestHandleStatusWorkCountsUseCounterStores(t *testing.T) {
listForbidden: true,
}
state.stores["myrig"] = counter
+ state.cityBeadStore = store
resp := getStatus(t, state)
@@ -195,6 +196,7 @@ func TestHandleStatusCounterUnsupportedFallsBackToList(t *testing.T) {
t: t,
countErr: beads.ErrCountUnsupported,
}
+ state.cityBeadStore = mem
resp := getStatus(t, state)
diff --git a/internal/api/handler_status_test.go b/internal/api/handler_status_test.go
index 392411aec5..48995102e3 100644
--- a/internal/api/handler_status_test.go
+++ b/internal/api/handler_status_test.go
@@ -177,6 +177,36 @@ func TestHandleStatusPreservesStoredCountsWhenReadyFails(t *testing.T) {
}
}
+type partialStatusRuntimeProvider struct {
+ runtime.Provider
+}
+
+func (partialStatusRuntimeProvider) StatusPartial() bool { return true }
+
+func TestHandleStatusMarksRuntimeProbePartial(t *testing.T) {
+ state := newFakeState(t)
+ state.sessionProvider = partialStatusRuntimeProvider{Provider: state.sp}
+ h := newTestCityHandler(t, state)
+
+ req := httptest.NewRequest("GET", cityURL(state, "/status"), nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ var resp statusResponse
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if !resp.Partial {
+ t.Fatalf("Partial = false, want true when runtime provider reports partial status")
+ }
+ if !statusPartialErrorsContain(resp.PartialErrors, "runtime status probe incomplete") {
+ t.Fatalf("PartialErrors = %#v, want runtime partial diagnostic", resp.PartialErrors)
+ }
+}
+
func TestHandleHealth(t *testing.T) {
state := newFakeState(t)
h := newTestCityHandler(t, state)
diff --git a/internal/api/handler_usage.go b/internal/api/handler_usage.go
index 8e0e999173..89ca8552c6 100644
--- a/internal/api/handler_usage.go
+++ b/internal/api/handler_usage.go
@@ -63,14 +63,18 @@ const (
UsageSourceUnavailable UsageSource = "unavailable"
)
-// UsageBody is the bounded city telemetry returned by GET /usage. Today and
-// recent are exact when Partial is false and lower-bound observations when it
-// is true.
+// UsageBody is the bounded city telemetry returned by GET /usage. Today,
+// last_24h, and recent are exact when Partial is false and lower-bound
+// observations when it is true. Last24H is a pointer so the contract stays
+// forward/backward compatible: a server or proxy that predates the field omits
+// it entirely rather than sending a zeroed aggregate, and consumers treat its
+// absence as "unavailable".
type UsageBody struct {
Available bool `json:"available" doc:"True when this city is configured to record local usage estimates."`
Recording bool `json:"recording" doc:"True when new facts are currently being written to the local estimate log."`
Source UsageSource `json:"source" enum:"local_estimate,unavailable" doc:"Source of this usage reading."`
Today UsageTotals `json:"today" doc:"Usage since local midnight on the supervisor host."`
+ Last24H *UsageTotals `json:"last_24h,omitempty" doc:"Usage over the trailing 24 hours; a rolling window that survives the local-midnight reset of today. Omitted by servers or proxies that predate the field."`
Recent UsageTotals `json:"recent" doc:"Usage in the trailing recent window."`
RecentBySession []UsageSessionRecent `json:"recent_by_session,omitempty" doc:"Recent model usage per session, largest token volume first."`
RecentWindowSecs int `json:"recent_window_secs" doc:"Length of the recent window in seconds."`
@@ -116,6 +120,7 @@ func usageResponse(body UsageBody, aggregateOnly bool) UsageBody {
func buildUsageBody(facts []usage.Fact, report usage.RecentReadReport, now time.Time) UsageBody {
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ last24hFrom := now.Add(-24 * time.Hour)
recentFrom := now.Add(-usageRecentWindow)
body := UsageBody{
Available: true,
@@ -144,7 +149,7 @@ func buildUsageBody(facts []usage.Fact, report usage.RecentReadReport, now time.
totals usage.Totals
}
bySession := make(map[string]*sessionAccum)
- var today, recent usage.Totals
+ var today, last24h, recent usage.Totals
var oldest time.Time
invalid := 0
for _, fact := range facts {
@@ -156,6 +161,14 @@ func buildUsageBody(facts []usage.Fact, report usage.RecentReadReport, now time.
if oldest.IsZero() || at.Before(oldest) {
oldest = at
}
+ // today is usually a subset of last_24h, but not always: on a 25-hour
+ // DST fall-back civil day now-midnight can exceed 24h, so a fact just
+ // after midnight can land outside the trailing-24h window. Each window is
+ // gated independently, so both fold correctly in this single pass over the
+ // facts regardless — no second scan, no reliance on the subset property.
+ if !at.Before(last24hFrom) && !at.After(now) {
+ last24h.Add(fact)
+ }
if !at.Before(midnight) && !at.After(now) {
today.Add(fact)
}
@@ -186,6 +199,8 @@ func buildUsageBody(facts []usage.Fact, report usage.RecentReadReport, now time.
body.ObservedFrom = oldest.UTC().Format(time.RFC3339Nano)
}
body.Today = usageTotalsBody(today)
+ l24 := usageTotalsBody(last24h)
+ body.Last24H = &l24
body.Recent = usageTotalsBody(recent)
for _, acc := range bySession {
body.RecentBySession = append(body.RecentBySession, UsageSessionRecent{
diff --git a/internal/api/handler_usage_test.go b/internal/api/handler_usage_test.go
index d7294c7ba4..236c257452 100644
--- a/internal/api/handler_usage_test.go
+++ b/internal/api/handler_usage_test.go
@@ -46,6 +46,14 @@ func TestBuildUsageBodyPreservesWindowAndPricingProvenance(t *testing.T) {
if body.Today.InputTokens != 30 || body.Recent.InputTokens != 20 {
t.Fatalf("today/recent input = %d/%d, want 30/20", body.Today.InputTokens, body.Recent.InputTokens)
}
+ // The pre-midnight "yesterday" fact is outside today but still inside the
+ // trailing 24h window (now is noon), so last_24h is a strict superset of today.
+ if body.Last24H == nil {
+ t.Fatal("last_24h aggregate is nil; a live reading must always populate it")
+ }
+ if body.Last24H.InputTokens != 129 {
+ t.Fatalf("last_24h input = %d, want 129 (today 30 + pre-midnight 99)", body.Last24H.InputTokens)
+ }
if body.Today.Unpriced != 1 || body.Today.CostUSDEstimate != 0.25 {
t.Fatalf("pricing provenance = %+v", body.Today)
}
@@ -62,6 +70,54 @@ func TestBuildUsageBodyPreservesWindowAndPricingProvenance(t *testing.T) {
}
}
+func TestBuildUsageBodyLast24HIsTodaySupersetIncludingPreMidnight(t *testing.T) {
+ now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.FixedZone("test", -7*60*60))
+ midnight := time.Date(2026, 7, 14, 0, 0, 0, 0, now.Location())
+ facts := []usage.Fact{
+ // Inside the trailing 24h but before local midnight: last_24h only, never today.
+ {Kind: usage.KindModel, InputTokens: 100, OutputTokens: 40, CostUSDEstimate: 1.50, At: midnight.Add(-2 * time.Hour).UnixMilli(), IdempotencyKey: "pre-midnight-priced"},
+ // Pre-midnight, inside 24h, unpriced: token volume + unpriced provenance, no cost.
+ {Kind: usage.KindModel, InputTokens: 5, Unpriced: true, At: midnight.Add(-3 * time.Hour).UnixMilli(), IdempotencyKey: "pre-midnight-unpriced"},
+ // After midnight (today): counted in both windows.
+ {Kind: usage.KindModel, InputTokens: 10, OutputTokens: 2, CostUSDEstimate: 0.25, At: now.Add(-time.Minute).UnixMilli(), IdempotencyKey: "today"},
+ // Older than 24h: outside every window (valid, just stale).
+ {Kind: usage.KindModel, InputTokens: 9999, OutputTokens: 9999, CostUSDEstimate: 9.99, At: now.Add(-25 * time.Hour).UnixMilli(), IdempotencyKey: "older-than-24h"},
+ }
+
+ body := buildUsageBody(facts, usage.RecentReadReport{}, now)
+
+ // today sees only the post-midnight fact — this is the amnesiac surface the bug is about.
+ if body.Today.InputTokens != 10 || body.Today.OutputTokens != 2 || body.Today.Invocations != 1 {
+ t.Fatalf("today = %+v, want only the post-midnight fact (in=10 out=2 calls=1)", body.Today)
+ }
+ if body.Today.Unpriced != 0 || body.Today.CostUSDEstimate != 0.25 {
+ t.Fatalf("today pricing = cost %v unpriced %d, want 0.25/0", body.Today.CostUSDEstimate, body.Today.Unpriced)
+ }
+ // A live reading always populates the pointer; only pre-field servers omit it.
+ if body.Last24H == nil {
+ t.Fatal("last_24h aggregate is nil; a live reading must always populate it")
+ }
+ last24h := *body.Last24H
+ // last_24h is a strict superset of today: both pre-midnight facts plus today,
+ // dropping only the 25h-old fact.
+ if last24h.InputTokens != 115 || last24h.OutputTokens != 42 {
+ t.Fatalf("last_24h tokens = in %d/out %d, want 115/42", last24h.InputTokens, last24h.OutputTokens)
+ }
+ if last24h.Invocations != 3 {
+ t.Fatalf("last_24h invocations = %d, want 3", last24h.Invocations)
+ }
+ if last24h.Unpriced != 1 {
+ t.Fatalf("last_24h unpriced = %d, want 1 (the pre-midnight unpriced fact)", last24h.Unpriced)
+ }
+ if last24h.CostUSDEstimate != 1.75 {
+ t.Fatalf("last_24h cost = %v, want 1.75 (1.50 pre-midnight + 0.25 today; unpriced adds none)", last24h.CostUSDEstimate)
+ }
+ // The rate window is unchanged: only the fact inside the 5-minute recent window.
+ if body.Recent.InputTokens != 10 || body.Recent.Invocations != 1 {
+ t.Fatalf("recent = %+v, want only the fact inside the 5m window", body.Recent)
+ }
+}
+
func TestBuildUsageBodySkipsInvalidFactsAndKeepsSessionIDsDistinct(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
facts := []usage.Fact{
@@ -110,6 +166,9 @@ func TestHandleUsageIsRegisteredAndReturnsSanitizedAggregate(t *testing.T) {
if body.Today.InputTokens != 100 || body.Recent.InputTokens != 100 {
t.Fatalf("body = %+v", body)
}
+ if body.Last24H == nil || body.Last24H.InputTokens != 100 {
+ t.Fatalf("last_24h did not survive the HTTP projection: %+v", body.Last24H)
+ }
if len(body.RecentBySession) != 1 || body.RecentBySession[0].SessionID != "session-1" {
t.Fatalf("default usage response lost its session breakdown: %+v", body.RecentBySession)
}
diff --git a/internal/api/huma_handlers_events.go b/internal/api/huma_handlers_events.go
index 6591613d62..2158dc1285 100644
--- a/internal/api/huma_handlers_events.go
+++ b/internal/api/huma_handlers_events.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
+ "math"
"strings"
"time"
@@ -15,7 +16,9 @@ import (
const eventRotateWaitTimeout = 30 * time.Second
-// humaHandleEventList is the Huma-typed handler for GET /v0/events.
+// humaHandleEventList is the Huma-typed handler for
+// GET /v0/city/{cityName}/events (the supervisor /v0/events list is a
+// separate handler on SupervisorMux).
func (s *Server) humaHandleEventList(ctx context.Context, input *EventListInput) (*ListOutput[WireEvent], error) {
bp := input.toBlockingParams()
if bp.isBlocking() {
@@ -40,10 +43,7 @@ func (s *Server) humaHandleEventList(ctx context.Context, input *EventListInput)
filter.Since = time.Now().Add(-d)
}
- // Resolve the effective limit first so we can decide between the
- // bounded tail path (fast) and the full-scan pagination path (slow
- // but needed when the caller walks offsets with cursors).
- limit := 100
+ limit := defaultPaginationLimit
if input.Limit > 0 {
limit = input.Limit
}
@@ -51,91 +51,149 @@ func (s *Server) humaHandleEventList(ctx context.Context, input *EventListInput)
limit = maxPaginationLimit
}
- index := s.latestIndex()
-
- // Fast path: no cursor → most clients just want the N newest events.
- // Use ListTail when the provider supports it so we don't parse the
- // entire events.jsonl (which is O(file size), ~4s on 100 MB) just to
- // throw away all but the tail. Same pattern as the supervisor
- // handler's optimizedTail branch.
- if input.Cursor == "" {
- if tp, ok := ep.(events.TailProvider); ok {
- evts, err := tp.ListTail(filter, limit)
- if err != nil {
- return nil, apierr.Internal.Msg(err.Error())
- }
- wires := toWireEvents(evts)
- // Total is best-effort here: when the caller narrowed with
- // Type/Actor/Since we cannot cheaply compute the full match
- // count, so report the returned slice length. When the
- // filter is empty, LatestSeq is authoritative since the log
- // is append-only and gap-free.
- total := len(wires)
- if filterIsEmpty(filter) {
- if seq, seqErr := ep.LatestSeq(); seqErr == nil {
- total = int(seq)
- }
- }
- return &ListOutput[WireEvent]{
- Index: index,
- Body: ListBody[WireEvent]{Items: wires, Total: total},
- }, nil
- }
+ // One order, both paths: seq DESC (newest first). The cursor is a v1
+ // sq-kind keyset token carrying the seq of the last row served; the next
+ // page is the events strictly below that boundary. The old contract had a
+ // window flip — no cursor returned the newest-N while any cursor walked
+ // oldest-first from the head — which made walking history coherently
+ // impossible. Anything other than a valid sq token is a typed 400.
+ beforeSeq, err := parseEventBeforeSeq(input.Cursor)
+ if err != nil {
+ return nil, err
}
- // Cursor pagination (or provider without TailProvider): we still
- // need the full materialized list to honor offset-based cursors.
- // Cap the scan at (offset+limit) matching events so this path is
- // bounded by caller pagination depth rather than file size.
- scanLimit := limit
- if input.Cursor != "" {
- scanLimit = decodeCursor(input.Cursor) + limit
- }
- filter.Limit = scanLimit
+ index := s.latestIndex()
- evts, err := ep.List(filter)
+ // Fetch limit+1 matching events at (first page) or strictly below (cursor
+ // page) the boundary, ascending; the extra row is the has-more signal.
+ scanFilter := filter
+ scanFilter.BeforeSeq = beforeSeq
+ evts, scanned, err := fetchEventPageAscending(ep, scanFilter, limit)
if err != nil {
return nil, apierr.Internal.Msg(err.Error())
}
- wires := toWireEvents(evts)
- if input.Cursor != "" {
- pp := pageParams{
- Offset: decodeCursor(input.Cursor),
- Limit: limit,
+ // evts is ascending; the overfetched row (the oldest) signals more below.
+ hasMore := false
+ if len(evts) > limit {
+ hasMore = true
+ evts = evts[len(evts)-limit:]
+ }
+
+ // Reverse into seq DESC while projecting to the wire shape.
+ wires := make([]WireEvent, 0, len(evts))
+ for i := len(evts) - 1; i >= 0; i-- {
+ w, ok := toWireEvent(evts[i])
+ if !ok {
+ continue
}
- page, total, nextCursor := paginate(wires, pp)
- if page == nil {
- page = []WireEvent{}
+ wires = append(wires, w)
+ }
+
+ // Total: authoritative for unfiltered reads (the log is append-only and
+ // gap-free, so LatestSeq counts every event and stays constant across a
+ // walk). Filtered reads report a best-effort count — the matching rows
+ // this request's scan could see.
+ total := scanned
+ if filterIsEmpty(filter) {
+ if seq, seqErr := ep.LatestSeq(); seqErr == nil {
+ // LatestSeq is a uint64 counter; bound it before the int narrowing so
+ // a value past the platform int range can't wrap to a negative or
+ // truncated total (CodeQL go/incorrect-integer-conversion).
+ if seq > uint64(math.MaxInt) {
+ total = math.MaxInt
+ } else {
+ total = int(seq)
+ }
}
- return &ListOutput[WireEvent]{
- Index: index,
- Body: ListBody[WireEvent]{Items: page, Total: total, NextCursor: nextCursor},
- }, nil
}
- // Capture the full match count BEFORE truncating so clients can tell
- // how many items match vs. fit the page.
- total := len(wires)
- if limit < len(wires) {
- wires = wires[:limit]
+ // Mint the boundary from the page's oldest fetched EVENT, not the last
+ // wire row: toWireEvent drops corrupt-payload rows (logged above), and a
+ // page whose whole window is corrupt would otherwise return no cursor and
+ // silently strand the rest of the walk. Anchoring on evts guarantees
+ // exactly `limit` seqs of progress per page regardless of projection
+ // failures — corrupt rows are skipped, never re-fetched, never wedge.
+ var nextCursor string
+ if hasMore {
+ nextCursor = encodeKeysetCursor(keysetCursor{
+ Kind: cursorKindSeq,
+ Seq: evts[0].Seq,
+ })
}
return &ListOutput[WireEvent]{
Index: index,
- Body: ListBody[WireEvent]{Items: wires, Total: total},
+ Body: ListBody[WireEvent]{Items: wires, Total: total, NextCursor: nextCursor},
}, nil
}
-func toWireEvents(evts []events.Event) []WireEvent {
- wires := make([]WireEvent, 0, len(evts))
- for _, e := range evts {
- w, ok := toWireEvent(e)
- if !ok {
- continue
+// parseEventBeforeSeq decodes the pagination cursor into a keyset seq boundary.
+// An empty cursor is the first page (boundary 0 = "no boundary"). Anything that
+// is not a valid sq-kind token with a non-zero seq rejects with a typed 400:
+// legacy offset tokens, wrong-kind (cb) tokens, and a crafted s:0. Seq 0 is
+// never minted (seqs start at 1) and 0 means "first page" here, so echoing an
+// s:0 token back would serve a cursor-following client the first page forever.
+func parseEventBeforeSeq(cursor string) (uint64, error) {
+ if cursor == "" {
+ return 0, nil
+ }
+ c, err := decodeKeysetCursor(cursor)
+ if err != nil || c.Kind != cursorKindSeq || c.Seq == 0 {
+ return 0, apierr.InvalidCursor.Msg("cursor is not a valid pagination token; re-fetch the first page")
+ }
+ return c.Seq, nil
+}
+
+// fetchEventPageAscending fetches up to limit+1 matching events at or below the
+// filter's BeforeSeq boundary in ascending seq order; the extra row is the
+// has-more signal. It returns the fetched events and scanned — the best-effort
+// count of matching rows the read could see, used as the filtered Total.
+//
+// ListTail is the fast path: a backward scan of the ACTIVE events.jsonl only,
+// never the .gz archives, so its result is trusted ONLY when it yields a full
+// limit+1 rows. The active file holds the newest events, so a full tail page
+// there IS the newest page below the boundary. Anything short cannot
+// distinguish "log exhausted" from "active file exhausted, older matches in
+// archives/rotation" and MUST fall through to the full scan — otherwise a
+// rotation (or a selective filter) strands the older history behind an unminted
+// cursor. The scan uses the in-flight-aware read when the provider offers one
+// (listWithInFlight) so a just-rotated segment living only in a .rotating-* file
+// is not skipped; the BeforeSeq predicate keeps rotation/archive handling inside
+// the one battle-tested sequential reader instead of a bespoke reverse reader.
+func fetchEventPageAscending(ep events.Provider, filter events.Filter, limit int) ([]events.Event, int, error) {
+ fetch := limit + 1
+ if tp, ok := ep.(events.TailProvider); ok {
+ tail, err := tp.ListTail(filter, fetch)
+ if err != nil {
+ return nil, 0, err
}
- wires = append(wires, w)
+ if len(tail) == fetch {
+ return tail, limit, nil
+ }
+ }
+ all, err := listWithInFlight(ep, filter)
+ if err != nil {
+ return nil, 0, err
+ }
+ scanned := len(all)
+ if len(all) > fetch {
+ all = all[len(all)-fetch:]
+ }
+ return all, scanned, nil
+}
+
+// listWithInFlight returns all events matching filter, folding in events still
+// stranded in an in-flight rotation file when the provider is an
+// [events.InFlightProvider]. Plain List reads archives + the active file, so
+// during a rotation's compression window it misses the just-rotated .rotating-*
+// segment; the in-flight-aware read closes that gap so a descending keyset walk
+// cannot skip a whole seq range. Providers with no in-flight window fall back to
+// List unchanged.
+func listWithInFlight(ep events.Provider, filter events.Filter) ([]events.Event, error) {
+ if ip, ok := ep.(events.InFlightProvider); ok {
+ return ip.ListInFlight(filter)
}
- return wires
+ return ep.List(filter)
}
func filterIsEmpty(f events.Filter) bool {
diff --git a/internal/api/huma_handlers_rigs.go b/internal/api/huma_handlers_rigs.go
index 5534e8c1f9..b04e01d320 100644
--- a/internal/api/huma_handlers_rigs.go
+++ b/internal/api/huma_handlers_rigs.go
@@ -306,14 +306,20 @@ func (s *Server) spawnRigProvision(sm StateMutator, city string, entry *liveProv
// Manifest sink: record-then-create. Each checkpoint persists the created
// resource onto the durable record (crash recovery) AND updates the captured
- // manifest the rollback path tears down (runtime recovery). Persist errors
- // are logged, not fatal — a missed persist only widens the boot-sweep's job.
+ // manifest the rollback path tears down (runtime recovery). The persist error
+ // is returned to ProvisionRigFromGit, which fails closed at the pre-clone
+ // checkpoint (a missed created_dir persist would leave an un-manifested clone
+ // the boot sweep and re-clone pre-drop cannot discover, wedging the name) and
+ // treats the post-init checkpoint as non-fatal. The reqID-tagged log stays for
+ // operability regardless of which checkpoint the caller is at.
var manifest RigProvisionManifest
- onManifest := func(m RigProvisionManifest) {
+ onManifest := func(m RigProvisionManifest) error {
manifest = m
if err := persistManifest(store, entry.beadID, m); err != nil {
log.Printf("api: rig create %s: %v", reqID, err)
+ return err
}
+ return nil
}
rigCfg := config.Rig{
diff --git a/internal/api/huma_handlers_runs.go b/internal/api/huma_handlers_runs.go
index 9811531ffa..60f7221f3d 100644
--- a/internal/api/huma_handlers_runs.go
+++ b/internal/api/huma_handlers_runs.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"errors"
+ "log"
"net/url"
"os"
"path/filepath"
@@ -49,6 +50,8 @@ const (
type runFoldResult struct {
beads []beads.Bead
decodeMisses int
+ ready bool
+ partial bool
}
const runCensusPartialReason = "run projection is incomplete"
@@ -60,22 +63,49 @@ type RunCensusSource interface {
RunCensus(context.Context, string) (runproj.CanonicalRunCensus, bool)
}
-// runFold reads the city event log, folds it into the latest bead snapshot per
-// id, and keeps only run-participating beads. The result is memoized in the
-// Server response cache keyed by the event log's modification time, so repeated
-// polls between appends are a pure cache hit and a new append re-folds. A city
-// with no event log yet yields an empty projection (a fresh city has no runs),
-// not an error.
-func (s *Server) runFold() (runFoldResult, error) {
+// RunProjectionSource serves immutable bead snapshots from an incremental
+// per-city projector. Production's RunCensusSource also implements this
+// capability; keeping it separate preserves the narrow census contract for
+// other sources and tests.
+type RunProjectionSource interface {
+ RunProjection(context.Context, string) (runproj.RunProjectionSnapshot, bool)
+}
+
+// RunProjectionGraceSource owns the bounded warming window for point-read
+// misses that may be valid newly-slung runs not yet visible in the event fold.
+type RunProjectionGraceSource interface {
+ RunProjectionMissInGrace(context.Context, string, string) bool
+ ForgetRunProjectionMiss(context.Context, string, string)
+}
+
+// runFold reads the warm incremental projection when the injected census source
+// provides it. Direct Server users without that capability retain the legacy
+// on-disk fold, memoized by event-log modification time. A city with no event
+// log yet yields a ready empty projection (a fresh city has no runs), not an
+// error.
+func (s *Server) runFold(ctx context.Context) (runFoldResult, error) {
+ if source, ok := s.runCensusSource.(RunProjectionSource); ok {
+ snapshot, found := source.RunProjection(ctx, s.state.CityName())
+ if !found {
+ return runFoldResult{}, errors.New("run projection source unavailable")
+ }
+ return runFoldResult{
+ beads: snapshot.Beads,
+ decodeMisses: snapshot.DecodeMisses,
+ ready: snapshot.Ready,
+ partial: snapshot.Partial || !snapshot.Ready,
+ }, nil
+ }
+
cityRoot := strings.TrimSpace(s.state.CityPath())
if cityRoot == "" {
- return runFoldResult{}, nil
+ return runFoldResult{ready: true}, nil
}
eventsPath := filepath.Join(cityRoot, ".gc", "events.jsonl")
fi, err := os.Stat(eventsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
- return runFoldResult{}, nil
+ return runFoldResult{ready: true}, nil
}
return runFoldResult{}, err
}
@@ -95,6 +125,8 @@ func (s *Server) runFold() (runFoldResult, error) {
res := runFoldResult{
beads: runproj.FilterRunBeads(proj.Beads()),
decodeMisses: proj.DecodeMisses(),
+ ready: true,
+ partial: proj.DecodeMisses() > 0,
}
s.storeResponse(key, index, res)
return res, nil
@@ -103,8 +135,8 @@ func (s *Server) runFold() (runFoldResult, error) {
// humaHandleRunsList is the Huma-typed handler for GET /v0/city/{cityName}/runs.
// It lists every run in the city (active, then waiting/blocked, then historical),
// newest activity first, capped by limit.
-func (s *Server) humaHandleRunsList(_ context.Context, input *RunsListInput) (*RunsListOutput, error) {
- fold, err := s.runFold()
+func (s *Server) humaHandleRunsList(ctx context.Context, input *RunsListInput) (*RunsListOutput, error) {
+ fold, err := s.runFold(ctx)
if err != nil {
return nil, runProjectionUnavailable(err)
}
@@ -135,6 +167,15 @@ func (s *Server) humaHandleRunsList(_ context.Context, input *RunsListInput) (*R
out.Body.PartialErrors = append(out.Body.PartialErrors,
"run list truncated; older runs are not shown")
}
+ if !fold.ready {
+ out.Body.Partial = true
+ out.Body.PartialErrors = append(out.Body.PartialErrors,
+ "run projection is warming")
+ } else if fold.partial && fold.decodeMisses == 0 {
+ out.Body.Partial = true
+ out.Body.PartialErrors = append(out.Body.PartialErrors,
+ runCensusPartialReason)
+ }
if fold.decodeMisses > 0 {
out.Body.Partial = true
out.Body.PartialErrors = append(out.Body.PartialErrors,
@@ -177,29 +218,62 @@ func runStatusCountsFromProjection(counts runproj.CanonicalRunStatusCounts) RunS
// GET /v0/city/{cityName}/runs/{run_id}. It resolves the single run off the fold
// via BuildRunLane, so a completed run beyond the list's historical cap is still
// retrievable (no false 404).
-func (s *Server) humaHandleRunGet(_ context.Context, input *RunGetInput) (*RunGetOutput, error) {
- fold, err := s.runFold()
+func (s *Server) humaHandleRunGet(ctx context.Context, input *RunGetInput) (*RunGetOutput, error) {
+ fold, err := s.runFold(ctx)
if err != nil {
return nil, runProjectionUnavailable(err)
}
+ if !fold.ready {
+ return nil, apierr.ServiceUnavailable.Msg("run projection is warming")
+ }
lane, ok := runproj.BuildRunLane(fold.beads, input.RunID)
if !ok {
+ if fold.partial {
+ return nil, apierr.ServiceUnavailable.Msg("run projection is incomplete")
+ }
+ if s.runProjectionMissInGrace(ctx, input.RunID) {
+ return nil, apierr.ServiceUnavailable.Msg("run projection is warming")
+ }
return nil, apierr.RunNotFound.Msgf("run not found: %s", input.RunID)
}
+ s.forgetRunProjectionMiss(ctx, input.RunID)
return &RunGetOutput{Body: laneToRun(lane, beadsByID(fold.beads), countStartedMembers(fold.beads, lane.ID))}, nil
}
// humaHandleRunSteps is the Huma-typed handler for
// GET /v0/city/{cityName}/runs/{run_id}/steps. Steps are the run's member beads
// (the root's children), each projected to a closed RunStepStatus.
-func (s *Server) humaHandleRunSteps(_ context.Context, input *RunStepsInput) (*RunStepsOutput, error) {
- fold, err := s.runFold()
+func (s *Server) humaHandleRunSteps(ctx context.Context, input *RunStepsInput) (*RunStepsOutput, error) {
+ fold, err := s.runFold(ctx)
if err != nil {
return nil, runProjectionUnavailable(err)
}
- if _, ok := runproj.BuildRunLane(fold.beads, input.RunID); !ok {
+ if !fold.ready {
+ return nil, apierr.ServiceUnavailable.Msg("run projection is warming")
+ }
+ lane, ok := runproj.BuildRunLane(fold.beads, input.RunID)
+ if !ok {
+ if fold.partial {
+ return nil, apierr.ServiceUnavailable.Msg("run projection is incomplete")
+ }
+ if s.runProjectionMissInGrace(ctx, input.RunID) {
+ return nil, apierr.ServiceUnavailable.Msg("run projection is warming")
+ }
return nil, apierr.RunNotFound.Msgf("run not found: %s", input.RunID)
}
+ s.forgetRunProjectionMiss(ctx, input.RunID)
+
+ // Derive the run's canonical lifecycle status exactly as laneToRun/deriveRunStatus
+ // do (root-terminality wins over lingering members), then clamp each step through
+ // it: a completed run must not report a step as eternally active when its close
+ // event was lost. A non-terminal run yields an inactive clamp (raw statuses stand).
+ byID := beadsByID(fold.beads)
+ root, rootFound := byID[input.RunID]
+ var rootPtr *beads.Bead
+ if rootFound {
+ rootPtr = &root
+ }
+ runStatus := runproj.CanonicalRunStatusForLane(lane, rootPtr, countStartedMembers(fold.beads, lane.ID))
members := runMemberBeads(fold.beads, input.RunID)
out := &RunStepsOutput{}
@@ -210,10 +284,11 @@ func (s *Server) humaHandleRunSteps(_ context.Context, input *RunStepsInput) (*R
if m.ID == input.RunID {
continue // the root is the run, not a step
}
+ status := RunStepStatus(runproj.ClampStepStatusForRun(runStatus, string(deriveRunStepStatus(m))))
out.Body.Steps = append(out.Body.Steps, RunStep{
ID: m.ID,
Title: runStepTitle(m),
- Status: deriveRunStepStatus(m),
+ Status: status,
Kind: m.Type,
Assignee: strings.TrimSpace(m.Assignee),
})
@@ -221,6 +296,17 @@ func (s *Server) humaHandleRunSteps(_ context.Context, input *RunStepsInput) (*R
return out, nil
}
+func (s *Server) runProjectionMissInGrace(ctx context.Context, runID string) bool {
+ source, ok := s.runCensusSource.(RunProjectionGraceSource)
+ return ok && source.RunProjectionMissInGrace(ctx, s.state.CityName(), runID)
+}
+
+func (s *Server) forgetRunProjectionMiss(ctx context.Context, runID string) {
+ if source, ok := s.runCensusSource.(RunProjectionGraceSource); ok {
+ source.ForgetRunProjectionMiss(ctx, s.state.CityName(), runID)
+ }
+}
+
// runCanceledCloseReason is the close_reason stamped on beads wound down by a run
// cancel, distinguishing an operator cancel from a skip-directive teardown.
const runCanceledCloseReason = "run canceled via POST /runs/{id}/cancel"
@@ -577,5 +663,6 @@ func normalizeRunsListLimit(limit int) int {
// runProjectionUnavailable wraps a fold/read failure as a 503 — reading the event
// log is a backend availability concern the caller can retry.
func runProjectionUnavailable(err error) error {
- return apierr.ServiceUnavailable.Msgf("run projection unavailable: %v", err)
+ log.Printf("gc api: run projection unavailable: %v", err)
+ return apierr.ServiceUnavailable.Msg("run projection unavailable")
}
diff --git a/internal/api/huma_handlers_runs_test.go b/internal/api/huma_handlers_runs_test.go
index fc5ab71941..c34b38dfb5 100644
--- a/internal/api/huma_handlers_runs_test.go
+++ b/internal/api/huma_handlers_runs_test.go
@@ -1,10 +1,12 @@
package api
import (
+ "bytes"
"context"
"encoding/json"
"errors"
"fmt"
+ "log"
"net/http"
"net/http/httptest"
"os"
@@ -19,6 +21,28 @@ import (
"github.com/gastownhall/gascity/internal/runproj"
)
+func TestRunProjectionUnavailableSanitizesPublicDetailAndLogsCause(t *testing.T) {
+ var logs bytes.Buffer
+ previousLog := log.Writer()
+ log.SetOutput(&logs)
+ t.Cleanup(func() { log.SetOutput(previousLog) })
+
+ err := runProjectionUnavailable(errors.New("read /private/city/.gc/events.jsonl: permission denied"))
+ var statusErr huma.StatusError
+ if !errors.As(err, &statusErr) || statusErr.GetStatus() != http.StatusServiceUnavailable {
+ t.Fatalf("error = %T %v, want Huma 503", err, err)
+ }
+ if got := err.Error(); got != "run projection unavailable" {
+ t.Fatalf("public detail = %q, want sanitized projection-unavailable message", got)
+ }
+ if strings.Contains(err.Error(), "/private/") || strings.Contains(err.Error(), "permission denied") {
+ t.Fatalf("public detail leaked filesystem cause: %q", err.Error())
+ }
+ if got := logs.String(); !strings.Contains(got, "/private/city/.gc/events.jsonl") || !strings.Contains(got, "permission denied") {
+ t.Fatalf("internal log omitted raw projection failure: %q", got)
+ }
+}
+
// runFixtureID gives a stable, zero-padded run id for cap/ordering fixtures.
func runFixtureID(i int) string { return fmt.Sprintf("run-%02d", i) }
@@ -217,6 +241,330 @@ func (f fakeRunCensusSource) RunCensus(context.Context, string) (runproj.Canonic
return f.value, f.ok
}
+type fakeRunProjectionSource struct {
+ fakeRunCensusSource
+ projection runproj.RunProjectionSnapshot
+ projectionOK bool
+ projectionCalls int
+ missInGrace bool
+ graceCalls int
+ forgottenRuns []string
+}
+
+func (f *fakeRunProjectionSource) RunProjection(context.Context, string) (runproj.RunProjectionSnapshot, bool) {
+ f.projectionCalls++
+ return f.projection, f.projectionOK
+}
+
+func (f *fakeRunProjectionSource) RunProjectionMissInGrace(context.Context, string, string) bool {
+ f.graceCalls++
+ return f.missInGrace
+}
+
+func (f *fakeRunProjectionSource) ForgetRunProjectionMiss(_ context.Context, _, runID string) {
+ f.forgottenRuns = append(f.forgottenRuns, runID)
+}
+
+func TestRunsListEndpointUsesInjectedWarmProjectionInsteadOfDiskReplay(t *testing.T) {
+ s := newRunServer(t,
+ beadCreatedEvent(1, runRootBead("disk-run", "disk-formula", "open")),
+ )
+ // A directory at the active-log path makes any attempted disk replay fail.
+ // The injected warm source must make this path entirely irrelevant.
+ eventsPath := filepath.Join(s.state.CityPath(), ".gc", "events.jsonl")
+ if err := os.Remove(eventsPath); err != nil {
+ t.Fatalf("remove event log: %v", err)
+ }
+ if err := os.Mkdir(eventsPath, 0o755); err != nil {
+ t.Fatalf("replace event log with directory: %v", err)
+ }
+ source := &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{
+ Ready: true,
+ Beads: []beads.Bead{
+ runRootBead("warm-run", "warm-formula", "open"),
+ },
+ },
+ }
+ s.runCensusSource = source
+
+ out, err := s.humaHandleRunsList(context.Background(), &RunsListInput{
+ CityScope: CityScope{CityName: "test-city"},
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunsList error: %v", err)
+ }
+ if source.projectionCalls != 1 {
+ t.Fatalf("RunProjection calls = %d, want 1", source.projectionCalls)
+ }
+ if len(out.Body.Runs) != 1 || out.Body.Runs[0].RunID != "warm-run" {
+ t.Fatalf("runs = %+v, want only warm-run from the injected projection", out.Body.Runs)
+ }
+ if out.Body.Partial {
+ t.Fatalf("ready complete projection reported partial: %+v", out.Body)
+ }
+}
+
+func TestRunsListEndpointReportsWarmProjectionStartupAsPartial(t *testing.T) {
+ s := newRunServer(t)
+ s.runCensusSource = &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{},
+ }
+
+ out, err := s.humaHandleRunsList(context.Background(), &RunsListInput{
+ CityScope: CityScope{CityName: "test-city"},
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunsList error: %v", err)
+ }
+ if len(out.Body.Runs) != 0 || !out.Body.Partial {
+ t.Fatalf("warming list = %+v, want empty partial response", out.Body)
+ }
+ if len(out.Body.PartialErrors) != 1 || out.Body.PartialErrors[0] != "run projection is warming" {
+ t.Fatalf("partial_errors = %q, want one sanitized warming reason", out.Body.PartialErrors)
+ }
+}
+
+func TestRunsListEndpointTreatsReadyEmptyWarmProjectionAsComplete(t *testing.T) {
+ s := newRunServer(t)
+ s.runCensusSource = &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{Ready: true},
+ }
+
+ out, err := s.humaHandleRunsList(context.Background(), &RunsListInput{
+ CityScope: CityScope{CityName: "test-city"},
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunsList error: %v", err)
+ }
+ if len(out.Body.Runs) != 0 || out.Body.Partial || len(out.Body.PartialErrors) != 0 {
+ t.Fatalf("ready empty list = %+v, want complete empty response", out.Body)
+ }
+}
+
+func TestRunPointReadsReturnServiceUnavailableWhileWarmProjectionStarts(t *testing.T) {
+ s := newRunServer(t)
+ s.runCensusSource = &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{
+ Partial: true,
+ },
+ }
+
+ tests := []struct {
+ name string
+ call func() error
+ }{
+ {
+ name: "run",
+ call: func() error {
+ _, err := s.humaHandleRunGet(context.Background(), &RunGetInput{
+ CityScope: CityScope{CityName: "test-city"}, RunID: "run-unknown",
+ })
+ return err
+ },
+ },
+ {
+ name: "steps",
+ call: func() error {
+ _, err := s.humaHandleRunSteps(context.Background(), &RunStepsInput{
+ CityScope: CityScope{CityName: "test-city"}, RunID: "run-unknown",
+ })
+ return err
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.call()
+ if err == nil {
+ t.Fatal("error = nil, want warming 503")
+ }
+ var statusErr huma.StatusError
+ if !errors.As(err, &statusErr) || statusErr.GetStatus() != http.StatusServiceUnavailable {
+ t.Fatalf("error = %T %v, want Huma 503", err, err)
+ }
+ if !strings.Contains(err.Error(), "warming") || strings.Contains(err.Error(), s.state.CityPath()) {
+ t.Fatalf("error = %q, want sanitized warming detail", err.Error())
+ }
+ })
+ }
+}
+
+func TestRunPointReadsDoNotReturnNotFoundFromReadyPartialProjection(t *testing.T) {
+ s := newRunServer(t)
+ s.runCensusSource = &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{
+ Ready: true,
+ Partial: true,
+ },
+ }
+
+ tests := []struct {
+ name string
+ call func() error
+ }{
+ {
+ name: "run",
+ call: func() error {
+ _, err := s.humaHandleRunGet(context.Background(), &RunGetInput{
+ CityScope: CityScope{CityName: "test-city"}, RunID: "possibly-missing",
+ })
+ return err
+ },
+ },
+ {
+ name: "steps",
+ call: func() error {
+ _, err := s.humaHandleRunSteps(context.Background(), &RunStepsInput{
+ CityScope: CityScope{CityName: "test-city"}, RunID: "possibly-missing",
+ })
+ return err
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.call()
+ var statusErr huma.StatusError
+ if !errors.As(err, &statusErr) || statusErr.GetStatus() != http.StatusServiceUnavailable {
+ t.Fatalf("error = %T %v, want Huma 503 for an incomplete projection", err, err)
+ }
+ if !strings.Contains(err.Error(), "incomplete") || strings.Contains(err.Error(), "possibly-missing") {
+ t.Fatalf("error = %q, want sanitized incomplete-projection detail", err.Error())
+ }
+ })
+ }
+}
+
+func TestRunPointReadsGraceWarmProjectionMissThenResolveOrExpire(t *testing.T) {
+ tests := []struct {
+ name string
+ beads []beads.Bead
+ call func(*Server) error
+ }{
+ {
+ name: "run",
+ beads: []beads.Bead{runRootBead("run-new", "formula", "open")},
+ call: func(s *Server) error {
+ _, err := s.humaHandleRunGet(context.Background(), &RunGetInput{
+ CityScope: CityScope{CityName: "test-city"}, RunID: "run-new",
+ })
+ return err
+ },
+ },
+ {
+ name: "steps",
+ beads: []beads.Bead{
+ runRootBead("run-new", "formula", "open"),
+ runChildBead("run-new.step", "run-new", "open", nil),
+ },
+ call: func(s *Server) error {
+ _, err := s.humaHandleRunSteps(context.Background(), &RunStepsInput{
+ CityScope: CityScope{CityName: "test-city"}, RunID: "run-new",
+ })
+ return err
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := newRunServer(t)
+ source := &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{Ready: true},
+ missInGrace: true,
+ }
+ s.runCensusSource = source
+
+ err := tt.call(s)
+ var statusErr huma.StatusError
+ if !errors.As(err, &statusErr) || statusErr.GetStatus() != http.StatusServiceUnavailable {
+ t.Fatalf("initial miss error = %T %v, want warming 503", err, err)
+ }
+ if !strings.Contains(err.Error(), "warming") || source.graceCalls != 1 {
+ t.Fatalf("initial miss = %v, grace calls = %d; want warming via one grace lookup", err, source.graceCalls)
+ }
+
+ source.projection.Beads = tt.beads
+ if err := tt.call(s); err != nil {
+ t.Fatalf("resolved run error: %v", err)
+ }
+ if len(source.forgottenRuns) != 1 || source.forgottenRuns[0] != "run-new" {
+ t.Fatalf("forgotten runs = %q, want run-new after projection resolves", source.forgottenRuns)
+ }
+
+ source.projection.Beads = nil
+ source.missInGrace = false
+ err = tt.call(s)
+ if !errors.As(err, &statusErr) || statusErr.GetStatus() != http.StatusNotFound {
+ t.Fatalf("expired miss error = %T %v, want definitive 404", err, err)
+ }
+ })
+ }
+}
+
+func TestRunsListEndpointReportsWarmProjectionReadFailureAsPartial(t *testing.T) {
+ s := newRunServer(t)
+ s.runCensusSource = &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{
+ Ready: true,
+ Partial: true,
+ },
+ }
+
+ out, err := s.humaHandleRunsList(context.Background(), &RunsListInput{
+ CityScope: CityScope{CityName: "test-city"},
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunsList error: %v", err)
+ }
+ if !out.Body.Partial || len(out.Body.PartialErrors) != 1 || out.Body.PartialErrors[0] != runCensusPartialReason {
+ t.Fatalf("partial response = %+v, want one sanitized incomplete reason", out.Body)
+ }
+}
+
+func TestRunsListEndpointPreservesWarmProjectionDecodeMissPartial(t *testing.T) {
+ s := newRunServer(t)
+ s.runCensusSource = &fakeRunProjectionSource{
+ fakeRunCensusSource: fakeRunCensusSource{ok: true},
+ projectionOK: true,
+ projection: runproj.RunProjectionSnapshot{
+ Ready: true,
+ Beads: []beads.Bead{runRootBead("run-one", "formula", "open")},
+ DecodeMisses: 1,
+ Partial: true,
+ },
+ }
+
+ out, err := s.humaHandleRunsList(context.Background(), &RunsListInput{
+ CityScope: CityScope{CityName: "test-city"},
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunsList error: %v", err)
+ }
+ if !out.Body.Partial {
+ t.Fatalf("decode-miss projection reported complete: %+v", out.Body)
+ }
+ want := "some run events could not be decoded; the list may be incomplete"
+ if len(out.Body.PartialErrors) != 1 || out.Body.PartialErrors[0] != want {
+ t.Fatalf("partial_errors = %q, want %q", out.Body.PartialErrors, want)
+ }
+}
+
func TestRunsCensusEndpointUsesWarmProjectionWithoutRows(t *testing.T) {
s := newRunServer(t)
s.runCensusSource = fakeRunCensusSource{
@@ -500,6 +848,74 @@ func TestRunStepsEndpoint(t *testing.T) {
}
}
+// TestRunStepsClampsCompletedRun proves the typed /steps endpoint honors run
+// terminality: a completed run whose steps lost their close events must report each
+// lingering step terminal (completed / skipped), never eternally active.
+func TestRunStepsClampsCompletedRun(t *testing.T) {
+ root := runRootBead("runc", "mol-adopt-pr-v2", "closed")
+ root.Metadata["gc.outcome"] = "pass"
+ s := newRunServer(t,
+ beadCreatedEvent(1, root),
+ beadCreatedEvent(2, runChildBead("runc.step1", "runc", "closed", map[string]string{"gc.outcome": "pass"})),
+ beadCreatedEvent(3, runChildBead("runc.step2", "runc", "in_progress", nil)), // lost close event
+ beadCreatedEvent(4, runChildBead("runc.step3", "runc", "open", nil)), // never started
+ )
+ out, err := s.humaHandleRunSteps(context.Background(), &RunStepsInput{
+ CityScope: CityScope{CityName: "test-city"},
+ RunID: "runc",
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunSteps error: %v", err)
+ }
+ byID := map[string]RunStep{}
+ for _, st := range out.Body.Steps {
+ byID[st.ID] = st
+ }
+ if byID["runc.step1"].Status != RunStepStatusCompleted {
+ t.Errorf("step1 = %q, want completed", byID["runc.step1"].Status)
+ }
+ if byID["runc.step2"].Status != RunStepStatusCompleted {
+ t.Errorf("lost-close step2 = %q, want completed (run is completed)", byID["runc.step2"].Status)
+ }
+ if byID["runc.step3"].Status != RunStepStatusSkipped {
+ t.Errorf("never-started step3 = %q, want skipped (never started under a completed run)", byID["runc.step3"].Status)
+ }
+}
+
+// TestRunStepsClampsFailedRunToCanceled proves the failure family: a failed run
+// cancels its lingering active steps (rather than completing them) and skips its
+// never-started ones, while a recorded step outcome is preserved.
+func TestRunStepsClampsFailedRunToCanceled(t *testing.T) {
+ root := runRootBead("runx", "mol-adopt-pr-v2", "closed")
+ root.Metadata["gc.outcome"] = "fail"
+ s := newRunServer(t,
+ beadCreatedEvent(1, root),
+ beadCreatedEvent(2, runChildBead("runx.step1", "runx", "in_progress", nil)), // lost close
+ beadCreatedEvent(3, runChildBead("runx.step2", "runx", "open", nil)), // never started
+ beadCreatedEvent(4, runChildBead("runx.step3", "runx", "closed", map[string]string{"gc.outcome": "fail"})), // real failure
+ )
+ out, err := s.humaHandleRunSteps(context.Background(), &RunStepsInput{
+ CityScope: CityScope{CityName: "test-city"},
+ RunID: "runx",
+ })
+ if err != nil {
+ t.Fatalf("humaHandleRunSteps error: %v", err)
+ }
+ byID := map[string]RunStep{}
+ for _, st := range out.Body.Steps {
+ byID[st.ID] = st
+ }
+ if byID["runx.step1"].Status != RunStepStatusCanceled {
+ t.Errorf("lost-close step1 = %q, want canceled (run failed)", byID["runx.step1"].Status)
+ }
+ if byID["runx.step2"].Status != RunStepStatusSkipped {
+ t.Errorf("never-started step2 = %q, want skipped", byID["runx.step2"].Status)
+ }
+ if byID["runx.step3"].Status != RunStepStatusFailed {
+ t.Errorf("recorded-failure step3 = %q, want failed (real outcome preserved)", byID["runx.step3"].Status)
+ }
+}
+
// TestRunGetBeyondHistoricalCap guards the false-404 defect: with more completed
// runs than the projection's historical lane cap, every run must still resolve by
// id (the single-run path bypasses the list cap via BuildRunLane).
diff --git a/internal/api/huma_handlers_sessions_command.go b/internal/api/huma_handlers_sessions_command.go
index fcca3472ea..a3ff18e86d 100644
--- a/internal/api/huma_handlers_sessions_command.go
+++ b/internal/api/huma_handlers_sessions_command.go
@@ -8,10 +8,12 @@ import (
"net/http"
"os"
"os/exec"
+ "reflect"
"strings"
"sync/atomic"
"time"
+ "github.com/danielgtaylor/huma/v2"
"github.com/gastownhall/gascity/internal/api/apierr"
"github.com/gastownhall/gascity/internal/beads"
"github.com/gastownhall/gascity/internal/config"
@@ -143,6 +145,7 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea
}
resolvedCfg, cfgErr := resolvedSessionConfigForProvider(
s.state.CityPath(),
+ configuredWorkspaceSessionEnv(s.state.Config()),
alias,
explicitName,
template,
@@ -322,7 +325,7 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Sessio
}
go func() {
defer s.recoverAsRequestFailed(reqID, RequestOperationSessionCreate)
- resolvedCfg, cfgErr := resolvedSessionConfigForProvider(s.state.CityPath(), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
+ resolvedCfg, cfgErr := resolvedSessionConfigForProvider(s.state.CityPath(), configuredWorkspaceSessionEnv(s.state.Config()), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
if cfgErr != nil {
s.emitSessionCreateFailed(reqID, "create_failed", cfgErr.Error())
return
@@ -375,22 +378,136 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Sessio
// --- Session Transcript ---
-// sessionTranscriptGetResponse is the union of conversation/text and raw
-// transcript response shapes. When Format is "conversation" or "text",
-// Turns is populated. When Format is "raw", Messages carries pre-decoded
-// provider-native frames as generic JSON values. The spec describes the
-// items as arbitrary JSON (any) — clients interpret shapes based on the
-// session's provider.
+// sessionTranscriptGetResponse is the runtime container for conversation,
+// raw, and structured transcript responses. Its OpenAPI schema is a
+// discriminated union so generated clients never see raw provider frames on
+// the structured response branch.
type sessionTranscriptGetResponse struct {
+ ID string `json:"id"`
+ Template string `json:"template"`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing."`
+ Format string `json:"format" doc:"conversation, text, raw, or structured."`
+ SchemaVersion string `json:"schema_version,omitempty" doc:"Structured session transcript schema version when format is structured."`
+ Operation string `json:"operation,omitempty" doc:"Structured response application mode. REST structured transcripts are snapshots."`
+ ResetReason string `json:"reset_reason,omitempty" doc:"Structured reset reason when operation is reset."`
+ History *SessionStructuredHistory `json:"history,omitempty" doc:"Normalized worker-history envelope when format is structured."`
+ Turns []outputTurn `json:"turns,omitempty" doc:"Populated for conversation/text formats."`
+ Messages *[]SessionRawMessageFrame `json:"messages,omitempty" doc:"Populated for raw format; provider-native frames emitted verbatim as the provider wrote them."`
+ StructuredMessages *[]SessionStructuredMessage `json:"structured_messages,omitempty" doc:"Populated for structured format; provider-normalized structured messages."`
+ Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
+}
+
+type sessionTranscriptConversationResponse struct {
ID string `json:"id"`
Template string `json:"template"`
- Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing."`
- Format string `json:"format" doc:"conversation, text, or raw."`
- Turns []outputTurn `json:"turns,omitempty" doc:"Populated for conversation/text formats."`
- Messages []SessionRawMessageFrame `json:"messages,omitempty" doc:"Populated for raw format; provider-native frames emitted verbatim as the provider wrote them."`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.)."`
+ Format string `json:"format" enum:"conversation,text" doc:"Conversation or text transcript format."`
+ Turns []outputTurn `json:"turns,omitempty" doc:"Conversation/text transcript turns."`
Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
}
+type sessionTranscriptRawResponse struct {
+ ID string `json:"id"`
+ Template string `json:"template"`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing."`
+ Format string `json:"format" enum:"raw" doc:"Raw provider-native transcript format."`
+ Messages []SessionRawMessageFrame `json:"messages" doc:"Provider-native transcript frames emitted only for raw format."`
+ Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
+}
+
+type sessionTranscriptStructuredResponse struct {
+ ID string `json:"id"`
+ Template string `json:"template"`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.)."`
+ Format string `json:"format" enum:"structured" doc:"Structured provider-neutral transcript format."`
+ SchemaVersion string `json:"schema_version" enum:"session.structured.v1" doc:"Structured session transcript schema version."`
+ Operation string `json:"operation" enum:"snapshot" doc:"Always snapshot for a REST structured transcript."`
+ History *SessionStructuredHistory `json:"history" doc:"Normalized worker-history envelope when format is structured."`
+ StructuredMessages []SessionStructuredMessage `json:"structured_messages" doc:"Provider-normalized structured messages."`
+ Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
+}
+
+func nonNilStructuredMessages(messages []SessionStructuredMessage) []SessionStructuredMessage {
+ if messages == nil {
+ return []SessionStructuredMessage{}
+ }
+ return messages
+}
+
+func structuredMessagesField(messages []SessionStructuredMessage) *[]SessionStructuredMessage {
+ messages = nonNilStructuredMessages(messages)
+ return &messages
+}
+
+func nonNilRawMessages(messages []SessionRawMessageFrame) []SessionRawMessageFrame {
+ if messages == nil {
+ return []SessionRawMessageFrame{}
+ }
+ return messages
+}
+
+func rawMessagesField(messages []SessionRawMessageFrame) *[]SessionRawMessageFrame {
+ messages = nonNilRawMessages(messages)
+ return &messages
+}
+
+func structuredTranscriptMessages(response sessionTranscriptGetResponse) []SessionStructuredMessage {
+ if response.StructuredMessages == nil {
+ return nil
+ }
+ return *response.StructuredMessages
+}
+
+func rawTranscriptMessages(response sessionTranscriptGetResponse) []SessionRawMessageFrame {
+ if response.Messages == nil {
+ return nil
+ }
+ return *response.Messages
+}
+
+// Schema publishes session transcript responses as a discriminated union over
+// the format field, keeping provider-native raw frames out of the structured
+// response schema while preserving the compact runtime container above.
+func (sessionTranscriptGetResponse) Schema(r huma.Registry) *huma.Schema {
+ const name = "SessionTranscriptGetResponse"
+ if _, ok := r.Map()[name]; !ok {
+ variants := []struct {
+ format string
+ name string
+ typ reflect.Type
+ }{
+ {format: "conversation", name: "SessionTranscriptConversationResponse", typ: reflect.TypeOf(sessionTranscriptConversationResponse{})},
+ {format: "text", name: "SessionTranscriptConversationResponse", typ: reflect.TypeOf(sessionTranscriptConversationResponse{})},
+ {format: "raw", name: "SessionTranscriptRawResponse", typ: reflect.TypeOf(sessionTranscriptRawResponse{})},
+ {format: "structured", name: "SessionTranscriptStructuredResponse", typ: reflect.TypeOf(sessionTranscriptStructuredResponse{})},
+ }
+ oneOf := make([]*huma.Schema, 0, 3)
+ mapping := make(map[string]string, len(variants))
+ seen := make(map[string]bool, 3)
+ for _, variant := range variants {
+ ref := schemaRefPrefix + variant.name
+ if _, ok := r.Map()[variant.name]; !ok {
+ r.Schema(variant.typ, true, variant.name)
+ }
+ if !seen[variant.name] {
+ oneOf = append(oneOf, &huma.Schema{Ref: ref})
+ seen[variant.name] = true
+ }
+ mapping[variant.format] = ref
+ }
+ r.Map()[name] = &huma.Schema{
+ Title: "Session transcript response",
+ Description: "Discriminated union of session transcript response shapes. Raw provider-native frames are available only on the raw branch; structured responses contain only provider-neutral typed data.",
+ OneOf: oneOf,
+ Discriminator: &huma.Discriminator{
+ PropertyName: "format",
+ Mapping: mapping,
+ },
+ }
+ }
+ return &huma.Schema{Ref: schemaRefPrefix + name}
+}
+
// humaHandleSessionTranscript is the Huma-typed handler for GET /v0/session/{id}/transcript.
func (s *Server) humaHandleSessionPatch(_ context.Context, input *SessionPatchInput) (*IndexOutput[sessionResponse], error) {
@@ -565,11 +682,21 @@ func providerHasOption(schema []config.ProviderOption, key string) bool {
// humaHandleSessionSubmit is the Huma-typed handler for POST /v0/session/{id}/submit.
-func (s *Server) humaHandleSessionSubmit(_ context.Context, input *SessionSubmitInput) (*SessionSubmitOutput, error) {
+func (s *Server) humaHandleSessionSubmit(ctx context.Context, input *SessionSubmitInput) (*SessionSubmitOutput, error) {
store := s.state.SessionsBeadStore()
if store.Store == nil {
return nil, apierr.ServiceUnavailable.Msg("no bead store configured")
}
+ if err := s.sessionTargetDeliverable(ctx, store.Store, input.ID); err != nil {
+ if errors.Is(err, session.ErrSessionNotFound) {
+ return nil, apierr.SessionNotFound.Msg(fmt.Sprintf("session %q not found and not a configured named session", input.ID))
+ }
+ // Ambiguous bare names and configured-name/live-bead conflicts are
+ // deterministic client addressing errors: map them through the resolve
+ // helper so they surface as 409 (matching /stop, /respond, and the
+ // synchronous message twin) instead of a 500 from humaStoreError.
+ return nil, humaResolveError(err)
+ }
intent := input.Body.Intent
if intent == "" {
@@ -612,11 +739,21 @@ func (s *Server) humaHandleSessionSubmit(_ context.Context, input *SessionSubmit
// humaHandleSessionMessage is the Huma-typed handler for POST /v0/session/{id}/messages.
-func (s *Server) humaHandleSessionMessage(_ context.Context, input *SessionMessageInput) (*SessionMessageOutput, error) {
+func (s *Server) humaHandleSessionMessage(ctx context.Context, input *SessionMessageInput) (*SessionMessageOutput, error) {
store := s.state.SessionsBeadStore()
if store.Store == nil {
return nil, apierr.ServiceUnavailable.Msg("no bead store configured")
}
+ if err := s.sessionTargetDeliverable(ctx, store.Store, input.ID); err != nil {
+ if errors.Is(err, session.ErrSessionNotFound) {
+ return nil, apierr.SessionNotFound.Msg(fmt.Sprintf("session %q not found and not a configured named session", input.ID))
+ }
+ // Ambiguous bare names and configured-name/live-bead conflicts are
+ // deterministic client addressing errors: map them through the resolve
+ // helper so they surface as 409 (matching /stop, /respond, and the
+ // synchronous message twin) instead of a 500 from humaStoreError.
+ return nil, humaResolveError(err)
+ }
reqID, reqIDErr := newRequestID()
if reqIDErr != nil {
diff --git a/internal/api/huma_handlers_sessions_query.go b/internal/api/huma_handlers_sessions_query.go
index 191afa5b65..8835746462 100644
--- a/internal/api/huma_handlers_sessions_query.go
+++ b/internal/api/huma_handlers_sessions_query.go
@@ -40,17 +40,11 @@ func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInpu
}
sessions, responseByID := filterEnrichReadModel(mgr, listings, input.State, input.Template)
- wantPeek := input.Peek
- hasDeferredQueue := strings.TrimSpace(s.state.CityPath()) != ""
- items := make([]sessionResponse, len(sessions))
- for i, sess := range sessions {
- items[i] = sessionResponseWithReason(sess, responseByID[sess.ID], cfg, s.state.SessionProvider(), hasDeferredQueue)
- s.enrichSessionResponse(&items[i], sess, cfg, s.runtimeSessionResponseHandle(sess), wantPeek, false, false, 0)
- }
-
- // Pagination support. The session default page is the server cap, not the
- // 50-row default other lists use — preserved from the offset-cursor era.
- limit := maxPaginationLimit
+ // Unified page contract (S4): default 100 like every other keyset list.
+ // The offset-cursor era defaulted sessions to the 1000-row server cap;
+ // truncated responses now always mint next_cursor, so a default-size
+ // fetch of a large fleet is walkable instead of silently oversized.
+ limit := defaultPaginationLimit
if input.Limit > 0 {
limit = input.Limit
if limit > maxPaginationLimit {
@@ -58,15 +52,17 @@ func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInpu
}
}
- // items[i] mirrors sessions[i], and the read model returns them in the
- // canonical (created_at DESC, id DESC) total order. The keyset boundary is
+ // The read model returns sessions in the canonical (created_at DESC, id
+ // DESC) total order. Resolve the page before runtime/transcript enrichment:
+ // Codex exact-key lookup can probe bounded date directories, so off-page
+ // rows must not pay that I/O on every dashboard poll. The keyset boundary is
// compared and minted from the UNDERLYING session times (sessions[i]),
// never the response's RFC3339-formatted string, so sub-second precision
// survives the round trip — hence the index-keyed reuse of the shared
// helpers. Total keeps its full-match-count meaning, and a truncated
// response always carries next_cursor — cursor-less requests previously
// truncated silently, the #3208 defect class the bead list already fixed.
- rowIdx := make([]int, len(items))
+ rowIdx := make([]int, len(sessions))
for i := range rowIdx {
rowIdx[i] = i
}
@@ -75,9 +71,18 @@ func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInpu
}
pageIdx, total, hasMore := resolveKeysetPage(rowIdx, infoKey, seek, limit)
nextCursor := mintKeysetNextCursor(pageIdx, infoKey, hasMore)
- page := make([]sessionResponse, len(pageIdx))
+
+ wantPeek := input.Peek
+ hasDeferredQueue := strings.TrimSpace(s.state.CityPath()) != ""
+ pageSessions := make([]session.Info, len(pageIdx))
for j, i := range pageIdx {
- page[j] = items[i]
+ pageSessions[j] = sessions[i]
+ }
+ keyedTranscriptPaths := session.ResolveKeyedTranscriptPaths(sessionTranscriptLookupCandidates(pageSessions), s.sessionLogPaths(), sessionTranscriptProviderFallback(cfg))
+ page := make([]sessionResponse, len(pageSessions))
+ for j, sess := range pageSessions {
+ page[j] = sessionResponseWithReason(sess, responseByID[sess.ID], cfg, s.state.SessionProvider(), hasDeferredQueue)
+ s.enrichSessionResponseWithKeyedPaths(&page[j], sess, cfg, s.runtimeSessionResponseHandle(sess), wantPeek, false, false, 0, keyedTranscriptPaths)
}
return &ListOutput[sessionResponse]{
Index: s.latestIndex(),
@@ -127,7 +132,7 @@ func (s *Server) humaHandleSessionGet(_ context.Context, input *SessionGetInput)
// humaHandleSessionCreate is the Huma-typed handler for POST /v0/sessions.
-func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTranscriptInput) (*IndexOutput[sessionTranscriptGetResponse], error) {
+func (s *Server) humaHandleSessionTranscript(ctx context.Context, input *SessionTranscriptInput) (*IndexOutput[sessionTranscriptGetResponse], error) {
store := s.state.SessionsBeadStore()
if store.Store == nil {
return nil, apierr.ServiceUnavailable.Msg("no bead store configured")
@@ -150,6 +155,17 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr
}
wantRaw := input.Format == "raw"
+ wantStructured := input.Format == "structured"
+ before := strings.TrimSpace(input.Before)
+ after := strings.TrimSpace(input.After)
+ if before != "" && after != "" {
+ return nil, apierr.ValidationFailed.Msg("before and after are mutually exclusive")
+ }
+ if path == "" {
+ if cursorErr := transcriptCursorAbsentError(before, after); cursorErr != nil {
+ return nil, transcriptCursorInvalidatedProblem(cursorErr, "reading session log")
+ }
+ }
if path != "" {
// Compactions() returns (n, provided). When the client omitted
@@ -157,24 +173,54 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr
// entries, so default to 0 (sessionlog's "no pagination"
// sentinel) rather than 1 compaction.
tail, _ := input.Compactions()
- before := input.Before
- after := input.After
+ handle, handleErr := s.workerHandleForSession(store.Store, id)
+ if handleErr != nil {
+ return nil, humaSessionManagerError(handleErr)
+ }
- if before != "" && after != "" {
- return nil, apierr.ValidationFailed.Msg("before and after are mutually exclusive")
+ if wantStructured {
+ history, historyErr := handle.History(worker.WithoutOperationEvents(ctx), worker.HistoryRequest{
+ TailCompactions: tail,
+ BeforeEntryID: before,
+ AfterEntryID: after,
+ })
+ if historyErr != nil {
+ if errors.Is(historyErr, worker.ErrHistoryUnavailable) {
+ return s.structuredTranscriptFallback(info, input.IncludeThinking)
+ }
+ if problem := transcriptCursorInvalidatedProblem(historyErr, "reading session history"); problem != nil {
+ return nil, problem
+ }
+ return nil, apierr.Internal.Msg("reading session history: " + historyErr.Error())
+ }
+ messages, _ := historySnapshotStructuredMessages(history, input.IncludeThinking)
+ projection := structuredSnapshotProjection(SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredHistoryFromSnapshot(history),
+ StructuredMessages: messages,
+ Pagination: history.Pagination,
+ }, input.IncludeThinking)
+ return &IndexOutput[sessionTranscriptGetResponse]{
+ Index: s.latestIndex(),
+ Body: structuredTranscriptResponseFromEvent(projection),
+ }, nil
}
if wantRaw {
- var rawSess *sessionlog.Session
- switch {
- case before != "":
- rawSess, err = sessionlog.ReadProviderFileRawOlder(info.Provider, path, tail, before)
- case after != "":
- rawSess, err = sessionlog.ReadProviderFileRawNewer(info.Provider, path, tail, after)
- default:
- rawSess, err = sessionlog.ReadProviderFileRaw(info.Provider, path, tail)
- }
+ transcript, err := handle.Transcript(ctx, worker.TranscriptRequest{
+ TailCompactions: tail,
+ BeforeEntryID: before,
+ AfterEntryID: after,
+ Raw: true,
+ })
if err != nil {
+ if problem := transcriptCursorInvalidatedProblem(err, "reading session log"); problem != nil {
+ return nil, problem
+ }
return nil, apierr.Internal.Msg("reading session log: " + err.Error())
}
return &IndexOutput[sessionTranscriptGetResponse]{
@@ -184,24 +230,24 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr
Template: info.Template,
Provider: info.Provider,
Format: "raw",
- Messages: wrapRawFrameBytes(rawSess.RawPayloadBytes()),
- Pagination: rawSess.Pagination,
+ Messages: rawMessagesField(wrapRawFrameBytes(transcript.RawMessages)),
+ Pagination: transcript.Session.Pagination,
},
}, nil
}
- var sess *sessionlog.Session
- switch {
- case before != "":
- sess, err = sessionlog.ReadProviderFileOlder(info.Provider, path, tail, before)
- case after != "":
- sess, err = sessionlog.ReadProviderFileNewer(info.Provider, path, tail, after)
- default:
- sess, err = sessionlog.ReadProviderFile(info.Provider, path, tail)
- }
+ transcript, err := handle.Transcript(ctx, worker.TranscriptRequest{
+ TailCompactions: tail,
+ BeforeEntryID: before,
+ AfterEntryID: after,
+ })
if err != nil {
+ if problem := transcriptCursorInvalidatedProblem(err, "reading session log"); problem != nil {
+ return nil, problem
+ }
return nil, apierr.Internal.Msg("reading session log: " + err.Error())
}
+ sess := transcript.Session
turns := make([]outputTurn, 0, len(sess.Messages))
for _, entry := range sess.Messages {
@@ -224,6 +270,10 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr
}, nil
}
+ if wantStructured {
+ return s.structuredTranscriptFallback(info, input.IncludeThinking)
+ }
+
if wantRaw {
return &IndexOutput[sessionTranscriptGetResponse]{
Index: s.latestIndex(),
@@ -232,7 +282,7 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr
Template: info.Template,
Provider: info.Provider,
Format: "raw",
- Messages: []SessionRawMessageFrame{},
+ Messages: rawMessagesField(nil),
},
}, nil
}
@@ -270,6 +320,32 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr
}, nil
}
+func (s *Server) structuredTranscriptFallback(info session.Info, includeThinking bool) (*IndexOutput[sessionTranscriptGetResponse], error) {
+ activity := string(worker.TailActivityIdle)
+ output := ""
+ if info.State == session.StateActive && s.state.SessionProvider().IsRunning(info.SessionName) {
+ activity = string(worker.TailActivityInTurn)
+ peekOutput, peekErr := s.state.SessionProvider().Peek(info.SessionName, 100)
+ if peekErr != nil {
+ return nil, apierr.Internal.Msg("peeking session: " + peekErr.Error())
+ }
+ output = peekOutput
+ }
+ projection := structuredSnapshotProjection(SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, activity),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }, includeThinking)
+ return &IndexOutput[sessionTranscriptGetResponse]{
+ Index: s.latestIndex(),
+ Body: structuredTranscriptResponseFromEvent(projection),
+ }, nil
+}
+
// --- Session Pending ---
// humaHandleSessionPending is the Huma-typed handler for GET /v0/session/{id}/pending.
diff --git a/internal/api/huma_handlers_sessions_stream.go b/internal/api/huma_handlers_sessions_stream.go
index cf8678c9e8..7fb4888a81 100644
--- a/internal/api/huma_handlers_sessions_stream.go
+++ b/internal/api/huma_handlers_sessions_stream.go
@@ -44,6 +44,9 @@ func (s *Server) resolveSessionStream(ctx context.Context, input *SessionStreamI
history, historyErr := handle.History(worker.WithoutOperationEvents(ctx), historyReq)
hasHistory := historyErr == nil && history != nil
if historyErr != nil && !errors.Is(historyErr, worker.ErrHistoryUnavailable) {
+ if problem := transcriptCursorInvalidatedProblem(historyErr, "reading session history"); problem != nil {
+ return nil, problem
+ }
return nil, apierr.Internal.Msg("reading session history: " + historyErr.Error())
}
@@ -52,7 +55,7 @@ func (s *Server) resolveSessionStream(ctx context.Context, input *SessionStreamI
return nil, humaSessionManagerError(stateErr)
}
running := workerPhaseHasLiveOutput(state.Phase)
- if !hasHistory && !running {
+ if !hasHistory && !running && input.Format != "structured" {
return nil, apierr.SessionNotFound.Msg("session " + id + " has no live output")
}
@@ -79,7 +82,7 @@ func (s *Server) checkSessionStream(ctx context.Context, input *SessionStreamInp
// streamSession is the SSE streaming callback for GET /v0/session/{id}/stream.
-func (s *Server) streamSession(hctx huma.Context, input *SessionStreamInput, send sse.Sender) {
+func (s *Server) streamSession(hctx huma.Context, input *SessionStreamInput, send StringIDSender) {
reqCtx := hctx.Context()
state := input.resolved
if state == nil {
@@ -103,6 +106,8 @@ func (s *Server) streamSession(hctx huma.Context, input *SessionStreamInput, sen
hasHistory := state.hasHistory
running := state.running
format := input.Format
+ resumeToken := sessionStreamResumeToken(input.LastEventID, input.AfterCursor)
+ integerSend := integerSSESender(send)
// Custom session state headers.
if info.State != "" {
@@ -114,15 +119,22 @@ func (s *Server) streamSession(hctx huma.Context, input *SessionStreamInput, sen
flushSSEHeaders(hctx)
if info.Closed {
- if format == "raw" {
- s.emitClosedSessionSnapshotRawHuma(send, info, history)
- } else {
- s.emitClosedSessionSnapshotHuma(send, info, history)
+ switch format {
+ case "raw":
+ s.emitClosedSessionSnapshotRawHuma(integerSend, info, history)
+ case "structured":
+ s.emitClosedSessionSnapshotStructuredHuma(send, info, history, input.IncludeThinking, resumeToken)
+ default:
+ s.emitClosedSessionSnapshotHuma(integerSend, info, history)
}
return
}
+ if format == "structured" && !hasHistory && !running {
+ s.emitStructuredFallbackSnapshotHuma(send, info, "", input.IncludeThinking, resumeToken)
+ return
+ }
if format == "raw" {
- _ = send(sse.Message{ID: 0, Data: SessionStreamRawMessageEvent{
+ _ = integerSend(sse.Message{ID: 0, Data: SessionStreamRawMessageEvent{
ID: info.ID,
Template: info.Template,
Provider: info.Provider,
@@ -132,15 +144,20 @@ func (s *Server) streamSession(hctx huma.Context, input *SessionStreamInput, sen
}
switch {
case hasHistory:
- if format == "raw" {
- s.streamSessionTranscriptLogRawHuma(reqCtx, send, info, handle, history, historyReq)
- } else {
- s.streamSessionTranscriptLogHuma(reqCtx, send, info, handle, history)
+ switch format {
+ case "raw":
+ s.streamSessionTranscriptLogRawHuma(reqCtx, integerSend, info, handle, history, historyReq)
+ case "structured":
+ s.streamSessionTranscriptLogStructuredHuma(reqCtx, send, info, handle, history, input.IncludeThinking, resumeToken, "", "")
+ default:
+ s.streamSessionTranscriptLogHuma(reqCtx, integerSend, info, handle, history)
}
+ case format == "structured":
+ s.streamSessionPeekStructuredHuma(reqCtx, send, info, handle, input.IncludeThinking, resumeToken)
case format == "raw":
- s.streamSessionPeekRawHuma(reqCtx, send, info)
+ s.streamSessionPeekRawHuma(reqCtx, integerSend, info)
default:
- s.streamSessionPeekHuma(reqCtx, send, info)
+ s.streamSessionPeekHuma(reqCtx, integerSend, info)
}
}
@@ -181,3 +198,40 @@ func (s *Server) emitClosedSessionSnapshotRawHuma(send sse.Sender, info session.
}})
_ = send(sse.Message{ID: 2, Data: SessionActivityEvent{Activity: "idle"}})
}
+
+func (s *Server) emitClosedSessionSnapshotStructuredHuma(send StringIDSender, info session.Info, history *worker.HistorySnapshot, includeThinking bool, resumeToken string) {
+ if history == nil {
+ s.emitStructuredFallbackSnapshotHuma(send, info, "", includeThinking, resumeToken)
+ return
+ }
+ messages, _ := historySnapshotStructuredMessages(history, includeThinking)
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredHistoryFromSnapshot(history),
+ StructuredMessages: messages,
+ }
+ if update := buildStructuredStreamUpdate(resumeToken, projection, includeThinking); update != nil {
+ _ = send(StringIDMessage{ID: update.History.Cursor.ResumeToken, Data: *update})
+ }
+ _ = send(StringIDMessage{Data: SessionActivityEvent{Activity: "idle"}})
+}
+
+func (s *Server) emitStructuredFallbackSnapshotHuma(send StringIDSender, info session.Info, output string, includeThinking bool, resumeToken string) {
+ projection := SessionStreamStructuredMessageEvent{
+ ID: info.ID,
+ Template: info.Template,
+ Provider: info.Provider,
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: structuredFallbackHistory(info.ID, info.SessionKey, string(worker.TailActivityIdle)),
+ StructuredMessages: structuredFallbackMessages(info.ID, info.Provider, output),
+ }
+ if update := buildStructuredStreamUpdate(resumeToken, projection, includeThinking); update != nil {
+ _ = send(StringIDMessage{ID: update.History.Cursor.ResumeToken, Data: *update})
+ }
+ _ = send(StringIDMessage{Data: SessionActivityEvent{Activity: "idle"}})
+}
diff --git a/internal/api/huma_handlers_sling.go b/internal/api/huma_handlers_sling.go
index 23c38f1770..1169889106 100644
--- a/internal/api/huma_handlers_sling.go
+++ b/internal/api/huma_handlers_sling.go
@@ -31,6 +31,11 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling
ScopeKind: input.Body.ScopeKind,
ScopeRef: input.Body.ScopeRef,
Force: input.Body.Force,
+ Reassign: input.Body.Reassign,
+ Merge: input.Body.Merge,
+ NoConvoy: input.Body.NoConvoy,
+ Owned: input.Body.Owned,
+ NoFormula: input.Body.NoFormula,
}
if body.Target == "" {
@@ -65,6 +70,7 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling
defaultFormulaLaunch := body.Formula == "" &&
body.AttachedBeadID == "" &&
body.Bead != "" &&
+ !body.NoFormula &&
agentCfg.EffectiveDefaultSlingFormula() != "" &&
(len(body.Vars) > 0 || body.Title != "" || body.ScopeKind != "" || body.ScopeRef != "")
if body.Formula == "" && body.AttachedBeadID != "" {
@@ -79,6 +85,15 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling
if body.ScopeKind != "" && body.ScopeKind != "city" && body.ScopeKind != "rig" {
return nil, apierr.InvalidRequest.Msg("scope_kind must be 'city' or 'rig'")
}
+ if body.Owned && body.NoConvoy {
+ return nil, huma.Error400BadRequest("owned requires a convoy (cannot use with no_convoy)")
+ }
+ if body.Merge != "" && body.Merge != "direct" && body.Merge != "mr" && body.Merge != "local" {
+ return nil, huma.Error400BadRequest("merge must be 'direct', 'mr', or 'local'")
+ }
+ if body.NoFormula && (body.Formula != "" || body.AttachedBeadID != "") {
+ return nil, huma.Error400BadRequest("no_formula conflicts with formula/attached_bead_id")
+ }
if body.ScopeKind == "rig" && body.ScopeRef != "" {
if agentCfg.Dir != body.ScopeRef {
msg := "scope_ref " + body.ScopeRef + " conflicts with resolved target rig " + agentCfg.Dir
diff --git a/internal/api/huma_handlers_supervisor.go b/internal/api/huma_handlers_supervisor.go
index ad56f5ebc8..97e263f9f3 100644
--- a/internal/api/huma_handlers_supervisor.go
+++ b/internal/api/huma_handlers_supervisor.go
@@ -92,7 +92,7 @@ type cityCreateRequest struct {
// request_id. Polling is unnecessary.
type asyncAcceptedResponse struct {
RequestID string `json:"request_id" doc:"Correlation ID. Watch /v0/events/stream for request.result.city.create, request.result.city.unregister, or request.failed with this request_id."`
- EventCursor string `json:"event_cursor" doc:"Supervisor event-stream cursor captured before the async request was accepted. Pass this value as after_cursor to /v0/events/stream to receive the request result without replaying unrelated historical backlog. A value of 0 can also mean no event provider is configured or every event log is empty."`
+ EventCursor string `json:"event_cursor" doc:"Supervisor event-stream cursor captured before the async request was accepted. Pass this value as after_cursor to /v0/events/stream to receive the request result. A populated cursor resumes each city at its exact per-city position, so no unrelated historical backlog is replayed. The value 0 is returned only when no event provider is registered at capture time; passing 0 back requests a replay from zero for every provider present at resume time, which still delivers this request result because no provider predates the capture boundary."`
}
// SupervisorCityCreateInput is the input for POST /v0/city.
@@ -142,7 +142,7 @@ type SupervisorEventListInput struct {
// SupervisorEventListOutput is the response for GET /v0/events (supervisor scope).
type SupervisorEventListOutput struct {
Body struct {
- EventCursor string `json:"event_cursor" doc:"Supervisor event-stream cursor captured before the history snapshot was listed. Pass this value as after_cursor to /v0/events/stream to receive events emitted after the snapshot boundary without replaying unrelated historical backlog."`
+ EventCursor string `json:"event_cursor" doc:"Supervisor event-stream cursor captured before the history snapshot was listed. Pass this value as after_cursor to /v0/events/stream to receive events emitted after the snapshot boundary. A populated cursor resumes each city at its exact per-city position, so no unrelated historical backlog is replayed. The value 0 is returned only when no event provider is registered at capture time; passing 0 back requests a replay from zero for every provider present at resume time."`
Items []WireTaggedEvent `json:"items"`
Total int `json:"total"`
}
@@ -751,6 +751,12 @@ func supervisorEventCursorFromMux(mux *events.Multiplexer) (string, error) {
if cursor := events.FormatCursor(cursors); cursor != "" {
return cursor, nil
}
+ // No providers are registered yet, so there is no per-city boundary to
+ // capture. Return literal "0": resolveGlobalStreamCursors treats it as a
+ // replay-from-zero request, which lets an async caller still catch its
+ // result event once its city registers. Because no provider predates this
+ // cursor, the replay carries no pre-capture backlog. Callers that want a
+ // no-backlog head start must omit after_cursor instead of sending "0".
return "0", nil
}
@@ -761,11 +767,12 @@ func supervisorEventCursorFromMux(mux *events.Multiplexer) (string, error) {
// which now replays a city's entire retained history across archives.
//
// With no resume cursor — a head-start client or the attach-only precheck —
-// every city starts from its latest cursor. With a resume cursor, cities the
-// cursor omits are floored to their latest cursor so a cursor that predates a
-// newly registered city cannot trigger a full-history flood for it. It fails
-// closed on a LatestCursor error rather than letting unresolved cities default
-// to cursor 0. The returned map is always non-nil on success.
+// every city starts from its latest cursor. The literal cursor "0" explicitly
+// requests replay from zero for every current provider. With any other resume
+// cursor, cities the cursor omits are floored to their latest cursor so a cursor
+// that predates a newly registered city cannot trigger a full-history flood for
+// it. It fails closed on a LatestCursor error rather than letting unresolved
+// cities default to cursor 0. The returned map is always non-nil on success.
func resolveGlobalStreamCursors(mux *events.Multiplexer, resumeCursor string) (map[string]uint64, error) {
resumeCursor = strings.TrimSpace(resumeCursor)
if resumeCursor == "" {
@@ -787,6 +794,10 @@ func resolveGlobalStreamCursors(mux *events.Multiplexer, resumeCursor string) (m
return nil, err
}
for city, seq := range latest {
+ if resumeCursor == "0" {
+ cursors[city] = 0
+ continue
+ }
if _, ok := cursors[city]; !ok {
cursors[city] = seq
}
diff --git a/internal/api/huma_sse_test.go b/internal/api/huma_sse_test.go
index 89fe6aa130..4651f41689 100644
--- a/internal/api/huma_sse_test.go
+++ b/internal/api/huma_sse_test.go
@@ -135,6 +135,115 @@ func TestEventStreamsUseTypedEnvelopeUnions(t *testing.T) {
}
}
+func TestSessionStreamStructuredEventInSpec(t *testing.T) {
+ for _, source := range eventStreamSpecCases(t) {
+ t.Run(source.name, func(t *testing.T) {
+ gotRef := sseEventDataRef(t, source.spec, "/v0/city/{cityName}/session/{id}/stream", "structured")
+ if gotRef != "#/components/schemas/SessionStreamStructuredMessageEvent" {
+ t.Fatalf("session structured event data ref = %q, want SessionStreamStructuredMessageEvent", gotRef)
+ }
+ gotRef = sseEventDataRef(t, source.spec, "/v0/city/{cityName}/session/{id}/stream", "pending_cleared")
+ if gotRef != "#/components/schemas/SessionPendingClearedEvent" {
+ t.Fatalf("session pending_cleared event data ref = %q, want SessionPendingClearedEvent", gotRef)
+ }
+
+ schemas := componentSchemas(t, source.spec)
+ blockSchema := schemaByRef(t, schemas, "#/components/schemas/SessionStructuredBlock")
+ mapping := structuredDiscriminatorMapping(t, "SessionStructuredBlock", blockSchema, "type")
+ toolUseSchema := schemaByRef(t, schemas, mapping["tool_use"])
+ toolUseProperties := structuredSchemaProperties(t, "SessionStructuredBlockToolUse", toolUseSchema)
+ inputProperty, ok := toolUseProperties["input"].(map[string]any)
+ if !ok {
+ t.Fatal("SessionStructuredBlockToolUse.input property missing")
+ }
+ if gotRef, _ := inputProperty["$ref"].(string); gotRef != "#/components/schemas/SessionStructuredToolInput" {
+ t.Fatalf("SessionStructuredBlockToolUse.input ref = %q, want SessionStructuredToolInput", gotRef)
+ }
+ toolResultSchema := schemaByRef(t, schemas, mapping["tool_result"])
+ toolResultProperties := structuredSchemaProperties(t, "SessionStructuredBlockToolResult", toolResultSchema)
+ contentProperty, ok := toolResultProperties["content"].(map[string]any)
+ if !ok {
+ t.Fatal("SessionStructuredBlockToolResult.content property missing")
+ }
+ if !schemaIncludesJSONType(contentProperty, "string") {
+ t.Fatalf("SessionStructuredBlockToolResult.content does not include string: %#v", contentProperty)
+ }
+ })
+ }
+}
+
+func schemaIncludesJSONType(schema map[string]any, want string) bool {
+ if got, ok := schema["type"].(string); ok {
+ return got == want
+ }
+ values, ok := schema["type"].([]any)
+ if !ok {
+ return false
+ }
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
+
+func TestSessionTranscriptStructuredSchemaExcludesRawMessages(t *testing.T) {
+ for _, source := range eventStreamSpecCases(t) {
+ t.Run(source.name, func(t *testing.T) {
+ schemas := componentSchemas(t, source.spec)
+ transcriptSchema, ok := schemas["SessionTranscriptGetResponse"]
+ if !ok {
+ t.Fatal("components.schemas missing SessionTranscriptGetResponse")
+ }
+ oneOf, ok := transcriptSchema["oneOf"].([]any)
+ if !ok || len(oneOf) == 0 {
+ t.Fatalf("SessionTranscriptGetResponse oneOf missing: %#v", transcriptSchema)
+ }
+ discriminator, ok := transcriptSchema["discriminator"].(map[string]any)
+ if !ok {
+ t.Fatalf("SessionTranscriptGetResponse discriminator missing: %#v", transcriptSchema)
+ }
+ if property, _ := discriminator["propertyName"].(string); property != "format" {
+ t.Fatalf("SessionTranscriptGetResponse discriminator property = %q, want format", property)
+ }
+ mapping, ok := discriminator["mapping"].(map[string]any)
+ if !ok {
+ t.Fatalf("SessionTranscriptGetResponse discriminator mapping missing: %#v", discriminator)
+ }
+ structuredRef, _ := mapping["structured"].(string)
+ if structuredRef != "#/components/schemas/SessionTranscriptStructuredResponse" {
+ t.Fatalf("structured mapping = %q, want SessionTranscriptStructuredResponse", structuredRef)
+ }
+ rawRef, _ := mapping["raw"].(string)
+ if rawRef != "#/components/schemas/SessionTranscriptRawResponse" {
+ t.Fatalf("raw mapping = %q, want SessionTranscriptRawResponse", rawRef)
+ }
+
+ structuredSchema := schemaByRef(t, schemas, structuredRef)
+ structuredProps, ok := structuredSchema["properties"].(map[string]any)
+ if !ok {
+ t.Fatalf("structured transcript properties missing: %#v", structuredSchema)
+ }
+ if _, ok := structuredProps["messages"]; ok {
+ t.Fatalf("structured transcript schema exposes raw messages: %#v", structuredProps["messages"])
+ }
+ if _, ok := structuredProps["structured_messages"]; !ok {
+ t.Fatalf("structured transcript schema missing structured_messages: %#v", structuredProps)
+ }
+
+ rawSchema := schemaByRef(t, schemas, rawRef)
+ rawProps, ok := rawSchema["properties"].(map[string]any)
+ if !ok {
+ t.Fatalf("raw transcript properties missing: %#v", rawSchema)
+ }
+ if _, ok := rawProps["messages"]; !ok {
+ t.Fatalf("raw transcript schema missing raw messages: %#v", rawProps)
+ }
+ })
+ }
+}
+
func TestTypedEventEnvelopeUnionsCoverKnownEventTypes(t *testing.T) {
for _, source := range eventStreamSpecCases(t) {
t.Run(source.name, func(t *testing.T) {
diff --git a/internal/api/huma_types.go b/internal/api/huma_types.go
index bcca086d60..20e44eb45a 100644
--- a/internal/api/huma_types.go
+++ b/internal/api/huma_types.go
@@ -132,13 +132,16 @@ func (t *TailParam) Compactions() (n int, provided bool) {
}
// PaginationParam is an embeddable input mixin for paginated list endpoints.
-// Limit carries a minimum: validation tag so malformed requests (e.g.
-// limit=-1) fail Huma validation with 422 instead of silently defaulting
-// or — under older paginate() behavior — panicking with a slice-bounds
-// error.
+// Limit carries minimum/maximum validation tags so malformed requests (e.g.
+// limit=-1 or limit=5000) fail Huma validation with 422 instead of silently
+// defaulting or clamping, and a default so the spec documents the unified
+// page contract (default 100, maximum 1000 — pinned by the pagination
+// dialect guard). Huma injects the default when the param is omitted, so
+// handlers see Limit=100 for a bare request; an explicit limit=0 still
+// reaches the handler as 0 and means "server default" there.
type PaginationParam struct {
- Cursor string `query:"cursor" doc:"Pagination cursor from a previous response's next_cursor field." required:"false"`
- Limit int `query:"limit" minimum:"0" doc:"Maximum number of results to return. 0 = server default." required:"false"`
+ Cursor string `query:"cursor" doc:"Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page." required:"false"`
+ Limit int `query:"limit" minimum:"0" maximum:"1000" default:"100" doc:"Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected." required:"false"`
}
// --- Shared output types ---
diff --git a/internal/api/huma_types_events.go b/internal/api/huma_types_events.go
index 95e505725b..aa4c121f4d 100644
--- a/internal/api/huma_types_events.go
+++ b/internal/api/huma_types_events.go
@@ -99,6 +99,12 @@ type SessionActivityEvent struct {
Activity string `json:"activity" doc:"Session activity state: 'idle' or 'in-turn'." example:"idle"`
}
+// SessionPendingClearedEvent reports that a previously pending interaction is
+// no longer awaiting a response.
+type SessionPendingClearedEvent struct {
+ RequestID string `json:"request_id" doc:"Request ID of the interaction that was cleared."`
+}
+
// resolveAfterSeq returns the reconnect position from Last-Event-ID or after_seq.
func (e *EventStreamInput) resolveAfterSeq() uint64 {
if e.LastEventID != "" {
diff --git a/internal/api/huma_types_patches.go b/internal/api/huma_types_patches.go
index 6d65d33187..c2c2afd663 100644
--- a/internal/api/huma_types_patches.go
+++ b/internal/api/huma_types_patches.go
@@ -261,8 +261,8 @@ type StatusSessionCountsDetail struct {
type StatusStoreHealth struct {
Path string `json:"path" doc:"On-disk path of the Dolt store."`
SizeBytes int64 `json:"size_bytes" doc:"Total bytes of the store directory."`
- LiveRows int `json:"live_rows" doc:"Live bead row count."`
- RatioMB float64 `json:"ratio_mb_per_row" doc:"Derived megabytes per row."`
+ LiveRows int `json:"live_rows" doc:"Retained bead row count used as the denominator, including open and closed beads."`
+ RatioMB float64 `json:"ratio_mb_per_row" doc:"Derived megabytes per retained row, including open and closed beads."`
Warning bool `json:"warning" doc:"True when maintenance is overdue."`
ThresholdMB float64 `json:"threshold_mb_per_row" doc:"Ratio threshold; a ratio above this trips warning."`
LastGCAt string `json:"last_gc_at,omitempty" doc:"RFC3339 timestamp of last maintenance run."`
diff --git a/internal/api/huma_types_rigs.go b/internal/api/huma_types_rigs.go
index ce16fdefb1..8f7674924c 100644
--- a/internal/api/huma_types_rigs.go
+++ b/internal/api/huma_types_rigs.go
@@ -25,10 +25,13 @@ type RigGetInput struct {
// digest is computed over the exact wire body. Path is optional at the schema
// level because a git_url clone derives it server-side; the sync (git_url
// absent) branch enforces path presence in the handler, preserving the prior
-// 422-on-missing-path contract.
+// 422-on-missing-path contract. The Idempotency-Key header applies the S2
+// create-idempotency contract to the synchronous (git_url-absent) path; the
+// async git_url path carries its own idempotency via the request_id admission
+// state machine.
type RigCreateInput struct {
CityScope
- IdempotencyKey string `header:"Idempotency-Key" required:"false" doc:"Idempotency key for safe retries."`
+ IdempotencyKey string `header:"Idempotency-Key" required:"false" doc:"Idempotency key for safe retries (synchronous create)."`
Body RigCreateBody
}
diff --git a/internal/api/huma_types_sessions.go b/internal/api/huma_types_sessions.go
index 40831f7336..21481fb5e9 100644
--- a/internal/api/huma_types_sessions.go
+++ b/internal/api/huma_types_sessions.go
@@ -85,17 +85,21 @@ type SessionIDInput struct {
type SessionTranscriptInput struct {
CityScope
TailParam
- ID string `path:"id" doc:"Session ID, alias, or runtime session_name."`
- Format string `query:"format" required:"false" doc:"Transcript format: conversation (default) or raw."`
- Before string `query:"before" required:"false" doc:"Pagination cursor: return entries before this UUID."`
- After string `query:"after" required:"false" doc:"Pagination cursor: return entries after this UUID."`
+ ID string `path:"id" doc:"Session ID, alias, or runtime session_name."`
+ Format string `query:"format" required:"false" enum:"conversation,raw,structured" doc:"Transcript format: conversation (default), raw, or structured."`
+ IncludeThinking bool `query:"include_thinking" required:"false" doc:"Include thinking block text and signature in structured responses. Defaults to false; both are redacted otherwise."`
+ Before string `query:"before" required:"false" doc:"Pagination cursor: return entries before this stable transcript entry ID."`
+ After string `query:"after" required:"false" doc:"Pagination cursor: return entries after this stable transcript entry ID."`
}
// SessionStreamInput is the Huma input for GET /v0/city/{cityName}/session/{id}/stream.
type SessionStreamInput struct {
CityScope
- ID string `path:"id" doc:"Session ID, alias, or runtime session_name."`
- Format string `query:"format" required:"false" doc:"Transcript format: conversation (default) or raw."`
+ ID string `path:"id" doc:"Session ID, alias, or runtime session_name."`
+ Format string `query:"format" required:"false" enum:"conversation,raw,structured" doc:"Transcript format: conversation (default), raw, or structured."`
+ IncludeThinking bool `query:"include_thinking" required:"false" doc:"Include thinking block text and signature in structured stream frames. Defaults to false; both are redacted otherwise."`
+ AfterCursor string `query:"after_cursor" required:"false" maxLength:"2048" doc:"Opaque structured transcript resume cursor from the REST snapshot. Last-Event-ID takes precedence on automatic SSE reconnect."`
+ LastEventID string `header:"Last-Event-ID" required:"false" maxLength:"2048" doc:"Opaque structured transcript resume cursor from the last received SSE frame. Takes precedence over after_cursor."`
resolved *sessionStreamState
}
diff --git a/internal/api/huma_types_sling.go b/internal/api/huma_types_sling.go
index 19159e1b0e..143d79f082 100644
--- a/internal/api/huma_types_sling.go
+++ b/internal/api/huma_types_sling.go
@@ -24,5 +24,10 @@ type SlingInput struct {
ScopeKind string `json:"scope_kind,omitempty" doc:"Scope kind (city or rig)."`
ScopeRef string `json:"scope_ref,omitempty" doc:"Scope reference."`
Force bool `json:"force,omitempty" doc:"Bypass cross-rig guards; for direct bead routes, also bypass missing-bead validation. Formula-backed graph routes may replace existing live workflow roots but still require the source bead to exist."`
+ Reassign bool `json:"reassign,omitempty" doc:"Clear any existing human assignee on the bead before routing, so a bead claimed via bd update --claim is handed to the target's pool."`
+ Merge string `json:"merge,omitempty" doc:"Merge strategy: direct, mr, or local."`
+ NoConvoy bool `json:"no_convoy,omitempty" doc:"Do not create an auto-convoy for the routed bead."`
+ Owned bool `json:"owned,omitempty" doc:"Mark the routed bead as owned by the target."`
+ NoFormula bool `json:"no_formula,omitempty" doc:"Suppress the target's default_sling_formula even when configured."`
}
}
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index 57a051d0ca..32f77493cc 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -828,7 +828,7 @@
"additionalProperties": false,
"properties": {
"event_cursor": {
- "description": "Supervisor event-stream cursor captured before the async request was accepted. Pass this value as after_cursor to /v0/events/stream to receive the request result without replaying unrelated historical backlog. A value of 0 can also mean no event provider is configured or every event log is empty.",
+ "description": "Supervisor event-stream cursor captured before the async request was accepted. Pass this value as after_cursor to /v0/events/stream to receive the request result. A populated cursor resumes each city at its exact per-city position, so no unrelated historical backlog is replayed. The value 0 is returned only when no event provider is registered at capture time; passing 0 back requests a replay from zero for every provider present at resume time, which still delivers this request result because no provider predates the capture boundary.",
"type": "string"
},
"request_id": {
@@ -2331,6 +2331,7 @@
"urn:gascity:error:sling-missing-bead",
"urn:gascity:error:sling-source-workflow-conflict",
"urn:gascity:error:store-unavailable",
+ "urn:gascity:error:transcript-cursor-invalidated",
"urn:gascity:error:validation-failed",
"urn:gascity:error:wait-not-found",
"urn:gascity:error:webhook-rejected",
@@ -2377,6 +2378,7 @@
"urn:gascity:error:sling-missing-bead",
"urn:gascity:error:sling-source-workflow-conflict",
"urn:gascity:error:store-unavailable",
+ "urn:gascity:error:transcript-cursor-invalidated",
"urn:gascity:error:validation-failed",
"urn:gascity:error:wait-not-found",
"urn:gascity:error:webhook-rejected",
@@ -5107,6 +5109,13 @@
"check": {
"type": "string"
},
+ "check_timeout": {
+ "type": "string"
+ },
+ "check_timeout_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
"description": {
"type": "string"
},
@@ -5434,6 +5443,9 @@
"PaginationInfo": {
"additionalProperties": false,
"properties": {
+ "has_newer_messages": {
+ "type": "boolean"
+ },
"has_older_messages": {
"type": "boolean"
},
@@ -7530,6 +7542,19 @@
},
"type": "object"
},
+ "SessionPendingClearedEvent": {
+ "additionalProperties": false,
+ "properties": {
+ "request_id": {
+ "description": "Request ID of the interaction that was cleared.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "request_id"
+ ],
+ "type": "object"
+ },
"SessionPendingResponse": {
"additionalProperties": false,
"properties": {
@@ -7797,7 +7822,7 @@
"type": "object"
},
"SessionStreamCommonEvent": {
- "description": "Non-message events emitted on the session SSE stream: activity transitions, pending interactions, and keepalive heartbeats. The concrete variant is identified by the SSE event name.",
+ "description": "Non-message events emitted on the session SSE stream: activity transitions, pending-interaction lifecycle updates, and keepalive heartbeats. The concrete variant is identified by the SSE event name.",
"oneOf": [
{
"$ref": "#/components/schemas/SessionActivityEvent"
@@ -7805,6 +7830,9 @@
{
"$ref": "#/components/schemas/PendingInteraction"
},
+ {
+ "$ref": "#/components/schemas/SessionPendingClearedEvent"
+ },
{
"$ref": "#/components/schemas/HeartbeatEvent"
}
@@ -7824,7 +7852,7 @@
"$ref": "#/components/schemas/PaginationInfo"
},
"provider": {
- "description": "Producing provider identifier (claude, codex, gemini, open-code, etc.).",
+ "description": "Producing provider identifier (claude, codex, gemini, opencode, etc.).",
"type": "string"
},
"template": {
@@ -7872,7 +7900,7 @@
"$ref": "#/components/schemas/PaginationInfo"
},
"provider": {
- "description": "Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing.",
+ "description": "Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing.",
"type": "string"
},
"template": {
@@ -7888,90 +7916,2793 @@
],
"type": "object"
},
- "SessionSubmitInputBody": {
+ "SessionStreamStructuredMessageEvent": {
"additionalProperties": false,
+ "description": "Provider-neutral structured transcript update with explicit snapshot, upsert, or reset application semantics.",
"properties": {
- "intent": {
- "$ref": "#/components/schemas/SubmitIntent",
- "description": "Submit intent; empty defaults to \"default\".",
+ "format": {
+ "const": "structured",
+ "description": "Always structured for this event.",
+ "type": "string"
+ },
+ "history": {
+ "$ref": "#/components/schemas/SessionStructuredHistory",
+ "description": "Normalized worker-history envelope for this snapshot or stream batch."
+ },
+ "id": {
+ "type": "string"
+ },
+ "operation": {
+ "description": "How the client applies this structured frame: replace from a snapshot/reset or merge an upsert.",
"enum": [
- "default",
- "follow_up",
- "interrupt_now"
- ]
+ "snapshot",
+ "upsert",
+ "reset"
+ ],
+ "type": "string"
},
- "message": {
- "description": "Message text to submit.",
- "minLength": 1,
- "pattern": "\\S",
+ "pagination": {
+ "$ref": "#/components/schemas/PaginationInfo"
+ },
+ "provider": {
+ "description": "Producing provider identifier (claude, codex, gemini, opencode, etc.).",
+ "type": "string"
+ },
+ "reset_reason": {
+ "description": "Present if and only if operation is reset; absent for snapshot and upsert. Identifies why the reset replaced the client transcript.",
+ "enum": [
+ "resume_invalid",
+ "stream_changed",
+ "cursor_invalidated",
+ "history_rewritten"
+ ],
+ "type": "string"
+ },
+ "schema_version": {
+ "const": "session.structured.v1",
+ "description": "Structured session transcript schema version.",
+ "type": "string"
+ },
+ "structured_messages": {
+ "description": "Provider-normalized structured messages.",
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredMessage"
+ },
+ "type": "array"
+ },
+ "template": {
"type": "string"
}
},
"required": [
- "message"
+ "id",
+ "template",
+ "provider",
+ "format",
+ "schema_version",
+ "operation",
+ "history",
+ "structured_messages"
],
+ "title": "Structured session stream message",
"type": "object"
},
- "SessionSubmitSucceededPayload": {
+ "SessionStructuredArgument": {
"additionalProperties": false,
"properties": {
- "intent": {
- "description": "Resolved submit intent (default, follow_up, interrupt_now).",
+ "name": {
"type": "string"
},
- "queued": {
- "description": "Whether the message was queued for later delivery.",
+ "value": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "value"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredBlock": {
+ "description": "Provider-normalized transcript block discriminated by its closed block type vocabulary.",
+ "discriminator": {
+ "mapping": {
+ "image": "#/components/schemas/SessionStructuredBlockImage",
+ "interaction": "#/components/schemas/SessionStructuredBlockInteraction",
+ "text": "#/components/schemas/SessionStructuredBlockText",
+ "thinking": "#/components/schemas/SessionStructuredBlockThinking",
+ "tool_result": "#/components/schemas/SessionStructuredBlockToolResult",
+ "tool_use": "#/components/schemas/SessionStructuredBlockToolUse",
+ "unknown": "#/components/schemas/SessionStructuredBlockUnknown"
+ },
+ "propertyName": "type"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockText"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockThinking"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockToolUse"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockToolResult"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockInteraction"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockImage"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredBlockUnknown"
+ }
+ ],
+ "title": "Structured transcript block"
+ },
+ "SessionStructuredBlockImage": {
+ "additionalProperties": false,
+ "properties": {
+ "file_path": {
+ "type": "string"
+ },
+ "image_url": {
+ "type": "string"
+ },
+ "mime_type": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "type": {
+ "const": "image",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "title": "SessionStructuredBlockImage",
+ "type": "object"
+ },
+ "SessionStructuredBlockInteraction": {
+ "additionalProperties": false,
+ "properties": {
+ "interaction": {
+ "$ref": "#/components/schemas/SessionStructuredInteraction"
+ },
+ "type": {
+ "const": "interaction",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "title": "SessionStructuredBlockInteraction",
+ "type": "object"
+ },
+ "SessionStructuredBlockText": {
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "type": {
+ "const": "text",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "title": "SessionStructuredBlockText",
+ "type": "object"
+ },
+ "SessionStructuredBlockThinking": {
+ "additionalProperties": false,
+ "properties": {
+ "signature": {
+ "type": "string"
+ },
+ "thinking": {
+ "type": "string"
+ },
+ "type": {
+ "const": "thinking",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "title": "SessionStructuredBlockThinking",
+ "type": "object"
+ },
+ "SessionStructuredBlockToolResult": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "is_error": {
"type": "boolean"
},
- "request_id": {
- "description": "Correlation ID from the 202 response.",
+ "name": {
"type": "string"
},
- "session_id": {
- "description": "Session ID that received the submission.",
+ "structured": {
+ "$ref": "#/components/schemas/SessionStructuredToolResult"
+ },
+ "tool_call_id": {
+ "type": "string"
+ },
+ "type": {
+ "const": "tool_result",
"type": "string"
}
},
"required": [
- "request_id",
- "session_id",
- "queued",
- "intent"
+ "type"
],
+ "title": "SessionStructuredBlockToolResult",
"type": "object"
},
- "SessionTranscriptGetResponse": {
+ "SessionStructuredBlockToolUse": {
"additionalProperties": false,
"properties": {
- "format": {
- "description": "conversation, text, or raw.",
+ "file_path": {
"type": "string"
},
"id": {
"type": "string"
},
- "messages": {
- "description": "Populated for raw format; provider-native frames emitted verbatim as the provider wrote them.",
+ "input": {
+ "$ref": "#/components/schemas/SessionStructuredToolInput"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "const": "tool_use",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "title": "SessionStructuredBlockToolUse",
+ "type": "object"
+ },
+ "SessionStructuredBlockUnknown": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "image_url": {
+ "type": "string"
+ },
+ "input": {
+ "$ref": "#/components/schemas/SessionStructuredToolInput"
+ },
+ "interaction": {
+ "$ref": "#/components/schemas/SessionStructuredInteraction"
+ },
+ "is_error": {
+ "type": "boolean"
+ },
+ "mime_type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "signature": {
+ "type": "string"
+ },
+ "structured": {
+ "$ref": "#/components/schemas/SessionStructuredToolResult"
+ },
+ "text": {
+ "type": "string"
+ },
+ "thinking": {
+ "type": "string"
+ },
+ "tool_call_id": {
+ "type": "string"
+ },
+ "type": {
+ "const": "unknown",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "title": "SessionStructuredBlockUnknown",
+ "type": "object"
+ },
+ "SessionStructuredContinuity": {
+ "additionalProperties": false,
+ "properties": {
+ "compaction_count": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "has_branches": {
+ "type": "boolean"
+ },
+ "note": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "status"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredCursor": {
+ "additionalProperties": false,
+ "properties": {
+ "after_entry_id": {
+ "type": "string"
+ },
+ "resume_token": {
+ "description": "Opaque cursor for an exact structured REST-to-SSE handoff or SSE reconnect.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "resume_token"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredDiagnostic": {
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "code"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredGeneration": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "observed_at": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredHistory": {
+ "additionalProperties": false,
+ "properties": {
+ "continuity": {
+ "$ref": "#/components/schemas/SessionStructuredContinuity"
+ },
+ "cursor": {
+ "$ref": "#/components/schemas/SessionStructuredCursor"
+ },
+ "diagnostics": {
"items": {
- "$ref": "#/components/schemas/SessionRawMessageFrame"
+ "$ref": "#/components/schemas/SessionStructuredDiagnostic"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "gc_session_id": {
+ "type": "string"
+ },
+ "generation": {
+ "$ref": "#/components/schemas/SessionStructuredGeneration"
+ },
+ "logical_conversation_id": {
+ "type": "string"
+ },
+ "provider_session_id": {
+ "type": "string"
+ },
+ "tail_state": {
+ "$ref": "#/components/schemas/SessionStructuredTailState"
+ },
+ "transcript_stream_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "transcript_stream_id",
+ "generation",
+ "cursor",
+ "continuity",
+ "tail_state"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredIDESelection": {
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredInteraction": {
+ "additionalProperties": false,
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "options": {
+ "items": {
+ "type": "string"
},
"type": [
"array",
"null"
]
},
+ "prompt": {
+ "type": "string"
+ },
+ "request_id": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "state"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredMessage": {
+ "description": "Provider-normalized transcript message discriminated by its closed role vocabulary.",
+ "discriminator": {
+ "mapping": {
+ "assistant": "#/components/schemas/SessionStructuredMessageAssistant",
+ "system": "#/components/schemas/SessionStructuredMessageSystem",
+ "tool": "#/components/schemas/SessionStructuredMessageTool",
+ "unknown": "#/components/schemas/SessionStructuredMessageUnknown",
+ "user": "#/components/schemas/SessionStructuredMessageUser"
+ },
+ "propertyName": "role"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/SessionStructuredMessageUnknown"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredMessageUser"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredMessageAssistant"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredMessageSystem"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredMessageTool"
+ }
+ ],
+ "title": "Structured transcript message"
+ },
+ "SessionStructuredMessageAssistant": {
+ "additionalProperties": false,
+ "properties": {
+ "blocks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredBlock"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "const": "assistant",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "unknown",
+ "final",
+ "partial",
+ "superseded"
+ ],
+ "type": "string"
+ },
+ "stop_reason": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "usage": {
+ "$ref": "#/components/schemas/SessionStructuredUsage"
+ }
+ },
+ "required": [
+ "role",
+ "id",
+ "status",
+ "blocks"
+ ],
+ "title": "SessionStructuredMessageAssistant",
+ "type": "object"
+ },
+ "SessionStructuredMessageSystem": {
+ "additionalProperties": false,
+ "properties": {
+ "blocks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredBlock"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "const": "system",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "unknown",
+ "final",
+ "partial",
+ "superseded"
+ ],
+ "type": "string"
+ },
+ "system_event": {
+ "$ref": "#/components/schemas/SessionStructuredSystemEvent"
+ },
+ "timestamp": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "role",
+ "id",
+ "status",
+ "blocks"
+ ],
+ "title": "SessionStructuredMessageSystem",
+ "type": "object"
+ },
+ "SessionStructuredMessageTool": {
+ "additionalProperties": false,
+ "properties": {
+ "blocks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredBlock"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "const": "tool",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "unknown",
+ "final",
+ "partial",
+ "superseded"
+ ],
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "role",
+ "id",
+ "status",
+ "blocks"
+ ],
+ "title": "SessionStructuredMessageTool",
+ "type": "object"
+ },
+ "SessionStructuredMessageUnknown": {
+ "additionalProperties": false,
+ "properties": {
+ "blocks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredBlock"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "const": "unknown",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "unknown",
+ "final",
+ "partial",
+ "superseded"
+ ],
+ "type": "string"
+ },
+ "stop_reason": {
+ "type": "string"
+ },
+ "system_event": {
+ "$ref": "#/components/schemas/SessionStructuredSystemEvent"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "usage": {
+ "$ref": "#/components/schemas/SessionStructuredUsage"
+ },
+ "user_prompt": {
+ "$ref": "#/components/schemas/SessionStructuredUserPrompt"
+ }
+ },
+ "required": [
+ "role",
+ "id",
+ "status",
+ "blocks"
+ ],
+ "title": "SessionStructuredMessageUnknown",
+ "type": "object"
+ },
+ "SessionStructuredMessageUser": {
+ "additionalProperties": false,
+ "properties": {
+ "blocks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredBlock"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "const": "user",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "unknown",
+ "final",
+ "partial",
+ "superseded"
+ ],
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "user_prompt": {
+ "$ref": "#/components/schemas/SessionStructuredUserPrompt"
+ }
+ },
+ "required": [
+ "role",
+ "id",
+ "status",
+ "blocks"
+ ],
+ "title": "SessionStructuredMessageUser",
+ "type": "object"
+ },
+ "SessionStructuredPatchHunk": {
+ "additionalProperties": false,
+ "properties": {
+ "file_path": {
+ "type": "string"
+ },
+ "lines": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "new_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "new_start": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "old_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "old_start": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredPlanStep": {
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "step": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredQuestion": {
+ "additionalProperties": false,
+ "properties": {
+ "header": {
+ "type": "string"
+ },
+ "multi_select": {
+ "type": "boolean"
+ },
+ "options": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredQuestionOption"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "question": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredQuestionOption": {
+ "additionalProperties": false,
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredSearchResultItem": {
+ "additionalProperties": false,
+ "properties": {
+ "snippet": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredSystemEvent": {
+ "additionalProperties": false,
+ "properties": {
+ "category": {
+ "type": "string"
+ },
+ "code": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredTailState": {
+ "additionalProperties": false,
+ "properties": {
+ "activity": {
+ "type": "string"
+ },
+ "degraded": {
+ "type": "boolean"
+ },
+ "degraded_reason": {
+ "type": "string"
+ },
+ "last_entry_id": {
+ "type": "string"
+ },
+ "open_tool_call_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "pending_interaction_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "activity"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredTodoItem": {
+ "additionalProperties": false,
+ "properties": {
+ "active_form": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "priority": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredToolError": {
+ "additionalProperties": false,
+ "properties": {
+ "category": {
+ "description": "Provider-neutral category: user_rejection, user_rejection_with_reason, command_failure, file_error, validation_error, timeout, network_error, or unknown.",
+ "enum": [
+ "user_rejection",
+ "user_rejection_with_reason",
+ "command_failure",
+ "file_error",
+ "validation_error",
+ "timeout",
+ "network_error",
+ "unknown"
+ ],
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "user_reason": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "category"
+ ],
+ "type": "object"
+ },
+ "SessionStructuredToolInput": {
+ "description": "Provider-neutral tool input discriminated by its closed kind vocabulary.",
+ "discriminator": {
+ "mapping": {
+ "arguments": "#/components/schemas/SessionStructuredToolInputArguments",
+ "code": "#/components/schemas/SessionStructuredToolInputCode",
+ "command": "#/components/schemas/SessionStructuredToolInputCommand",
+ "fetch": "#/components/schemas/SessionStructuredToolInputFetch",
+ "file": "#/components/schemas/SessionStructuredToolInputFile",
+ "glob": "#/components/schemas/SessionStructuredToolInputGlob",
+ "patch": "#/components/schemas/SessionStructuredToolInputPatch",
+ "plan": "#/components/schemas/SessionStructuredToolInputPlan",
+ "question": "#/components/schemas/SessionStructuredToolInputQuestion",
+ "search": "#/components/schemas/SessionStructuredToolInputSearch",
+ "stdin": "#/components/schemas/SessionStructuredToolInputStdin",
+ "task": "#/components/schemas/SessionStructuredToolInputTask",
+ "text": "#/components/schemas/SessionStructuredToolInputText",
+ "todo": "#/components/schemas/SessionStructuredToolInputTodo",
+ "unknown": "#/components/schemas/SessionStructuredToolInputUnknown",
+ "write": "#/components/schemas/SessionStructuredToolInputWrite"
+ },
+ "propertyName": "kind"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputUnknown"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputCommand"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputStdin"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputCode"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputPatch"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputWrite"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputGlob"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputFetch"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputSearch"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputFile"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputTodo"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputPlan"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputQuestion"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputTask"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputText"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolInputArguments"
+ }
+ ],
+ "title": "Structured tool input"
+ },
+ "SessionStructuredToolInputArguments": {
+ "additionalProperties": false,
+ "properties": {
+ "arguments": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": "array"
+ },
+ "kind": {
+ "const": "arguments",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "arguments"
+ ],
+ "title": "SessionStructuredToolInputArguments",
+ "type": "object"
+ },
+ "SessionStructuredToolInputCode": {
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "code",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "code"
+ ],
+ "title": "SessionStructuredToolInputCode",
+ "type": "object"
+ },
+ "SessionStructuredToolInputCommand": {
+ "additionalProperties": false,
+ "properties": {
+ "arguments": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "command": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "command",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "command"
+ ],
+ "title": "SessionStructuredToolInputCommand",
+ "type": "object"
+ },
+ "SessionStructuredToolInputFetch": {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "fetch",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputFetch",
+ "type": "object"
+ },
+ "SessionStructuredToolInputFile": {
+ "additionalProperties": false,
+ "properties": {
+ "command": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "file",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "file_path"
+ ],
+ "title": "SessionStructuredToolInputFile",
+ "type": "object"
+ },
+ "SessionStructuredToolInputGlob": {
+ "additionalProperties": false,
+ "properties": {
+ "arguments": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "glob",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "query": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputGlob",
+ "type": "object"
+ },
+ "SessionStructuredToolInputPatch": {
+ "additionalProperties": false,
+ "properties": {
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "patch",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ },
+ "patch": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "patch"
+ ],
+ "title": "SessionStructuredToolInputPatch",
+ "type": "object"
+ },
+ "SessionStructuredToolInputPlan": {
+ "additionalProperties": false,
+ "properties": {
+ "explanation": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "plan",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "plan": {
+ "type": "string"
+ },
+ "steps": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPlanStep"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputPlan",
+ "type": "object"
+ },
+ "SessionStructuredToolInputQuestion": {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "question",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "options": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "question": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputQuestion",
+ "type": "object"
+ },
+ "SessionStructuredToolInputSearch": {
+ "additionalProperties": false,
+ "properties": {
+ "arguments": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "command": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "search",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "query": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputSearch",
+ "type": "object"
+ },
+ "SessionStructuredToolInputStdin": {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "stdin",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "linked_command": {
+ "type": "string"
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputStdin",
+ "type": "object"
+ },
+ "SessionStructuredToolInputTask": {
+ "additionalProperties": false,
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "task",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "task_status": {
+ "type": "string"
+ },
+ "task_type": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputTask",
+ "type": "object"
+ },
+ "SessionStructuredToolInputText": {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "text",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "text"
+ ],
+ "title": "SessionStructuredToolInputText",
+ "type": "object"
+ },
+ "SessionStructuredToolInputTodo": {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "todo",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "todos": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredTodoItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputTodo",
+ "type": "object"
+ },
+ "SessionStructuredToolInputUnknown": {
+ "additionalProperties": false,
+ "properties": {
+ "arguments": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "code": {
+ "type": "string"
+ },
+ "command": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "explanation": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "unknown",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ },
+ "linked_command": {
+ "type": "string"
+ },
+ "options": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "patch": {
+ "type": "string"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "plan": {
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "query": {
+ "type": "string"
+ },
+ "question": {
+ "type": "string"
+ },
+ "steps": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPlanStep"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "task_status": {
+ "type": "string"
+ },
+ "task_type": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "todos": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredTodoItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputUnknown",
+ "type": "object"
+ },
+ "SessionStructuredToolInputWrite": {
+ "additionalProperties": false,
+ "properties": {
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "write",
+ "description": "Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text.",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolInputWrite",
+ "type": "object"
+ },
+ "SessionStructuredToolResult": {
+ "description": "Provider-neutral tool result discriminated by its closed kind vocabulary.",
+ "discriminator": {
+ "mapping": {
+ "bash": "#/components/schemas/SessionStructuredToolResultBash",
+ "edit": "#/components/schemas/SessionStructuredToolResultEdit",
+ "fetch": "#/components/schemas/SessionStructuredToolResultFetch",
+ "glob": "#/components/schemas/SessionStructuredToolResultGlob",
+ "grep": "#/components/schemas/SessionStructuredToolResultGrep",
+ "plan": "#/components/schemas/SessionStructuredToolResultPlan",
+ "python": "#/components/schemas/SessionStructuredToolResultPython",
+ "question": "#/components/schemas/SessionStructuredToolResultQuestion",
+ "read": "#/components/schemas/SessionStructuredToolResultRead",
+ "search": "#/components/schemas/SessionStructuredToolResultSearch",
+ "stdin": "#/components/schemas/SessionStructuredToolResultStdin",
+ "task": "#/components/schemas/SessionStructuredToolResultTask",
+ "text": "#/components/schemas/SessionStructuredToolResultText",
+ "todo": "#/components/schemas/SessionStructuredToolResultTodo",
+ "unknown": "#/components/schemas/SessionStructuredToolResultUnknown",
+ "write": "#/components/schemas/SessionStructuredToolResultWrite"
+ },
+ "propertyName": "kind"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultUnknown"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultBash"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultPython"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultRead"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultGlob"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultGrep"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultSearch"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultFetch"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultTodo"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultPlan"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultQuestion"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultStdin"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultTask"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultWrite"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultEdit"
+ },
+ {
+ "$ref": "#/components/schemas/SessionStructuredToolResultText"
+ }
+ ],
+ "title": "Structured tool result"
+ },
+ "SessionStructuredToolResultBash": {
+ "additionalProperties": false,
+ "properties": {
+ "command": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "exit_code": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "interrupted": {
+ "type": "boolean"
+ },
+ "is_image": {
+ "type": "boolean"
+ },
+ "kind": {
+ "const": "bash",
+ "type": "string"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stderr_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "stdout": {
+ "type": "string"
+ },
+ "stdout_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "task_status": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "truncated": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultBash",
+ "type": "object"
+ },
+ "SessionStructuredToolResultEdit": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "file_paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "kind": {
+ "const": "edit",
+ "type": "string"
+ },
+ "new_string": {
+ "type": "string"
+ },
+ "old_string": {
+ "type": "string"
+ },
+ "original_file": {
+ "type": "string"
+ },
+ "patch": {
+ "type": "string"
+ },
+ "patch_hunks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPatchHunk"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "replace_all": {
+ "type": "boolean"
+ },
+ "user_modified": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultEdit",
+ "type": "object"
+ },
+ "SessionStructuredToolResultFetch": {
+ "additionalProperties": false,
+ "properties": {
+ "bytes": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ },
+ "duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "kind": {
+ "const": "fetch",
+ "type": "string"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "status_code": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "status_text": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultFetch",
+ "type": "object"
+ },
+ "SessionStructuredToolResultGlob": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "filenames": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "kind": {
+ "const": "glob",
+ "type": "string"
+ },
+ "num_files": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "truncated": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultGlob",
+ "type": "object"
+ },
+ "SessionStructuredToolResultGrep": {
+ "additionalProperties": false,
+ "properties": {
+ "applied_limit": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ },
+ "counts": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "filenames": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "kind": {
+ "const": "grep",
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "num_files": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_results": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "query": {
+ "type": "string"
+ },
+ "result_items": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredSearchResultItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultGrep",
+ "type": "object"
+ },
+ "SessionStructuredToolResultPlan": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "explanation": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "plan",
+ "type": "string"
+ },
+ "plan": {
+ "type": "string"
+ },
+ "steps": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPlanStep"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultPlan",
+ "type": "object"
+ },
+ "SessionStructuredToolResultPython": {
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "exit_code": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "interrupted": {
+ "type": "boolean"
+ },
+ "is_image": {
+ "type": "boolean"
+ },
+ "kind": {
+ "const": "python",
+ "type": "string"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stdout": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "truncated": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultPython",
+ "type": "object"
+ },
+ "SessionStructuredToolResultQuestion": {
+ "additionalProperties": false,
+ "properties": {
+ "answer": {
+ "type": "string"
+ },
+ "answers": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "kind": {
+ "const": "question",
+ "type": "string"
+ },
+ "options": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "question": {
+ "type": "string"
+ },
+ "questions": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredQuestion"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultQuestion",
+ "type": "object"
+ },
+ "SessionStructuredToolResultRead": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "kind": {
+ "const": "read",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "start_line": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_lines": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultRead",
+ "type": "object"
+ },
+ "SessionStructuredToolResultSearch": {
+ "additionalProperties": false,
+ "properties": {
+ "applied_limit": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ },
+ "counts": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "filenames": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "kind": {
+ "const": "search",
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "num_files": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_results": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "query": {
+ "type": "string"
+ },
+ "result_items": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredSearchResultItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultSearch",
+ "type": "object"
+ },
+ "SessionStructuredToolResultStdin": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "kind": {
+ "const": "stdin",
+ "type": "string"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultStdin",
+ "type": "object"
+ },
+ "SessionStructuredToolResultTask": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "exit_code": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "kind": {
+ "const": "task",
+ "type": "string"
+ },
+ "output": {
+ "type": "string"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stdout": {
+ "type": "string"
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "task_status": {
+ "type": "string"
+ },
+ "task_type": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "total_duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_tool_use_count": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultTask",
+ "type": "object"
+ },
+ "SessionStructuredToolResultText": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "kind": {
+ "const": "text",
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultText",
+ "type": "object"
+ },
+ "SessionStructuredToolResultTodo": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "kind": {
+ "const": "todo",
+ "type": "string"
+ },
+ "new_todos": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredTodoItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "old_todos": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredTodoItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultTodo",
+ "type": "object"
+ },
+ "SessionStructuredToolResultUnknown": {
+ "additionalProperties": false,
+ "properties": {
+ "answer": {
+ "type": "string"
+ },
+ "answers": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "applied_limit": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "bytes": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "code": {
+ "type": "string"
+ },
+ "command": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ },
+ "counts": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredArgument"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "exit_code": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "explanation": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "file_paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "filenames": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "interrupted": {
+ "type": "boolean"
+ },
+ "is_image": {
+ "type": "boolean"
+ },
+ "kind": {
+ "const": "unknown",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "new_string": {
+ "type": "string"
+ },
+ "new_todos": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredTodoItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "num_files": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "num_results": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "old_string": {
+ "type": "string"
+ },
+ "old_todos": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredTodoItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "options": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "original_file": {
+ "type": "string"
+ },
+ "output": {
+ "type": "string"
+ },
+ "patch": {
+ "type": "string"
+ },
+ "patch_hunks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPatchHunk"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "plan": {
+ "type": "string"
+ },
+ "query": {
+ "type": "string"
+ },
+ "question": {
+ "type": "string"
+ },
+ "questions": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredQuestion"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "replace_all": {
+ "type": "boolean"
+ },
+ "result_items": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredSearchResultItem"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "start_line": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "status_code": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "status_text": {
+ "type": "string"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stderr_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "stdout": {
+ "type": "string"
+ },
+ "stdout_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "steps": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPlanStep"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "task_id": {
+ "type": "string"
+ },
+ "task_status": {
+ "type": "string"
+ },
+ "task_type": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "total_duration_ms": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_tool_use_count": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "truncated": {
+ "type": "boolean"
+ },
+ "url": {
+ "type": "string"
+ },
+ "user_modified": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultUnknown",
+ "type": "object"
+ },
+ "SessionStructuredToolResultWrite": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "error": {
+ "$ref": "#/components/schemas/SessionStructuredToolError"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "file_paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "kind": {
+ "const": "write",
+ "type": "string"
+ },
+ "language": {
+ "type": "string"
+ },
+ "num_lines": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "patch": {
+ "type": "string"
+ },
+ "patch_hunks": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredPatchHunk"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "start_line": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "text": {
+ "type": "string"
+ },
+ "total_lines": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "SessionStructuredToolResultWrite",
+ "type": "object"
+ },
+ "SessionStructuredUploadedFile": {
+ "additionalProperties": false,
+ "properties": {
+ "file_path": {
+ "type": "string"
+ },
+ "mime_type": {
+ "type": "string"
+ },
+ "original_name": {
+ "type": "string"
+ },
+ "preview_url": {
+ "type": "string"
+ },
+ "size": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredUsage": {
+ "additionalProperties": false,
+ "properties": {
+ "cache_creation_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "cache_read_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "context_percent": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "context_used_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "context_window_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "input_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "output_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "reasoning_tokens": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "SessionStructuredUserPrompt": {
+ "additionalProperties": false,
+ "properties": {
+ "opened_files": {
+ "items": {
+ "type": "string"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "selections": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredIDESelection"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "text": {
+ "type": "string"
+ },
+ "uploaded_files": {
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredUploadedFile"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "SessionSubmitInputBody": {
+ "additionalProperties": false,
+ "properties": {
+ "intent": {
+ "$ref": "#/components/schemas/SubmitIntent",
+ "description": "Submit intent; empty defaults to \"default\".",
+ "enum": [
+ "default",
+ "follow_up",
+ "interrupt_now"
+ ]
+ },
+ "message": {
+ "description": "Message text to submit.",
+ "minLength": 1,
+ "pattern": "\\S",
+ "type": "string"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ },
+ "SessionSubmitSucceededPayload": {
+ "additionalProperties": false,
+ "properties": {
+ "intent": {
+ "description": "Resolved submit intent (default, follow_up, interrupt_now).",
+ "type": "string"
+ },
+ "queued": {
+ "description": "Whether the message was queued for later delivery.",
+ "type": "boolean"
+ },
+ "request_id": {
+ "description": "Correlation ID from the 202 response.",
+ "type": "string"
+ },
+ "session_id": {
+ "description": "Session ID that received the submission.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "request_id",
+ "session_id",
+ "queued",
+ "intent"
+ ],
+ "type": "object"
+ },
+ "SessionTranscriptConversationResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "format": {
+ "description": "Conversation or text transcript format.",
+ "enum": [
+ "conversation",
+ "text"
+ ],
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
"pagination": {
"$ref": "#/components/schemas/PaginationInfo"
},
"provider": {
- "description": "Producing provider identifier (claude, codex, gemini, open-code, etc.). Consumers use this to dispatch per-provider frame parsing.",
+ "description": "Producing provider identifier (claude, codex, gemini, opencode, etc.).",
"type": "string"
},
"template": {
"type": "string"
},
"turns": {
- "description": "Populated for conversation/text formats.",
+ "description": "Conversation/text transcript turns.",
"items": {
"$ref": "#/components/schemas/OutputTurn"
},
@@ -7989,6 +10720,130 @@
],
"type": "object"
},
+ "SessionTranscriptGetResponse": {
+ "description": "Discriminated union of session transcript response shapes. Raw provider-native frames are available only on the raw branch; structured responses contain only provider-neutral typed data.",
+ "discriminator": {
+ "mapping": {
+ "conversation": "#/components/schemas/SessionTranscriptConversationResponse",
+ "raw": "#/components/schemas/SessionTranscriptRawResponse",
+ "structured": "#/components/schemas/SessionTranscriptStructuredResponse",
+ "text": "#/components/schemas/SessionTranscriptConversationResponse"
+ },
+ "propertyName": "format"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/SessionTranscriptConversationResponse"
+ },
+ {
+ "$ref": "#/components/schemas/SessionTranscriptRawResponse"
+ },
+ {
+ "$ref": "#/components/schemas/SessionTranscriptStructuredResponse"
+ }
+ ],
+ "title": "Session transcript response"
+ },
+ "SessionTranscriptRawResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "format": {
+ "description": "Raw provider-native transcript format.",
+ "enum": [
+ "raw"
+ ],
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "messages": {
+ "description": "Provider-native transcript frames emitted only for raw format.",
+ "items": {
+ "$ref": "#/components/schemas/SessionRawMessageFrame"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ },
+ "pagination": {
+ "$ref": "#/components/schemas/PaginationInfo"
+ },
+ "provider": {
+ "description": "Producing provider identifier (claude, codex, gemini, opencode, etc.). Consumers use this to dispatch per-provider frame parsing.",
+ "type": "string"
+ },
+ "template": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "template",
+ "provider",
+ "format",
+ "messages"
+ ],
+ "type": "object"
+ },
+ "SessionTranscriptStructuredResponse": {
+ "additionalProperties": false,
+ "description": "Provider-neutral structured transcript snapshot.",
+ "properties": {
+ "format": {
+ "const": "structured",
+ "description": "Structured provider-neutral transcript format.",
+ "type": "string"
+ },
+ "history": {
+ "$ref": "#/components/schemas/SessionStructuredHistory",
+ "description": "Normalized worker-history envelope when format is structured."
+ },
+ "id": {
+ "type": "string"
+ },
+ "operation": {
+ "const": "snapshot",
+ "description": "Always snapshot for a REST structured transcript.",
+ "type": "string"
+ },
+ "pagination": {
+ "$ref": "#/components/schemas/PaginationInfo"
+ },
+ "provider": {
+ "description": "Producing provider identifier (claude, codex, gemini, opencode, etc.).",
+ "type": "string"
+ },
+ "schema_version": {
+ "const": "session.structured.v1",
+ "description": "Structured session transcript schema version.",
+ "type": "string"
+ },
+ "structured_messages": {
+ "description": "Provider-normalized structured messages.",
+ "items": {
+ "$ref": "#/components/schemas/SessionStructuredMessage"
+ },
+ "type": "array"
+ },
+ "template": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "template",
+ "provider",
+ "format",
+ "schema_version",
+ "operation",
+ "history",
+ "structured_messages"
+ ],
+ "title": "Structured session transcript response",
+ "type": "object"
+ },
"SessionUnknownStatePayload": {
"additionalProperties": false,
"properties": {
@@ -8039,6 +10894,26 @@
"description": "Formula name for workflow launch.",
"type": "string"
},
+ "merge": {
+ "description": "Merge strategy: direct, mr, or local.",
+ "type": "string"
+ },
+ "no_convoy": {
+ "description": "Do not create an auto-convoy for the routed bead.",
+ "type": "boolean"
+ },
+ "no_formula": {
+ "description": "Suppress the target's default_sling_formula even when configured.",
+ "type": "boolean"
+ },
+ "owned": {
+ "description": "Mark the routed bead as owned by the target.",
+ "type": "boolean"
+ },
+ "reassign": {
+ "description": "Clear any existing human assignee on the bead before routing, so a bead claimed via bd update --claim is handed to the target's pool.",
+ "type": "boolean"
+ },
"rig": {
"description": "Rig name.",
"type": "string"
@@ -8668,7 +11543,7 @@
"type": "string"
},
"live_rows": {
- "description": "Live bead row count.",
+ "description": "Retained bead row count used as the denominator, including open and closed beads.",
"format": "int64",
"type": "integer"
},
@@ -8677,7 +11552,7 @@
"type": "string"
},
"ratio_mb_per_row": {
- "description": "Derived megabytes per row.",
+ "description": "Derived megabytes per retained row, including open and closed beads.",
"format": "double",
"type": "number"
},
@@ -8952,7 +11827,7 @@
"additionalProperties": false,
"properties": {
"event_cursor": {
- "description": "Supervisor event-stream cursor captured before the history snapshot was listed. Pass this value as after_cursor to /v0/events/stream to receive events emitted after the snapshot boundary without replaying unrelated historical backlog.",
+ "description": "Supervisor event-stream cursor captured before the history snapshot was listed. Pass this value as after_cursor to /v0/events/stream to receive events emitted after the snapshot boundary. A populated cursor resumes each city at its exact per-city position, so no unrelated historical backlog is replayed. The value 0 is returned only when no event provider is registered at capture time; passing 0 back requests a replay from zero for every provider present at resume time.",
"type": "string"
},
"items": {
@@ -19528,6 +22403,10 @@
"description": "True when this city is configured to record local usage estimates.",
"type": "boolean"
},
+ "last_24h": {
+ "$ref": "#/components/schemas/UsageTotals",
+ "description": "Usage over the trailing 24 hours; a rolling window that survives the local-midnight reset of today. Omitted by servers or proxies that predate the field."
+ },
"observed_from": {
"description": "RFC3339 timestamp of the oldest fact included in this bounded read.",
"type": "string"
@@ -24298,6 +27177,7 @@
},
"/v0/city/{cityName}/beads": {
"get": {
+ "description": "Results are ordered (created_at DESC, id DESC) — newest beads first. A truncated response always carries next_cursor; passing it back returns the next page in the same order. Invalid or legacy cursors are rejected with a typed 400 (invalid-cursor).",
"operationId": "get-v0-city-by-city-name-beads",
"parameters": [
{
@@ -24333,23 +27213,25 @@
}
},
{
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"explode": false,
"in": "query",
"name": "cursor",
"schema": {
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"type": "string"
}
},
{
- "description": "Maximum number of results to return. 0 = server default.",
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"explode": false,
"in": "query",
"name": "limit",
"schema": {
- "description": "Maximum number of results to return. 0 = server default.",
+ "default": 100,
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"format": "int64",
+ "maximum": 1000,
"minimum": 0,
"type": "integer"
}
@@ -26193,6 +29075,7 @@
},
"/v0/city/{cityName}/convoys": {
"get": {
+ "description": "Results are ordered (created_at DESC, id DESC) — newest convoys first. A truncated response always carries next_cursor; passing it back returns the next page in the same order. Invalid or legacy cursors are rejected with a typed 400 (invalid-cursor).",
"operationId": "get-v0-city-by-city-name-convoys",
"parameters": [
{
@@ -26228,23 +29111,25 @@
}
},
{
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"explode": false,
"in": "query",
"name": "cursor",
"schema": {
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"type": "string"
}
},
{
- "description": "Maximum number of results to return. 0 = server default.",
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"explode": false,
"in": "query",
"name": "limit",
"schema": {
- "description": "Maximum number of results to return. 0 = server default.",
+ "default": 100,
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"format": "int64",
+ "maximum": 1000,
"minimum": 0,
"type": "integer"
}
@@ -26547,6 +29432,7 @@
},
"/v0/city/{cityName}/events": {
"get": {
+ "description": "Results are ordered seq DESC — newest events first. A truncated response always carries next_cursor; passing it back returns the next page in the same order. Invalid or legacy cursors are rejected with a typed 400 (invalid-cursor).",
"operationId": "get-v0-city-by-city-name-events",
"parameters": [
{
@@ -26582,23 +29468,25 @@
}
},
{
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"explode": false,
"in": "query",
"name": "cursor",
"schema": {
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"type": "string"
}
},
{
- "description": "Maximum number of results to return. 0 = server default.",
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"explode": false,
"in": "query",
"name": "limit",
"schema": {
- "description": "Maximum number of results to return. 0 = server default.",
+ "default": 100,
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"format": "int64",
+ "maximum": 1000,
"minimum": 0,
"type": "integer"
}
@@ -30971,6 +33859,7 @@
},
"/v0/city/{cityName}/mail": {
"get": {
+ "description": "Results are ordered (created_at DESC, id DESC) — newest messages first. A truncated response always carries next_cursor; passing it back returns the next page in the same order. Invalid or legacy cursors are rejected with a typed 400 (invalid-cursor).",
"operationId": "get-v0-city-by-city-name-mail",
"parameters": [
{
@@ -31006,23 +33895,25 @@
}
},
{
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"explode": false,
"in": "query",
"name": "cursor",
"schema": {
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"type": "string"
}
},
{
- "description": "Maximum number of results to return. 0 = server default.",
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"explode": false,
"in": "query",
"name": "limit",
"schema": {
- "description": "Maximum number of results to return. 0 = server default.",
+ "default": 100,
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"format": "int64",
+ "maximum": 1000,
"minimum": 0,
"type": "integer"
}
@@ -38235,11 +41126,11 @@
}
},
{
- "description": "Idempotency key for safe retries.",
+ "description": "Idempotency key for safe retries (synchronous create).",
"in": "header",
"name": "Idempotency-Key",
"schema": {
- "description": "Idempotency key for safe retries.",
+ "description": "Idempotency key for safe retries (synchronous create).",
"type": "string"
}
}
@@ -40219,48 +43110,200 @@
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SessionMessageInputBody"
- }
- }
- },
- "required": true
- },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SessionMessageInputBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "202": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AsyncAcceptedBody"
+ }
+ }
+ },
+ "description": "Accepted",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Unauthorized",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Forbidden",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "404": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Not Found",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Conflict",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "422": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Unprocessable Entity",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "500": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Internal Server Error",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "503": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Service Unavailable",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ }
+ },
+ "summary": "Send a message to a session"
+ }
+ },
+ "/v0/city/{cityName}/session/{id}/pending": {
+ "get": {
+ "operationId": "get-v0-city-by-city-name-session-by-id-pending",
+ "parameters": [
+ {
+ "description": "City name.",
+ "in": "path",
+ "name": "cityName",
+ "required": true,
+ "schema": {
+ "description": "City name.",
+ "minLength": 1,
+ "pattern": "\\S",
+ "type": "string"
+ }
+ },
+ {
+ "description": "Session ID, alias, or runtime session_name.",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "description": "Session ID, alias, or runtime session_name.",
+ "type": "string"
+ }
+ }
+ ],
"responses": {
- "202": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/AsyncAcceptedBody"
+ "$ref": "#/components/schemas/SessionPendingResponse"
}
}
},
- "description": "Accepted",
+ "description": "OK",
"headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "401": {
- "content": {
- "application/problem+json": {
+ "X-GC-Cache-Age-S": {
"schema": {
- "$ref": "#/components/schemas/ErrorModel"
+ "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).",
+ "format": "double",
+ "type": "number"
}
- }
- },
- "description": "Unauthorized",
- "headers": {
+ },
+ "X-GC-Index": {
+ "schema": {
+ "description": "Latest event sequence number.",
+ "format": "int64",
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "403": {
+ "404": {
"content": {
"application/problem+json": {
"schema": {
@@ -40268,14 +43311,14 @@
}
}
},
- "description": "Forbidden",
+ "description": "Not Found",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "404": {
+ "409": {
"content": {
"application/problem+json": {
"schema": {
@@ -40283,7 +43326,7 @@
}
}
},
- "description": "Not Found",
+ "description": "Conflict",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
@@ -40336,13 +43379,24 @@
}
}
},
- "summary": "Send a message to a session"
+ "summary": "Get v0 city by city name session by ID pending"
}
},
- "/v0/city/{cityName}/session/{id}/pending": {
- "get": {
- "operationId": "get-v0-city-by-city-name-session-by-id-pending",
+ "/v0/city/{cityName}/session/{id}/permission-mode": {
+ "post": {
+ "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode",
"parameters": [
+ {
+ "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
+ "in": "header",
+ "name": "X-GC-Request",
+ "required": true,
+ "schema": {
+ "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
+ "minLength": 1,
+ "type": "string"
+ }
+ },
{
"description": "City name.",
"in": "path",
@@ -40366,12 +43420,22 @@
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SessionPermissionModeBody"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SessionPendingResponse"
+ "$ref": "#/components/schemas/SessionResponse"
}
}
},
@@ -40397,6 +43461,51 @@
}
}
},
+ "400": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Bad Request",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "401": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Unauthorized",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
+ "403": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Forbidden",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
"404": {
"content": {
"application/problem+json": {
@@ -40457,6 +43566,21 @@
}
}
},
+ "501": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Not Implemented",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
"503": {
"content": {
"application/problem+json": {
@@ -40473,12 +43597,12 @@
}
}
},
- "summary": "Get v0 city by city name session by ID pending"
+ "summary": "Post v0 city by city name session by ID permission mode"
}
},
- "/v0/city/{cityName}/session/{id}/permission-mode": {
+ "/v0/city/{cityName}/session/{id}/rename": {
"post": {
- "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode",
+ "operationId": "post-v0-city-by-city-name-session-by-id-rename",
"parameters": [
{
"description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
@@ -40518,7 +43642,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SessionPermissionModeBody"
+ "$ref": "#/components/schemas/SessionRenameInputBody"
}
}
},
@@ -40660,21 +43784,6 @@
}
}
},
- "501": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Not Implemented",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
"503": {
"content": {
"application/problem+json": {
@@ -40691,12 +43800,12 @@
}
}
},
- "summary": "Post v0 city by city name session by ID permission mode"
+ "summary": "Post v0 city by city name session by ID rename"
}
},
- "/v0/city/{cityName}/session/{id}/rename": {
+ "/v0/city/{cityName}/session/{id}/respond": {
"post": {
- "operationId": "post-v0-city-by-city-name-session-by-id-rename",
+ "operationId": "respond-session",
"parameters": [
{
"description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
@@ -40736,44 +43845,29 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SessionRenameInputBody"
+ "$ref": "#/components/schemas/SessionRespondInputBody"
}
}
},
"required": true
},
"responses": {
- "200": {
+ "202": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SessionResponse"
+ "$ref": "#/components/schemas/SessionRespondOutputBody"
}
}
},
- "description": "OK",
+ "description": "Accepted",
"headers": {
- "X-GC-Cache-Age-S": {
- "schema": {
- "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).",
- "format": "double",
- "type": "number"
- }
- },
- "X-GC-Index": {
- "schema": {
- "description": "Latest event sequence number.",
- "format": "int64",
- "minimum": 0,
- "type": "integer"
- }
- },
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "400": {
+ "401": {
"content": {
"application/problem+json": {
"schema": {
@@ -40781,14 +43875,14 @@
}
}
},
- "description": "Bad Request",
+ "description": "Unauthorized",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "401": {
+ "403": {
"content": {
"application/problem+json": {
"schema": {
@@ -40796,14 +43890,14 @@
}
}
},
- "description": "Unauthorized",
+ "description": "Forbidden",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "403": {
+ "404": {
"content": {
"application/problem+json": {
"schema": {
@@ -40811,14 +43905,14 @@
}
}
},
- "description": "Forbidden",
+ "description": "Not Found",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "404": {
+ "409": {
"content": {
"application/problem+json": {
"schema": {
@@ -40826,14 +43920,14 @@
}
}
},
- "description": "Not Found",
+ "description": "Conflict",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "409": {
+ "422": {
"content": {
"application/problem+json": {
"schema": {
@@ -40841,14 +43935,14 @@
}
}
},
- "description": "Conflict",
+ "description": "Unprocessable Entity",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "422": {
+ "500": {
"content": {
"application/problem+json": {
"schema": {
@@ -40856,14 +43950,14 @@
}
}
},
- "description": "Unprocessable Entity",
+ "description": "Internal Server Error",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
}
}
},
- "500": {
+ "501": {
"content": {
"application/problem+json": {
"schema": {
@@ -40871,7 +43965,7 @@
}
}
},
- "description": "Internal Server Error",
+ "description": "Not Implemented",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
@@ -40894,12 +43988,12 @@
}
}
},
- "summary": "Post v0 city by city name session by ID rename"
+ "summary": "Respond to a pending interaction"
}
},
- "/v0/city/{cityName}/session/{id}/respond": {
+ "/v0/city/{cityName}/session/{id}/stop": {
"post": {
- "operationId": "respond-session",
+ "operationId": "post-v0-city-by-city-name-session-by-id-stop",
"parameters": [
{
"description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
@@ -40935,26 +44029,16 @@
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SessionRespondInputBody"
- }
- }
- },
- "required": true
- },
"responses": {
- "202": {
+ "200": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SessionRespondOutputBody"
+ "$ref": "#/components/schemas/OKWithIDResponseBody"
}
}
},
- "description": "Accepted",
+ "description": "OK",
"headers": {
"X-GC-Request-Id": {
"$ref": "#/components/headers/X-GC-Request-Id"
@@ -41051,21 +44135,6 @@
}
}
},
- "501": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Not Implemented",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
"503": {
"content": {
"application/problem+json": {
@@ -41082,24 +44151,14 @@
}
}
},
- "summary": "Respond to a pending interaction"
+ "summary": "Post v0 city by city name session by ID stop"
}
},
- "/v0/city/{cityName}/session/{id}/stop": {
- "post": {
- "operationId": "post-v0-city-by-city-name-session-by-id-stop",
+ "/v0/city/{cityName}/session/{id}/stream": {
+ "get": {
+ "description": "Server-Sent Events stream of session transcript updates. Streams turns (conversation format), raw messages (JSONL format), or structured messages based on the format query parameter. Emits activity and pending events for tool approval prompts.",
+ "operationId": "stream-session",
"parameters": [
- {
- "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
- "in": "header",
- "name": "X-GC-Request",
- "required": true,
- "schema": {
- "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.",
- "minLength": 1,
- "type": "string"
- }
- },
{
"description": "City name.",
"in": "path",
@@ -41121,167 +44180,50 @@
"description": "Session ID, alias, or runtime session_name.",
"type": "string"
}
- }
- ],
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/OKWithIDResponseBody"
- }
- }
- },
- "description": "OK",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "401": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Unauthorized",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "403": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Forbidden",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
},
- "404": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Not Found",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "409": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Conflict",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "422": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Unprocessable Entity",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "500": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Internal Server Error",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- },
- "503": {
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorModel"
- }
- }
- },
- "description": "Service Unavailable",
- "headers": {
- "X-GC-Request-Id": {
- "$ref": "#/components/headers/X-GC-Request-Id"
- }
- }
- }
- },
- "summary": "Post v0 city by city name session by ID stop"
- }
- },
- "/v0/city/{cityName}/session/{id}/stream": {
- "get": {
- "description": "Server-Sent Events stream of session transcript updates. Streams turns (conversation format) or raw messages (JSONL format) based on the format query parameter. Emits activity and pending events for tool approval prompts.",
- "operationId": "stream-session",
- "parameters": [
{
- "description": "City name.",
- "in": "path",
- "name": "cityName",
- "required": true,
+ "description": "Transcript format: conversation (default), raw, or structured.",
+ "explode": false,
+ "in": "query",
+ "name": "format",
"schema": {
- "description": "City name.",
- "minLength": 1,
- "pattern": "\\S",
+ "description": "Transcript format: conversation (default), raw, or structured.",
+ "enum": [
+ "conversation",
+ "raw",
+ "structured"
+ ],
"type": "string"
}
},
{
- "description": "Session ID, alias, or runtime session_name.",
- "in": "path",
- "name": "id",
- "required": true,
+ "description": "Include thinking block text and signature in structured stream frames. Defaults to false; both are redacted otherwise.",
+ "explode": false,
+ "in": "query",
+ "name": "include_thinking",
"schema": {
- "description": "Session ID, alias, or runtime session_name.",
- "type": "string"
+ "description": "Include thinking block text and signature in structured stream frames. Defaults to false; both are redacted otherwise.",
+ "type": "boolean"
}
},
{
- "description": "Transcript format: conversation (default) or raw.",
+ "description": "Opaque structured transcript resume cursor from the REST snapshot. Last-Event-ID takes precedence on automatic SSE reconnect.",
"explode": false,
"in": "query",
- "name": "format",
+ "name": "after_cursor",
"schema": {
- "description": "Transcript format: conversation (default) or raw.",
+ "description": "Opaque structured transcript resume cursor from the REST snapshot. Last-Event-ID takes precedence on automatic SSE reconnect.",
+ "maxLength": 2048,
+ "type": "string"
+ }
+ },
+ {
+ "description": "Opaque structured transcript resume cursor from the last received SSE frame. Takes precedence over after_cursor.",
+ "in": "header",
+ "name": "Last-Event-ID",
+ "schema": {
+ "description": "Opaque structured transcript resume cursor from the last received SSE frame. Takes precedence over after_cursor.",
+ "maxLength": 2048,
"type": "string"
}
}
@@ -41305,8 +44247,8 @@
"type": "string"
},
"id": {
- "description": "The event ID.",
- "type": "integer"
+ "description": "The event resume cursor.",
+ "type": "string"
},
"retry": {
"description": "The retry time in milliseconds.",
@@ -41331,8 +44273,8 @@
"type": "string"
},
"id": {
- "description": "The event ID.",
- "type": "integer"
+ "description": "The event resume cursor.",
+ "type": "string"
},
"retry": {
"description": "The retry time in milliseconds.",
@@ -41357,8 +44299,8 @@
"type": "string"
},
"id": {
- "description": "The event ID.",
- "type": "integer"
+ "description": "The event resume cursor.",
+ "type": "string"
},
"retry": {
"description": "The retry time in milliseconds.",
@@ -41382,8 +44324,8 @@
"type": "string"
},
"id": {
- "description": "The event ID.",
- "type": "integer"
+ "description": "The event resume cursor.",
+ "type": "string"
},
"retry": {
"description": "The retry time in milliseconds.",
@@ -41397,6 +44339,58 @@
"title": "Event pending",
"type": "object"
},
+ {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/SessionPendingClearedEvent"
+ },
+ "event": {
+ "const": "pending_cleared",
+ "description": "The event name.",
+ "type": "string"
+ },
+ "id": {
+ "description": "The event resume cursor.",
+ "type": "string"
+ },
+ "retry": {
+ "description": "The retry time in milliseconds.",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "data",
+ "event"
+ ],
+ "title": "Event pending_cleared",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/SessionStreamStructuredMessageEvent"
+ },
+ "event": {
+ "const": "structured",
+ "description": "The event name.",
+ "type": "string"
+ },
+ "id": {
+ "description": "The event resume cursor.",
+ "type": "string"
+ },
+ "retry": {
+ "description": "The retry time in milliseconds.",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "data",
+ "event"
+ ],
+ "title": "Event structured",
+ "type": "object"
+ },
{
"properties": {
"data": {
@@ -41408,8 +44402,8 @@
"type": "string"
},
"id": {
- "description": "The event ID.",
- "type": "integer"
+ "description": "The event resume cursor.",
+ "type": "string"
},
"retry": {
"description": "The retry time in milliseconds.",
@@ -41579,6 +44573,21 @@
}
}
},
+ "409": {
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorModel"
+ }
+ }
+ },
+ "description": "Conflict",
+ "headers": {
+ "X-GC-Request-Id": {
+ "$ref": "#/components/headers/X-GC-Request-Id"
+ }
+ }
+ },
"422": {
"content": {
"application/problem+json": {
@@ -41828,32 +44837,47 @@
}
},
{
- "description": "Transcript format: conversation (default) or raw.",
+ "description": "Transcript format: conversation (default), raw, or structured.",
"explode": false,
"in": "query",
"name": "format",
"schema": {
- "description": "Transcript format: conversation (default) or raw.",
+ "description": "Transcript format: conversation (default), raw, or structured.",
+ "enum": [
+ "conversation",
+ "raw",
+ "structured"
+ ],
"type": "string"
}
},
{
- "description": "Pagination cursor: return entries before this UUID.",
+ "description": "Include thinking block text and signature in structured responses. Defaults to false; both are redacted otherwise.",
+ "explode": false,
+ "in": "query",
+ "name": "include_thinking",
+ "schema": {
+ "description": "Include thinking block text and signature in structured responses. Defaults to false; both are redacted otherwise.",
+ "type": "boolean"
+ }
+ },
+ {
+ "description": "Pagination cursor: return entries before this stable transcript entry ID.",
"explode": false,
"in": "query",
"name": "before",
"schema": {
- "description": "Pagination cursor: return entries before this UUID.",
+ "description": "Pagination cursor: return entries before this stable transcript entry ID.",
"type": "string"
}
},
{
- "description": "Pagination cursor: return entries after this UUID.",
+ "description": "Pagination cursor: return entries after this stable transcript entry ID.",
"explode": false,
"in": "query",
"name": "after",
"schema": {
- "description": "Pagination cursor: return entries after this UUID.",
+ "description": "Pagination cursor: return entries after this stable transcript entry ID.",
"type": "string"
}
}
@@ -42148,6 +45172,7 @@
},
"/v0/city/{cityName}/sessions": {
"get": {
+ "description": "Results are ordered (created_at DESC, id DESC) — newest sessions first. A truncated response always carries next_cursor; passing it back returns the next page in the same order. Invalid or legacy cursors are rejected with a typed 400 (invalid-cursor).",
"operationId": "get-v0-city-by-city-name-sessions",
"parameters": [
{
@@ -42163,23 +45188,25 @@
}
},
{
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"explode": false,
"in": "query",
"name": "cursor",
"schema": {
- "description": "Pagination cursor from a previous response's next_cursor field.",
+ "description": "Opaque keyset pagination token from a previous response's next_cursor field. Invalid or legacy tokens are rejected with a typed 400 (invalid-cursor); re-fetch the first page.",
"type": "string"
}
},
{
- "description": "Maximum number of results to return. 0 = server default.",
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"explode": false,
"in": "query",
"name": "limit",
"schema": {
- "description": "Maximum number of results to return. 0 = server default.",
+ "default": 100,
+ "description": "Maximum number of results to return. Omitted or 0 = server default (100). Values above 1000 are rejected.",
"format": "int64",
+ "maximum": 1000,
"minimum": 0,
"type": "integer"
}
@@ -43647,7 +46674,7 @@
"type": "string"
},
"id": {
- "description": "The event ID (composite cursor).",
+ "description": "The event resume cursor.",
"type": "string"
},
"retry": {
@@ -43673,7 +46700,7 @@
"type": "string"
},
"id": {
- "description": "The event ID (composite cursor).",
+ "description": "The event resume cursor.",
"type": "string"
},
"retry": {
diff --git a/internal/api/pagination.go b/internal/api/pagination.go
index f410b08b0b..2c9590ce81 100644
--- a/internal/api/pagination.go
+++ b/internal/api/pagination.go
@@ -16,7 +16,12 @@ type pageParams struct {
// maxPaginationLimit caps the maximum page size to prevent oversized responses.
const maxPaginationLimit = 1000
-const defaultPaginationLimit = 50
+// defaultPaginationLimit is THE server default page size, unified across
+// every keyset list (S4 of the cursor program; previously 50 on beads/
+// convoys/mail, 1000 on sessions, 100 on events). PaginationParam's
+// default:"100" tag documents it in the spec and the pagination dialect
+// guard pins the two values together.
+const defaultPaginationLimit = 100
// parsePagination extracts cursor and limit from query parameters.
// The cursor is an opaque string that encodes an offset into the result set.
diff --git a/internal/api/pagination_bounds_test.go b/internal/api/pagination_bounds_test.go
index 01df69bd7f..3efc3d9c4f 100644
--- a/internal/api/pagination_bounds_test.go
+++ b/internal/api/pagination_bounds_test.go
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
+ "net/http"
"net/http/httptest"
"testing"
)
@@ -42,3 +43,22 @@ func TestPaginationLimitZeroAccepted(t *testing.T) {
t.Fatalf("decode: %v", err)
}
}
+
+// The unified page contract (S4) rejects limit>maximum (1000) at the Huma
+// edge with a typed 422 rather than silently clamping — the headline
+// behavior change of the pagination-vocabulary slice. Pin it at runtime so a
+// future edit that drops PaginationParam.Limit's maximum:"1000" tag (or the
+// dialect guard's schema pin) fails here instead of regressing to a silent
+// clamp.
+func TestPaginationLimitOverMaximumRejected(t *testing.T) {
+ fs := newFakeState(t)
+ h := newTestCityHandler(t, fs)
+
+ req := httptest.NewRequest("GET", cityURL(fs, "/beads?limit=5000"), nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Errorf("status = %d, want 422 for over-maximum limit (body=%q)", rec.Code, rec.Body.String())
+ }
+}
diff --git a/internal/api/pagination_dialect_guard_test.go b/internal/api/pagination_dialect_guard_test.go
new file mode 100644
index 0000000000..986b4ca2d4
--- /dev/null
+++ b/internal/api/pagination_dialect_guard_test.go
@@ -0,0 +1,365 @@
+package api_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/api"
+)
+
+// S4 of the keyset-cursor track: one pagination vocabulary, enforced by
+// walking the live OpenAPI spec. Every list endpoint speaks keyset
+// (cursor + limit) unless its exact legacy dialect is consciously
+// grandfathered below. The audit that started this program found five
+// different pagination dialects that had accreted silently; this guard
+// makes a sixth loud to add: known names and pagination-shaped names
+// (paginationSuspect) both trip it, so drift requires either adopting
+// PaginationParam or writing a grandfather entry in review.
+
+// paginationParamNames is the vocabulary of query params that express
+// pagination. `since` is deliberately absent — it is a time FILTER
+// (events?since=1h), not a page boundary.
+var paginationParamNames = map[string]bool{
+ "cursor": true, "limit": true,
+ "offset": true, "page": true, "page_size": true, "per_page": true,
+ "before": true, "after": true, "after_seq": true, "after_sequence": true,
+ "tail": true,
+}
+
+// paginationSuspect widens the exact vocabulary with naming patterns so a
+// novel dialect cannot slip past the guard just by picking a fresh name
+// (next, page_token, resume_at, start_after...). A suspect param that is
+// not plain keyset forces the same choice as a known legacy name: adopt
+// PaginationParam or grandfather the exact set consciously. A genuine
+// filter caught by the pattern (rare) gets grandfathered too — one
+// visible entry beats a silent blind spot.
+func paginationSuspect(name string) bool {
+ if paginationParamNames[name] {
+ return true
+ }
+ switch name {
+ case "next", "marker":
+ return true
+ }
+ return strings.Contains(name, "cursor") || strings.Contains(name, "token") ||
+ strings.HasPrefix(name, "page") || strings.HasPrefix(name, "resume") ||
+ strings.HasSuffix(name, "_after") || strings.HasSuffix(name, "_before")
+}
+
+// grandfatheredDialects maps "METHOD path" to the EXACT (sorted) set of
+// pagination params that legacy endpoint is allowed to keep. Owner
+// sign-off 2026-07-11: these dialects predate the keyset program and
+// stay as-is; new endpoints must speak keyset. Changing a set here —
+// or adding an entry — is a conscious contract decision that belongs
+// in its own review, not a side effect.
+var grandfatheredDialects = map[string][]string{
+ "GET /v0/city/{cityName}/agent/{base}/output": {"before", "tail"},
+ "GET /v0/city/{cityName}/agent/{dir}/{base}/output": {"before", "tail"},
+ "GET /v0/city/{cityName}/events/stream": {"after_seq"},
+ "GET /v0/events/stream": {"after_cursor"},
+ "GET /v0/city/{cityName}/extmsg/transcript": {"after_sequence", "limit"},
+ "GET /v0/city/{cityName}/orders/history": {"before", "limit"},
+ // Session structured-transcript SSE stream. Owner sign-off 2026-07-18:
+ // this is a live Server-Sent-Events reconnection endpoint, not a keyset
+ // list walk. It resumes via the Last-Event-ID header, with after_cursor as
+ // the browser fallback query param — the identical reconnect dialect the
+ // event streams above already speak (/v0/events/stream and
+ // /v0/city/{cityName}/events/stream). Grandfathered as a conscious
+ // SSE-resume exception: a live stream has no page boundary to express as
+ // cursor+limit.
+ "GET /v0/city/{cityName}/session/{id}/stream": {"after_cursor"},
+ "GET /v0/city/{cityName}/session/{id}/transcript": {"after", "before", "tail"},
+}
+
+// boundedLimitOnlyFeeds is the "METHOD path" allowlist of endpoints that
+// expose only `limit` (no cursor) and deliberately do NOT support a keyset
+// walk — they return a bounded, most-recent-N view. Owner sign-off
+// 2026-07-18: these predate or intentionally sit outside the keyset program
+// and stay limit-only. A NEW limit-only endpoint must either adopt keyset
+// (cursor + limit via PaginationParam) or be added here in its own review;
+// otherwise a sixth silent pagination dialect could ship as a bare `limit`
+// param without anyone noticing, the exact drift this guard exists to stop.
+// Unlike keyset lists, these are not held to the unified default/maximum
+// limit schema — each feed keeps its own documented bound.
+var boundedLimitOnlyFeeds = map[string]bool{
+ "GET /v0/city/{cityName}/formulas/feed": true,
+ "GET /v0/city/{cityName}/formulas/{name}/runs": true,
+ "GET /v0/city/{cityName}/orders/feed": true,
+ "GET /v0/city/{cityName}/runs": true,
+ "GET /v0/events": true,
+}
+
+type specParam struct {
+ Name string `json:"name"`
+ In string `json:"in"`
+ Schema json.RawMessage `json:"schema"`
+}
+
+type specOperation struct {
+ Parameters []specParam `json:"parameters"`
+ Responses map[string]json.RawMessage `json:"responses"`
+}
+
+type limitSchema struct {
+ Default *float64 `json:"default"`
+ Maximum *float64 `json:"maximum"`
+}
+
+// checkPaginationDialects walks a parsed OpenAPI paths object and returns
+// one human-readable violation per contract breach:
+// - a pagination param set that is neither keyset (subset of
+// {cursor, limit}) nor an exact grandfathered dialect
+// - a limit-only param set ({limit} with no cursor) on an operation that is
+// not allowlisted in boundedLimitOnlyFeeds (a new silent limit-only dialect)
+// - a cursor-speaking operation that does not declare a 400 response
+// (invalid cursors are a typed 400, never a silent page-1 restart)
+// - a cursor-speaking operation whose limit schema does not pin the
+// unified default (100) and maximum (1000)
+func checkPaginationDialects(paths map[string]map[string]specOperation) []string {
+ var violations []string
+ keys := make([]string, 0, len(paths))
+ for p := range paths {
+ keys = append(keys, p)
+ }
+ sort.Strings(keys)
+ seenGrandfathered := map[string]bool{}
+ seenBoundedFeed := map[string]bool{}
+ for _, path := range keys {
+ for _, method := range []string{"get", "post", "put", "patch", "delete"} {
+ op, ok := paths[path][method]
+ if !ok {
+ continue
+ }
+ opKey := strings.ToUpper(method) + " " + path
+ var pag []string
+ var hasCursor bool
+ var limit *specParam
+ for i, p := range op.Parameters {
+ if p.In != "query" || !paginationSuspect(p.Name) {
+ continue
+ }
+ pag = append(pag, p.Name)
+ if p.Name == "cursor" {
+ hasCursor = true
+ }
+ if p.Name == "limit" {
+ limit = &op.Parameters[i]
+ }
+ }
+ if len(pag) == 0 {
+ continue
+ }
+ sort.Strings(pag)
+
+ keyset := true
+ for _, name := range pag {
+ if name != "cursor" && name != "limit" {
+ keyset = false
+ }
+ }
+ if !keyset {
+ want, grandfathered := grandfatheredDialects[opKey]
+ if !grandfathered {
+ violations = append(violations, fmt.Sprintf(
+ "%s uses pagination params %v: new endpoints must speak keyset (cursor + limit via PaginationParam); if this is a conscious legacy dialect, grandfather its exact param set in grandfatheredDialects with owner sign-off",
+ opKey, pag))
+ continue
+ }
+ seenGrandfathered[opKey] = true
+ if !equalStringSets(pag, want) {
+ violations = append(violations, fmt.Sprintf(
+ "%s pagination params drifted: grandfathered as %v, spec now has %v; dialect changes on legacy endpoints need their own review",
+ opKey, want, pag))
+ }
+ continue
+ }
+
+ if !hasCursor {
+ // Limit-only feed ({limit}, no cursor): a bounded read with no
+ // keyset walk. Legitimate for a recent-N feed, but adding one
+ // must be conscious — otherwise a sixth pagination dialect ships
+ // as a bare limit param with no review. The exact operation must
+ // be allowlisted in boundedLimitOnlyFeeds.
+ if !boundedLimitOnlyFeeds[opKey] {
+ violations = append(violations, fmt.Sprintf(
+ "%s exposes a limit-only pagination feed that is not allowlisted: adopt keyset (cursor + limit via PaginationParam), or if this is an intentional bounded feed, add it to boundedLimitOnlyFeeds with owner sign-off",
+ opKey))
+ continue
+ }
+ seenBoundedFeed[opKey] = true
+ continue
+ }
+ if _, ok := op.Responses["400"]; !ok {
+ violations = append(violations, fmt.Sprintf(
+ "%s speaks keyset but does not declare a 400 response: invalid cursors are a typed 400 (apierr.InvalidCursor), declare it via errorStatuses(http.StatusBadRequest, ...)",
+ opKey))
+ }
+ if limit == nil {
+ violations = append(violations, fmt.Sprintf(
+ "%s has a cursor param without a limit param: embed PaginationParam instead of declaring cursor ad hoc", opKey))
+ } else {
+ var ls limitSchema
+ _ = json.Unmarshal(limit.Schema, &ls)
+ if ls.Default == nil || *ls.Default != 100 || ls.Maximum == nil || *ls.Maximum != 1000 {
+ violations = append(violations, fmt.Sprintf(
+ "%s limit schema must pin the unified page contract (default 100, maximum 1000): embed PaginationParam rather than declaring limit ad hoc", opKey))
+ }
+ }
+ }
+ }
+ for opKey := range grandfatheredDialects {
+ if !seenGrandfathered[opKey] {
+ violations = append(violations, fmt.Sprintf(
+ "%s is grandfathered but no longer in the spec (or went keyset): remove its grandfatheredDialects entry", opKey))
+ }
+ }
+ for opKey := range boundedLimitOnlyFeeds {
+ if !seenBoundedFeed[opKey] {
+ violations = append(violations, fmt.Sprintf(
+ "%s is allowlisted as a bounded limit-only feed but no longer appears as one in the spec (or adopted keyset): remove its boundedLimitOnlyFeeds entry", opKey))
+ }
+ }
+ sort.Strings(violations)
+ return violations
+}
+
+func equalStringSets(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// TestPaginationDialectGuard walks the live spec and fails on any
+// pagination-vocabulary drift.
+func TestPaginationDialectGuard(t *testing.T) {
+ sm := api.NewSupervisorMux(emptyTestResolver{}, nil, false, "", "", time.Time{})
+ req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil)
+ rec := httptest.NewRecorder()
+ sm.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /openapi.json returned %d", rec.Code)
+ }
+ var spec struct {
+ Paths map[string]map[string]specOperation `json:"paths"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil {
+ t.Fatalf("parse live spec: %v", err)
+ }
+ if len(spec.Paths) == 0 {
+ t.Fatal("live spec has no paths")
+ }
+ for _, v := range checkPaginationDialects(spec.Paths) {
+ t.Error(v)
+ }
+}
+
+// TestPaginationDialectCheckerCatchesViolations proves the checker
+// actually bites, so a refactor cannot silently neuter the guard.
+func TestPaginationDialectCheckerCatchesViolations(t *testing.T) {
+ limitOK := json.RawMessage(`{"type":"integer","default":100,"maximum":1000}`)
+ limitBad := json.RawMessage(`{"type":"integer"}`)
+ resp400 := map[string]json.RawMessage{"200": {}, "400": {}}
+ resp200 := map[string]json.RawMessage{"200": {}}
+ cases := []struct {
+ name string
+ paths map[string]map[string]specOperation
+ want string
+ }{
+ {
+ name: "offset dialect rejected",
+ paths: map[string]map[string]specOperation{
+ "/v0/widgets": {"get": {Parameters: []specParam{
+ {Name: "offset", In: "query"}, {Name: "limit", In: "query", Schema: limitOK},
+ }, Responses: resp400}},
+ },
+ want: "must speak keyset",
+ },
+ {
+ name: "novel cursor name rejected (next/page_token class)",
+ paths: map[string]map[string]specOperation{
+ "/v0/widgets": {"get": {Parameters: []specParam{
+ {Name: "next", In: "query"}, {Name: "limit", In: "query", Schema: limitOK},
+ }, Responses: resp400}},
+ },
+ want: "must speak keyset",
+ },
+ {
+ name: "grandfathered dialect drift rejected",
+ paths: map[string]map[string]specOperation{
+ "/v0/city/{cityName}/orders/history": {"get": {Parameters: []specParam{
+ {Name: "before", In: "query"}, {Name: "after", In: "query"}, {Name: "limit", In: "query", Schema: limitOK},
+ }, Responses: resp400}},
+ },
+ want: "drifted",
+ },
+ {
+ name: "keyset without 400 rejected",
+ paths: map[string]map[string]specOperation{
+ "/v0/widgets": {"get": {Parameters: []specParam{
+ {Name: "cursor", In: "query"}, {Name: "limit", In: "query", Schema: limitOK},
+ }, Responses: resp200}},
+ },
+ want: "does not declare a 400",
+ },
+ {
+ name: "ad-hoc limit schema rejected",
+ paths: map[string]map[string]specOperation{
+ "/v0/widgets": {"get": {Parameters: []specParam{
+ {Name: "cursor", In: "query"}, {Name: "limit", In: "query", Schema: limitBad},
+ }, Responses: resp400}},
+ },
+ want: "unified page contract",
+ },
+ {
+ name: "stale grandfather entry rejected",
+ paths: map[string]map[string]specOperation{
+ "/v0/other": {"get": {Parameters: []specParam{{Name: "limit", In: "query", Schema: limitOK}}, Responses: resp200}},
+ },
+ want: "no longer in the spec",
+ },
+ {
+ name: "unlisted limit-only feed rejected",
+ paths: map[string]map[string]specOperation{
+ "/v0/gadgets": {"get": {Parameters: []specParam{
+ {Name: "limit", In: "query", Schema: limitOK},
+ }, Responses: resp200}},
+ },
+ want: "not allowlisted",
+ },
+ {
+ name: "stale bounded-feed entry rejected",
+ paths: map[string]map[string]specOperation{
+ // A pure keyset endpoint with none of the allowlisted bounded
+ // feeds present, so every boundedLimitOnlyFeeds entry reports
+ // itself stale.
+ "/v0/gadgets": {"get": {Parameters: []specParam{
+ {Name: "cursor", In: "query"}, {Name: "limit", In: "query", Schema: limitOK},
+ }, Responses: resp400}},
+ },
+ want: "no longer appears as one in the spec",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ violations := checkPaginationDialects(tc.paths)
+ for _, v := range violations {
+ if strings.Contains(v, tc.want) {
+ return
+ }
+ }
+ t.Fatalf("checker missed the violation (want substring %q), got: %v", tc.want, violations)
+ })
+ }
+}
diff --git a/internal/api/pagination_test.go b/internal/api/pagination_test.go
index 746ba9fc5c..f6f04d6f02 100644
--- a/internal/api/pagination_test.go
+++ b/internal/api/pagination_test.go
@@ -16,8 +16,8 @@ func TestParsePagination_LimitZeroMeansAll(t *testing.T) {
func TestParsePagination_DefaultLimit(t *testing.T) {
req := httptest.NewRequest("GET", "/v0/city/test-city/beads", nil)
pp := parsePagination(req)
- if pp.Limit != 50 {
- t.Errorf("default limit should be 50, got %d", pp.Limit)
+ if pp.Limit != defaultPaginationLimit {
+ t.Errorf("default limit should be %d, got %d", defaultPaginationLimit, pp.Limit)
}
}
@@ -32,7 +32,7 @@ func TestParsePagination_ExplicitLimit(t *testing.T) {
func TestParsePagination_NegativeLimitUsesDefault(t *testing.T) {
req := httptest.NewRequest("GET", "/v0/city/test-city/beads?limit=-5", nil)
pp := parsePagination(req)
- if pp.Limit != 50 {
- t.Errorf("negative limit should fall back to default 50, got %d", pp.Limit)
+ if pp.Limit != defaultPaginationLimit {
+ t.Errorf("negative limit should fall back to the default %d, got %d", defaultPaginationLimit, pp.Limit)
}
}
diff --git a/internal/api/rigidem_hardening_test.go b/internal/api/rigidem_hardening_test.go
index f87ca02bab..a831ed50d5 100644
--- a/internal/api/rigidem_hardening_test.go
+++ b/internal/api/rigidem_hardening_test.go
@@ -368,3 +368,79 @@ func TestRigCreateAsyncSameRequestIDDifferentNameSerialized(t *testing.T) {
t.Fatalf("post-race lookupIdemRecord = %v, want nil (unpoisoned)", err)
}
}
+
+// TestRigIdemRecloneRefusesForeignSameName proves the reviewed blocker fix: a
+// re-clone re-checks the name axis before re-registering byName and pre-dropping
+// the prior manifest. After this request_id's attempt rolled back, a DIFFERENT
+// actor may have taken the same rig name; the re-clone must return a rig_name
+// conflict instead of overwriting byName and tearing down that actor's live
+// working tree. The final sub-case is the negative control: a genuinely-free
+// name still re-clones (the request's own record must not self-block).
+func TestRigIdemRecloneRefusesForeignSameName(t *testing.T) {
+ body := RigCreateBody{Name: "web", Path: "/srv/web", GitURL: "g://x", RequestID: "req-A-000001"}
+ digest, _ := rigCreateDigest(body)
+
+ t.Run("foreign live byName blocks reclone", func(t *testing.T) {
+ store := beads.NewMemStore()
+ idx := newRigIdemIndex()
+ if _, err := createIdemRecord(store, "c1", "req-A-000001", digest, "3", "web", idemStateRolledBack); err != nil {
+ t.Fatal(err)
+ }
+ // A second actor is mid-provision under the same name.
+ foreign := &liveProvision{requestID: "req-B-000002", rigName: "web", eventCursor: "7", done: make(chan struct{})}
+ idx.register("c1", foreign)
+
+ res, err := admitRigCreate(idx, store, fixedCursor("9"), nil, nil, "c1", body)
+ var conflict *rigNameConflictError
+ if !errors.As(err, &conflict) {
+ t.Fatalf("err = %v (outcome %d), want *rigNameConflictError", err, res.outcome)
+ }
+ if conflict.InFlightRequestID != "req-B-000002" {
+ t.Fatalf("conflict.InFlightRequestID = %q, want req-B-000002", conflict.InFlightRequestID)
+ }
+ if res.entry != nil {
+ t.Fatalf("res.entry = %+v, want nil (no fresh registration on conflict)", res.entry)
+ }
+ if !res.recloneManifest.IsEmpty() {
+ t.Fatalf("res.recloneManifest = %+v, want empty (no teardown of the foreign rig)", res.recloneManifest)
+ }
+ // The foreign live entry must be untouched by the refused admission.
+ if live, ok := idx.lookupByName("c1", "web"); !ok || live != foreign {
+ t.Fatalf("foreign byName entry disturbed: ok=%v live=%+v", ok, live)
+ }
+ })
+
+ t.Run("foreign live in config blocks reclone", func(t *testing.T) {
+ store := beads.NewMemStore()
+ idx := newRigIdemIndex()
+ if _, err := createIdemRecord(store, "c1", "req-A-000001", digest, "3", "web", idemStateRolledBack); err != nil {
+ t.Fatal(err)
+ }
+ inConfig := func(string) bool { return true }
+ res, err := admitRigCreate(idx, store, fixedCursor("9"), inConfig, nil, "c1", body)
+ var conflict *rigNameConflictError
+ if !errors.As(err, &conflict) {
+ t.Fatalf("err = %v (outcome %d), want *rigNameConflictError", err, res.outcome)
+ }
+ if res.entry != nil {
+ t.Fatalf("res.entry = %+v, want nil", res.entry)
+ }
+ })
+
+ t.Run("own free name still reclones", func(t *testing.T) {
+ store := beads.NewMemStore()
+ idx := newRigIdemIndex()
+ id, err := createIdemRecord(store, "c1", "req-A-000001", digest, "3", "web", idemStateRolledBack)
+ if err != nil {
+ t.Fatal(err)
+ }
+ free := func(string) bool { return false }
+ res, err := admitRigCreate(idx, store, fixedCursor("9"), free, nil, "c1", body)
+ if err != nil {
+ t.Fatalf("admit: %v", err)
+ }
+ if res.outcome != rigAdmitReclone || res.entry == nil || res.entry.beadID != id {
+ t.Fatalf("free-name outcome = %d entry=%+v, want rigAdmitReclone reusing %s", res.outcome, res.entry, id)
+ }
+ })
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 6bc94b7e08..9b6df9a4e8 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -11,8 +11,7 @@ import (
"golang.org/x/sync/singleflight"
"github.com/gastownhall/gascity/internal/config"
- "github.com/gastownhall/gascity/internal/formula"
- "github.com/gastownhall/gascity/internal/molecule"
+ "github.com/gastownhall/gascity/internal/featureflags"
"github.com/gastownhall/gascity/internal/rollout"
"github.com/gastownhall/gascity/internal/sling"
"github.com/gastownhall/gascity/internal/webhookverify"
@@ -70,6 +69,10 @@ type Server struct {
// session JSONL files. Nil means use worker.DefaultSearchPaths().
sessionLogSearchPaths []string
+ // structuredPeekPoll overrides the structured fallback stream's periodic
+ // history check in tests. Nil uses outputStreamPollInterval.
+ structuredPeekPoll <-chan time.Time
+
// idem caches responses for Idempotency-Key replay on create endpoints.
idem *idempotencyCache
@@ -101,7 +104,8 @@ type Server struct {
storeHealthMu sync.Mutex
storeHealthEntry *StatusStoreHealth
storeHealthExpires time.Time
- storeHealthComputer func(ctx context.Context) *StatusStoreHealth
+ storeHealthComputer func(ctx context.Context) (*StatusStoreHealth, error)
+ storeHealthFlight singleflight.Group
// statusWarm holds the last background-built /v0/status body (full + lite
// variants) so the request path serves a snapshot instead of running the
@@ -292,13 +296,7 @@ func newServer(state State, readOnly bool) *Server {
// feature flags based on the city's daemon config. Called from New
// and NewReadOnly so both modes observe the same flag state.
func syncFeatureFlags(cfg *config.City) {
- enabled := cfg != nil && cfg.Daemon.FormulaV2Enabled()
- if formula.IsFormulaV2Enabled() != enabled {
- formula.SetFormulaV2Enabled(enabled)
- }
- if molecule.IsGraphApplyEnabled() != enabled {
- molecule.SetGraphApplyEnabled(enabled)
- }
+ featureflags.Apply(featureflags.FromConfig(cfg))
}
type singleStateResolver struct {
diff --git a/internal/api/session_frame_types.go b/internal/api/session_frame_types.go
index 7bd52107b3..38752d3b17 100644
--- a/internal/api/session_frame_types.go
+++ b/internal/api/session_frame_types.go
@@ -151,7 +151,8 @@ func (SessionRawMessageFrame) Schema(r huma.Registry) *huma.Schema {
// SessionStreamCommonEvent is a documentation-only union over the
// lifecycle/state events emitted on the session SSE stream
-// (SessionActivityEvent, runtime.PendingInteraction, HeartbeatEvent).
+// (SessionActivityEvent, runtime.PendingInteraction,
+// SessionPendingClearedEvent, HeartbeatEvent).
// The wire shape of each variant is unchanged; this type exists purely
// to give downstream consumers a single schema name that groups the
// non-message events the stream can emit.
@@ -165,6 +166,7 @@ func (SessionStreamCommonEvent) Schema(r huma.Registry) *huma.Schema {
variants := []reflect.Type{
reflect.TypeOf(SessionActivityEvent{}),
reflect.TypeOf(runtime.PendingInteraction{}),
+ reflect.TypeOf(SessionPendingClearedEvent{}),
reflect.TypeOf(HeartbeatEvent{}),
}
oneOf := make([]*huma.Schema, len(variants))
@@ -173,7 +175,7 @@ func (SessionStreamCommonEvent) Schema(r huma.Registry) *huma.Schema {
}
r.Map()[name] = &huma.Schema{
Title: "Session stream lifecycle event",
- Description: "Non-message events emitted on the session SSE stream: activity transitions, pending interactions, and keepalive heartbeats. The concrete variant is identified by the SSE event name.",
+ Description: "Non-message events emitted on the session SSE stream: activity transitions, pending-interaction lifecycle updates, and keepalive heartbeats. The concrete variant is identified by the SSE event name.",
OneOf: oneOf,
}
}
diff --git a/internal/api/session_model_phase0_interface_spec_test.go b/internal/api/session_model_phase0_interface_spec_test.go
index 51a279d932..8459496087 100644
--- a/internal/api/session_model_phase0_interface_spec_test.go
+++ b/internal/api/session_model_phase0_interface_spec_test.go
@@ -67,7 +67,6 @@ func TestPhase0APISessionTargetingSurfaces_RejectTemplateFactoryTargets(t *testi
},
}
- asyncOps := map[string]bool{"POST /messages": true, "POST /submit": true}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := newPhase0APIOrdinaryWorkerState(t)
@@ -77,14 +76,11 @@ func TestPhase0APISessionTargetingSurfaces_RejectTemplateFactoryTargets(t *testi
rec := httptest.NewRecorder()
h.ServeHTTP(rec, tt.req(fs))
- if asyncOps[tt.name] {
- if rec.Code != http.StatusAccepted {
- t.Fatalf("%s status = %d, want 202; body=%s", tt.name, rec.Code, rec.Body.String())
- }
- } else {
- if rec.Code < 400 {
- t.Fatalf("%s accepted template:worker with status %d; body=%s", tt.name, rec.Code, rec.Body.String())
- }
+ // Async command surfaces reject undeliverable targets
+ // synchronously since the deliverability gate (2026-07-18);
+ // every surface now refuses template-factory targets up front.
+ if rec.Code < 400 {
+ t.Fatalf("%s accepted template:worker with status %d; body=%s", tt.name, rec.Code, rec.Body.String())
}
})
}
@@ -127,7 +123,6 @@ func TestPhase0APISessionTargetingSurfaces_BareConfigNameDoesNotCreateOrdinarySe
},
}
- asyncOps := map[string]bool{"POST /messages": true, "POST /submit": true}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := newPhase0APIOrdinaryWorkerState(t)
@@ -137,14 +132,10 @@ func TestPhase0APISessionTargetingSurfaces_BareConfigNameDoesNotCreateOrdinarySe
rec := httptest.NewRecorder()
h.ServeHTTP(rec, tt.req(fs))
- if asyncOps[tt.name] {
- if rec.Code != http.StatusAccepted {
- t.Fatalf("%s status = %d, want 202; body=%s", tt.name, rec.Code, rec.Body.String())
- }
- } else {
- if rec.Code < 400 {
- t.Fatalf("%s accepted ordinary config name worker with status %d; body=%s", tt.name, rec.Code, rec.Body.String())
- }
+ // Async command surfaces reject undeliverable targets
+ // synchronously since the deliverability gate (2026-07-18).
+ if rec.Code < 400 {
+ t.Fatalf("%s accepted ordinary config name worker with status %d; body=%s", tt.name, rec.Code, rec.Body.String())
}
})
}
diff --git a/internal/api/session_resolution.go b/internal/api/session_resolution.go
index 9014f009f5..6949ae82c2 100644
--- a/internal/api/session_resolution.go
+++ b/internal/api/session_resolution.go
@@ -305,7 +305,6 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b
ResumeCommand: resolved.ResumeCommand,
SessionIDFlag: resolved.SessionIDFlag,
}
- mgr := s.sessionManager(store)
extraMeta := map[string]string{
apiNamedSessionMetadataKey: "true",
apiNamedSessionIdentityKey: spec.Identity,
@@ -328,8 +327,37 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b
return "", err
}
}
- sessionEnv := cityAnchoredSessionEnv(s.state.CityPath(), resolved.Env)
+ sessionEnv := cityAnchoredSessionEnv(s.state.CityPath(), configuredWorkspaceSessionEnv(s.state.Config()), resolved.Env)
hints := sessionCreateHints(resolved, sessionEnv, mcpServers)
+ // Route the named-session create through the worker.Handle boundary
+ // (worker-boundary migration) rather than calling session.Manager directly.
+ // SessionSpecForResolvedRuntime maps this config 1:1 onto the same
+ // CreateAliasedNamedWithTransportAndMetadata call createStartedLocked makes
+ // (alias, name, template, title, command, workdir, provider, transport, env,
+ // resume, hints, metadata), so the created session is identical; the handle
+ // additionally emits the uniform worker create-operation event.
+ resolvedCfg := worker.ResolvedSessionConfig{
+ Alias: spec.Identity,
+ ExplicitName: spec.SessionName,
+ Template: qualifiedTemplate,
+ Title: spec.Identity,
+ Transport: transport,
+ Metadata: extraMeta,
+ Runtime: worker.ResolvedRuntime{
+ // Backfill an empty command with the provider name, matching the
+ // sibling boundary consumer (resolvedSessionConfigForProvider) and
+ // cmd/gc/worker_handle.go. A command-less custom provider otherwise
+ // hard-fails NormalizeResolvedRuntime ("command is required") where
+ // the old direct path minted a (doomed) session — the backfill keeps
+ // the create succeeding and converges this path with the adhoc one.
+ Command: firstNonEmptyString(launchCommand.Command, resolved.Name),
+ WorkDir: workDir,
+ Provider: resolved.Name,
+ SessionEnv: sessionEnv,
+ Resume: resume,
+ Hints: hints,
+ },
+ }
var info session.Info
err = session.WithCitySessionIdentifierLocks(s.state.CityPath(), []string{spec.Identity, spec.SessionName}, func() error {
if err := session.EnsureAliasAvailableWithConfigForOwner(store, s.state.Config(), spec.Identity, "", spec.Identity); err != nil {
@@ -338,21 +366,12 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b
if err := session.EnsureSessionNameAvailableWithConfigForOwner(store, s.state.Config(), spec.SessionName, "", spec.Identity); err != nil {
return err
}
+ handle, herr := s.newResolvedWorkerSessionHandle(store, resolvedCfg)
+ if herr != nil {
+ return herr
+ }
var createErr error
- info, createErr = mgr.CreateSession(ctx, session.CreateOptions{
- Alias: spec.Identity,
- ExplicitName: spec.SessionName,
- Template: qualifiedTemplate,
- Title: spec.Identity,
- Command: launchCommand.Command,
- WorkDir: workDir,
- Provider: resolved.Name,
- Transport: transport,
- Env: sessionEnv,
- Resume: resume,
- Hints: hints,
- ExtraMeta: extraMeta,
- })
+ info, createErr = handle.Create(ctx, worker.CreateModeStarted)
return createErr
})
if err == nil {
@@ -589,6 +608,29 @@ func (s *Server) resolveSessionIDAllowClosedWithConfig(store beads.Store, identi
return s.resolveSessionTargetID(store, identifier, apiSessionResolveOptions{allowClosed: true})
}
+// sessionTargetDeliverable reports whether a message/submit target is
+// deliverable: it resolves to an existing session without materializing, or
+// names a configured named session the materializing async path can wake.
+// The async command handlers (POST /session/{id}/messages, /submit) used to
+// accept ANY identifier with 202 and only discover resolve_failed inside the
+// post-accept goroutine, surfacing it solely as an event — callers treating
+// 202 as delivery proof black-holed messages to typo'd/drifted session names
+// (2026-07-18: three drifted Slack company-room bindings dropped cross-city
+// wakes for days). This gate restores the declared-404 contract for targets
+// that can never deliver, while keeping the accept-then-work model for slow
+// paths (cold named-session wakes).
+func (s *Server) sessionTargetDeliverable(ctx context.Context, store beads.Store, identifier string) error {
+ if _, err := s.resolveSessionTargetIDWithContext(ctx, store, identifier, apiSessionResolveOptions{}); err == nil {
+ return nil
+ } else if !errors.Is(err, session.ErrSessionNotFound) {
+ return err
+ }
+ if _, ok, specErr := s.findNamedSessionSpecForTarget(store, identifier); specErr == nil && ok {
+ return nil
+ }
+ return apiSessionTargetNotFound(identifier)
+}
+
func (s *Server) resolveSessionIDMaterializingNamed(store beads.Store, identifier string) (string, error) {
return s.resolveSessionTargetID(store, identifier, apiSessionResolveOptions{materialize: true})
}
diff --git a/internal/api/session_resolved_config.go b/internal/api/session_resolved_config.go
index 156ba015d6..8954609a2d 100644
--- a/internal/api/session_resolved_config.go
+++ b/internal/api/session_resolved_config.go
@@ -10,7 +10,9 @@ import (
)
func resolvedSessionConfigForProvider(
- cityPath, alias, explicitName, template, title, transport string,
+ cityPath string,
+ workspaceEnv map[string]string,
+ alias, explicitName, template, title, transport string,
metadata map[string]string,
resolved *config.ResolvedProvider,
command, workDir string,
@@ -36,7 +38,7 @@ func resolvedSessionConfigForProvider(
if transport == "acp" {
resolvedCommand = resolved.ACPCommandString()
}
- sessionEnv := cityAnchoredSessionEnv(cityPath, resolved.Env)
+ sessionEnv := cityAnchoredSessionEnv(cityPath, workspaceEnv, resolved.Env)
return worker.NormalizeResolvedSessionConfig(worker.ResolvedSessionConfig{
Alias: alias,
ExplicitName: explicitName,
diff --git a/internal/api/session_resolved_config_test.go b/internal/api/session_resolved_config_test.go
index ad8d9c918f..34c0b9238c 100644
--- a/internal/api/session_resolved_config_test.go
+++ b/internal/api/session_resolved_config_test.go
@@ -1,20 +1,33 @@
package api
import (
+ "os"
"path/filepath"
+ "strings"
"testing"
"github.com/gastownhall/gascity/internal/config"
+ "github.com/gastownhall/gascity/internal/convergence"
"github.com/gastownhall/gascity/internal/runtime"
"github.com/gastownhall/gascity/internal/session"
)
func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) {
+ t.Setenv("API_SESSION_WORKSPACE_VALUE", "expanded-workspace-value")
metadata := map[string]string{
"session_origin": "named",
"agent_name": "myrig/worker-adhoc-123",
}
- env := map[string]string{"API_TOKEN": "present"}
+ workspaceEnv := map[string]string{
+ "WORKSPACE_ONLY": "$API_SESSION_WORKSPACE_VALUE",
+ "SESSION_ENV_PRECEDENCE": "workspace",
+ "GC_BIN": "/workspace/bin/gc",
+ }
+ env := map[string]string{
+ "API_TOKEN": "present",
+ "SESSION_ENV_PRECEDENCE": "provider",
+ "GC_BIN": "/provider/bin/gc",
+ }
mcpServers := []runtime.MCPServerConfig{{
Name: "filesystem",
Command: "/bin/mcp",
@@ -36,6 +49,7 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) {
cfg, err := resolvedSessionConfigForProvider(
"/tmp/test-city",
+ workspaceEnv,
"worker",
"worker-named",
"myrig/worker",
@@ -90,11 +104,86 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) {
if got, want := cfg.Runtime.SessionEnv["API_TOKEN"], "present"; got != want {
t.Fatalf("Runtime.SessionEnv[API_TOKEN] = %q, want %q", got, want)
}
+ gcBin, err := os.Executable()
+ if err != nil {
+ t.Fatalf("os.Executable: %v", err)
+ }
+ for key, want := range map[string]string{
+ "WORKSPACE_ONLY": "expanded-workspace-value",
+ "SESSION_ENV_PRECEDENCE": "provider",
+ "GC_BIN": gcBin,
+ } {
+ if got := cfg.Runtime.SessionEnv[key]; got != want {
+ t.Errorf("Runtime.SessionEnv[%s] = %q, want %q", key, got, want)
+ }
+ if got := cfg.Runtime.Hints.Env[key]; got != want {
+ t.Errorf("Runtime.Hints.Env[%s] = %q, want %q", key, got, want)
+ }
+ }
+ // PR #4577 review (behavioral-correctness major): the API create path must
+ // pair authoritative GC_BIN with the same PATH prepend the CLI applies, so a
+ // bare `gc` in the session resolves to this binary, not a colliding one.
+ wantPATHPrefix := filepath.Dir(gcBin)
+ for name, env := range map[string]map[string]string{
+ "Runtime.SessionEnv": cfg.Runtime.SessionEnv,
+ "Runtime.Hints.Env": cfg.Runtime.Hints.Env,
+ } {
+ parts := strings.Split(env["PATH"], string(os.PathListSeparator))
+ if len(parts) == 0 || parts[0] != wantPATHPrefix {
+ t.Errorf("%s[PATH] = %q, want first entry %q (dir of GC_BIN)", name, env["PATH"], wantPATHPrefix)
+ }
+ }
+}
+
+// TestResolvedSessionConfigForProviderScrubsControllerToken is the regression
+// for the PR #4577 review (security major): cityAnchoredSessionEnv expands
+// workspace and provider env against the controller process, so a configured
+// `GC_CONTROLLER_TOKEN = "$GC_CONTROLLER_TOKEN"` (or a literal) would otherwise
+// leak the controller-only token into a managed session. The final API env must
+// scrub convergence.TokenEnvVar — matching cmd/gc/template_resolve.go — so it
+// reaches neither Runtime.SessionEnv nor Runtime.Hints.Env, regardless of which
+// layer supplied it.
+func TestResolvedSessionConfigForProviderScrubsControllerToken(t *testing.T) {
+ t.Setenv(convergence.TokenEnvVar, "super-secret-controller-token")
+ workspaceEnv := map[string]string{
+ // Expands from the controller process env — the exact leak vector.
+ convergence.TokenEnvVar: "$" + convergence.TokenEnvVar,
+ }
+ cfg, err := resolvedSessionConfigForProvider(
+ "/tmp/test-city",
+ workspaceEnv,
+ "worker",
+ "",
+ "myrig/worker",
+ "Worker",
+ "",
+ nil,
+ &config.ResolvedProvider{
+ Name: "stub",
+ Command: "/bin/echo",
+ Env: map[string]string{
+ convergence.TokenEnvVar: "literal-token-value",
+ },
+ },
+ "",
+ "/tmp/workdir",
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("resolvedSessionConfigForProvider: %v", err)
+ }
+ if got, present := cfg.Runtime.SessionEnv[convergence.TokenEnvVar]; present {
+ t.Errorf("Runtime.SessionEnv[%s] = %q present, want scrubbed", convergence.TokenEnvVar, got)
+ }
+ if got, present := cfg.Runtime.Hints.Env[convergence.TokenEnvVar]; present {
+ t.Errorf("Runtime.Hints.Env[%s] = %q present, want scrubbed", convergence.TokenEnvVar, got)
+ }
}
func TestResolvedSessionConfigForProviderRejectsNilProvider(t *testing.T) {
if _, err := resolvedSessionConfigForProvider(
"/tmp/test-city",
+ nil,
"worker",
"",
"myrig/worker",
@@ -178,6 +267,7 @@ func TestResolvedSessionConfigForProviderSeedsCityRuntimeEnv(t *testing.T) {
cityPath := t.TempDir()
cfg, err := resolvedSessionConfigForProvider(
cityPath,
+ nil,
"worker",
"",
"myrig/worker",
@@ -267,6 +357,7 @@ func TestResolvedSessionConfigForProviderCityAnchorsBeatConflictingProviderEnv(t
cityPath := t.TempDir()
cfg, err := resolvedSessionConfigForProvider(
cityPath,
+ nil,
"worker",
"",
"myrig/worker",
@@ -302,7 +393,7 @@ func TestCityAnchoredSessionEnvSkipsCityAnchorsWhenCityPathEmpty(t *testing.T) {
"PROVIDER_TOKEN": "ok",
}
- got := cityAnchoredSessionEnv(" \t\n ", providerEnv)
+ got := cityAnchoredSessionEnv(" \t\n ", nil, providerEnv)
if got["GC_CITY"] != "/provider/city" {
t.Fatalf("GC_CITY = %q, want provider value", got["GC_CITY"])
}
@@ -325,6 +416,7 @@ func TestCityAnchoredSessionEnvSkipsCityAnchorsWhenCityPathEmpty(t *testing.T) {
func TestResolvedSessionConfigForProviderSkipsStoredMCPMetadataForTmuxTransport(t *testing.T) {
cfg, err := resolvedSessionConfigForProvider(
"/tmp/test-city",
+ nil,
"worker",
"",
"myrig/worker",
diff --git a/internal/api/session_runtime.go b/internal/api/session_runtime.go
index ac9fccbe8e..a1e6f0f928 100644
--- a/internal/api/session_runtime.go
+++ b/internal/api/session_runtime.go
@@ -3,11 +3,13 @@ package api
import (
"errors"
"fmt"
+ "os"
"os/exec"
"strings"
"github.com/gastownhall/gascity/internal/citylayout"
"github.com/gastownhall/gascity/internal/config"
+ "github.com/gastownhall/gascity/internal/convergence"
"github.com/gastownhall/gascity/internal/materialize"
"github.com/gastownhall/gascity/internal/processenv"
"github.com/gastownhall/gascity/internal/runtime"
@@ -17,12 +19,19 @@ import (
)
// cityAnchoredSessionEnv returns the provider process baseline merged with the
-// resolved provider env and the three city-anchored env vars (GC_CITY,
-// GC_CITY_PATH, GC_CITY_RUNTIME_DIR). Resolved provider env overrides process
-// passthrough values, and city anchors win on conflicts to mirror the
-// canonical create-time layering in cmd/gc/template_resolve.go where the
-// per-agent env (which carries the same anchors) is applied after the resolved
-// provider env.
+// configured workspace env, resolved provider/agent env, the three
+// city-anchored env vars (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR), and the
+// canonical path to the running gc binary. Later layers win, matching the
+// create-time precedence in cmd/gc/template_resolve.go: workspace env is the
+// lowest config layer, provider/agent env can override it, and runtime-owned
+// city anchors plus GC_BIN are authoritative. TOML-sourced workspace and
+// provider values support the same $VAR expansion as the CLI launch path.
+//
+// As the final step — mirroring the CLI env finalization in template_resolve.go
+// — the gc binary's directory is prepended to PATH so a bare `gc` in the
+// session resolves to this binary rather than a colliding one, and
+// GC_CONTROLLER_TOKEN is scrubbed so the controller-only token never reaches a
+// managed session even when a workspace/provider env entry expands to it.
//
// Without these anchors, sessions spawned or restarted via the API code
// paths cannot locate their city. Rig-scoped env remains a separate
@@ -36,23 +45,38 @@ import (
// regress per-dispatcher trace files for control-dispatcher sessions
// restarted through the API. Dispatcher-trace handling stays the
// responsibility of the caller that knows the qualified agent name.
-func cityAnchoredSessionEnv(cityPath string, providerEnv map[string]string) map[string]string {
+func cityAnchoredSessionEnv(cityPath string, workspaceEnv, providerEnv map[string]string) map[string]string {
baseline := processenv.ProviderProcessPassthroughEnv()
anchors := citylayout.CityIdentityEnvMap(cityPath)
- if len(baseline) == 0 && len(providerEnv) == 0 && len(anchors) == 0 {
+ gcBin, _ := os.Executable()
+ if len(baseline) == 0 && len(workspaceEnv) == 0 && len(providerEnv) == 0 && len(anchors) == 0 && gcBin == "" {
return nil
}
- out := make(map[string]string, len(baseline)+len(providerEnv)+len(anchors))
+ out := make(map[string]string, len(baseline)+len(workspaceEnv)+len(providerEnv)+len(anchors)+1)
for k, v := range baseline {
out[k] = v
}
+ for k, v := range workspaceEnv {
+ out[k] = os.ExpandEnv(v)
+ }
for k, v := range providerEnv {
- out[k] = v
+ out[k] = os.ExpandEnv(v)
}
for k, v := range anchors {
out[k] = v
}
- return out
+ if gcBin != "" {
+ out["GC_BIN"] = gcBin
+ processenv.PrependGCBinDirToPATH(out, gcBin)
+ }
+ return convergence.ScrubTokenEnv(out)
+}
+
+func configuredWorkspaceSessionEnv(cfg *config.City) map[string]string {
+ if cfg == nil {
+ return nil
+ }
+ return cfg.Workspace.Env
}
var errAmbiguousLegacyACPTransport = errors.New("legacy session transport is ambiguous")
@@ -352,7 +376,7 @@ func (s *Server) buildSessionResume(info session.Info) (string, runtime.Config,
resolvedInfo.ResumeFlag = resolved.ResumeFlag
resolvedInfo.ResumeStyle = resolved.ResumeStyle
resolvedInfo.ResumeCommand = resumeCommand
- sessionEnv := cityAnchoredSessionEnv(s.state.CityPath(), resolved.Env)
+ sessionEnv := cityAnchoredSessionEnv(s.state.CityPath(), configuredWorkspaceSessionEnv(s.state.Config()), resolved.Env)
return session.BuildResumeCommand(resolvedInfo), sessionResumeHints(resolved, workDir, sessionEnv, mcpServers, sessionResumeInteractive(metadata)), nil
}
@@ -470,7 +494,7 @@ func (s *Server) resolveWorkerSessionRuntimeWithMetadata(info session.Info, _ st
resumeCommand = command
}
}
- sessionEnv := cityAnchoredSessionEnv(s.state.CityPath(), resolved.Env)
+ sessionEnv := cityAnchoredSessionEnv(s.state.CityPath(), configuredWorkspaceSessionEnv(s.state.Config()), resolved.Env)
runtimeCfg, err := worker.NormalizeResolvedRuntime(worker.ResolvedRuntime{
Command: command,
WorkDir: firstNonEmptyString(info.WorkDir, workDir),
diff --git a/internal/api/session_structured_providers_test.go b/internal/api/session_structured_providers_test.go
new file mode 100644
index 0000000000..6bca07a6cd
--- /dev/null
+++ b/internal/api/session_structured_providers_test.go
@@ -0,0 +1,2616 @@
+package api
+
+import (
+ "context"
+ "crypto/md5" //nolint:gosec // Kimi transcript fixtures use the provider's MD5 workdir layout.
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/beads"
+ "github.com/gastownhall/gascity/internal/config"
+ "github.com/gastownhall/gascity/internal/runtime"
+ "github.com/gastownhall/gascity/internal/session"
+ "github.com/gastownhall/gascity/internal/sessionlog"
+ "github.com/gastownhall/gascity/internal/testutil"
+)
+
+// isolateProviderDiscovery points provider transcript discovery at an empty,
+// per-test HOME so the structured handler tests never wander into the
+// developer's real provider session directories (for example a large
+// ~/.codex). Real provider dirs make discovery slow and the no-transcript
+// downgrade path nondeterministic against the streaming read deadline; this
+// keeps these tests hermetic regardless of the host machine.
+func isolateProviderDiscovery(t *testing.T) {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
+}
+
+func TestHandleSessionTranscriptStructuredNormalizesFirstClassProviders(t *testing.T) {
+ resume := session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }
+
+ tests := []struct {
+ name string
+ provider string
+ writeFixture func(t *testing.T, root, workDir, sessionKey string)
+ toolCallID string
+ toolName string
+ inputKind string
+ inputFilePath string
+ inputURL string
+ inputPrompt string
+ inputQuestion string
+ inputOptions []string
+ inputCommand string
+ inputQuery string
+ inputPattern string
+ inputText string
+ inputPlan string
+ inputStepCount int
+ inputTodoCount int
+ inputArguments map[string]string
+ resultKind string
+ resultFile string
+ resultContent string
+ resultStdout string
+ resultExit *int
+ resultFiles []string
+ resultItemURLs []string
+ resultMode string
+ resultQuery string
+ resultCount int
+ resultURL string
+ resultStatus int
+ resultStatusText string
+ resultBytes int
+ resultDuration int
+ resultAppliedLimit int
+ resultTruncated bool
+ resultQuestion string
+ resultQuestions int
+ resultAnswer string
+ resultAnswers int
+ resultPlan string
+ resultStepCount int
+ resultOldTodos int
+ resultNewTodos int
+ resultPatch []string
+ resultOldString string
+ resultNewString string
+ resultOriginalFile string
+ resultReplaceAll *bool
+ resultUserModified *bool
+ resultAbsent []string
+ }{
+ {
+ name: "claude read",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeReadFixture,
+ toolCallID: "call-claude-read",
+ toolName: "Read",
+ inputKind: "file",
+ inputFilePath: "README.md",
+ resultKind: "read",
+ resultFile: "README.md",
+ resultContent: "Gas City README",
+ },
+ {
+ name: "claude edit",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeEditFixture,
+ toolCallID: "call-claude-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "updated successfully",
+ resultPatch: []string{"--- README.md", "+++ README.md", "-export const message = \"old line\";", "+export const message = \"new line\";"},
+ resultOldString: "old line",
+ resultNewString: "new line",
+ resultOriginalFile: "export const message = \"old line\";\n",
+ resultReplaceAll: boolPtr(false),
+ resultUserModified: boolPtr(false),
+ },
+ {
+ name: "codex patch",
+ provider: "codex",
+ writeFixture: writeStructuredCodexPatchFixture,
+ toolCallID: "call-codex-patch",
+ toolName: "apply_patch",
+ inputKind: "patch",
+ inputFilePath: "city.toml",
+ resultKind: "edit",
+ resultFile: "city.toml",
+ resultContent: "Updated the following files",
+ resultPatch: []string{"--- city.toml", "+++ city.toml", "+[workspace]"},
+ },
+ {
+ name: "codex shell read",
+ provider: "codex",
+ writeFixture: writeStructuredCodexShellReadFixture,
+ toolCallID: "call-codex-read",
+ toolName: "exec_command",
+ inputKind: "file",
+ inputFilePath: "src/app.ts",
+ inputCommand: "sed -n '12,14p' src/app.ts",
+ resultKind: "read",
+ resultFile: "src/app.ts",
+ resultContent: "line 13",
+ resultAbsent: []string{"Command:", "Output:"},
+ },
+ {
+ name: "codex wrapped shell read",
+ provider: "codex",
+ writeFixture: writeStructuredCodexWrappedShellReadFixture,
+ toolCallID: "call-codex-wrapped-read",
+ toolName: "exec_command",
+ inputKind: "file",
+ inputFilePath: "src/app.ts",
+ inputCommand: `/usr/bin/env bash -lc "sed -n '12,14p' src/app.ts"`,
+ resultKind: "read",
+ resultFile: "src/app.ts",
+ resultContent: "line 13",
+ resultAbsent: []string{"Command:", "Output:"},
+ },
+ {
+ name: "codex shell grep",
+ provider: "codex",
+ writeFixture: writeStructuredCodexShellGrepFixture,
+ toolCallID: "call-codex-grep",
+ toolName: "exec_command",
+ inputKind: "search",
+ inputCommand: "rg -n \"needle\" README.md src/app.ts",
+ inputPattern: "needle",
+ resultKind: "grep",
+ resultMode: "content",
+ resultFiles: []string{"README.md", "src/app.ts"},
+ resultAbsent: []string{"Command:", "Output:"},
+ },
+ {
+ name: "codex json string command output",
+ provider: "codex",
+ writeFixture: writeStructuredCodexJSONStringCommandFixture,
+ toolCallID: "call-codex-json-command",
+ toolName: "exec_command",
+ inputKind: "command",
+ inputCommand: "go test ./...",
+ resultKind: "bash",
+ resultStdout: "ok ./...\n",
+ resultExit: intPtr(0),
+ resultAbsent: []string{"{\"stdout\""},
+ },
+ {
+ name: "codex web search",
+ provider: "codex",
+ writeFixture: writeStructuredCodexWebSearchFixture,
+ toolCallID: "call-codex-web-search",
+ toolName: "web_search",
+ inputKind: "search",
+ inputQuery: "structured tool result formats",
+ resultKind: "search",
+ resultMode: "query",
+ resultQuery: "structured tool result formats",
+ resultCount: 1,
+ resultFiles: []string{"https://example.com/provider-format"},
+ resultItemURLs: []string{"https://example.com/provider-format"},
+ },
+ {
+ name: "claude glob",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeGlobFixture,
+ toolCallID: "call-claude-glob",
+ toolName: "Glob",
+ inputKind: "glob",
+ inputFilePath: "internal",
+ inputPattern: "**/*.go",
+ resultKind: "glob",
+ resultFiles: []string{"internal/api/session_structured_types.go", "internal/worker/structured_tool.go"},
+ resultDuration: 27,
+ resultTruncated: true,
+ },
+ {
+ name: "claude grep",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeGrepFixture,
+ toolCallID: "call-claude-grep",
+ toolName: "Grep",
+ inputKind: "search",
+ inputFilePath: "README.md",
+ inputPattern: "needle",
+ resultKind: "grep",
+ resultMode: "content",
+ resultFiles: []string{"README.md"},
+ resultContent: "README.md:1:needle",
+ resultAppliedLimit: 100,
+ },
+ {
+ name: "claude web search",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeWebSearchFixture,
+ toolCallID: "call-claude-search",
+ toolName: "WebSearch",
+ inputKind: "search",
+ inputQuery: "structured stream format",
+ resultKind: "search",
+ resultQuery: "structured stream format",
+ resultCount: 1,
+ resultDuration: 1250,
+ resultItemURLs: []string{"https://example.com/structured"},
+ },
+ {
+ name: "claude web fetch",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeWebFetchFixture,
+ toolCallID: "call-claude-fetch",
+ toolName: "WebFetch",
+ inputKind: "fetch",
+ inputURL: "https://example.com/spec",
+ inputPrompt: "Extract the structured contract",
+ resultKind: "fetch",
+ resultURL: "https://example.com/spec",
+ resultStatus: 200,
+ resultStatusText: "OK",
+ resultBytes: 4096,
+ resultDuration: 83,
+ resultContent: "Fetched structured spec content.",
+ },
+ {
+ name: "claude todo write",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeTodoWriteFixture,
+ toolCallID: "call-claude-todo",
+ toolName: "TodoWrite",
+ inputKind: "todo",
+ inputTodoCount: 1,
+ resultKind: "todo",
+ resultOldTodos: 1,
+ resultNewTodos: 2,
+ resultContent: "todos updated",
+ },
+ {
+ name: "claude exit plan mode",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeExitPlanFixture,
+ toolCallID: "call-claude-plan",
+ toolName: "ExitPlanMode",
+ inputKind: "plan",
+ inputPlan: "Inspect MC and expose typed plan data.",
+ resultKind: "plan",
+ resultPlan: "Inspect MC and expose typed plan data.",
+ resultContent: "plan captured",
+ },
+ {
+ name: "claude ask user question",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeAskQuestionFixture,
+ toolCallID: "call-claude-question",
+ toolName: "AskUserQuestion",
+ inputKind: "question",
+ inputQuestion: "Proceed with typed question DTOs?",
+ inputOptions: []string{"Yes", "No"},
+ resultKind: "question",
+ resultQuestion: "Select rollout scope",
+ resultQuestions: 1,
+ resultAnswer: "All providers",
+ resultAnswers: 1,
+ resultContent: "question answered",
+ },
+ {
+ name: "gemini grep",
+ provider: "gemini",
+ writeFixture: writeStructuredGeminiGrepFixture,
+ toolCallID: "call-gemini-grep",
+ toolName: "grep_search",
+ inputKind: "search",
+ inputPattern: "needle",
+ resultKind: "grep",
+ resultFiles: []string{"README.md", "main.go"},
+ },
+ {
+ name: "gemini write fileDiff",
+ provider: "gemini",
+ writeFixture: writeStructuredGeminiWriteFixture,
+ toolCallID: "call-gemini-write",
+ toolName: "write_file",
+ inputKind: "write",
+ inputFilePath: "notes.txt",
+ inputText: "hello gemini",
+ resultKind: "write",
+ resultFile: "notes.txt",
+ resultContent: "Successfully created",
+ resultPatch: []string{"Index: notes.txt", "+hello gemini"},
+ },
+ {
+ name: "gemini write content pair",
+ provider: "gemini",
+ writeFixture: writeStructuredGeminiWriteContentPairFixture,
+ toolCallID: "call-gemini-write",
+ toolName: "write_file",
+ inputKind: "write",
+ inputFilePath: "notes.txt",
+ inputText: "hello gemini",
+ resultKind: "write",
+ resultFile: "notes.txt",
+ resultContent: "Successfully created",
+ resultPatch: []string{"--- notes.txt", "-old text", "+hello gemini"},
+ },
+ {
+ name: "kimi read",
+ provider: "kimi",
+ writeFixture: writeStructuredKimiReadFixture,
+ toolCallID: "call-kimi-read",
+ toolName: "Read",
+ inputKind: "file",
+ inputFilePath: "README.md",
+ resultKind: "read",
+ resultFile: "README.md",
+ resultContent: "Kimi file data",
+ },
+ {
+ name: "kimi edit result patch",
+ provider: "kimi",
+ writeFixture: writeStructuredKimiEditPatchFixture,
+ toolCallID: "call-kimi-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "Edited README.md",
+ resultPatch: []string{"--- README.md", "-old", "+new"},
+ },
+ {
+ name: "opencode edit",
+ provider: "opencode",
+ writeFixture: writeStructuredOpenCodeEditFixture,
+ toolCallID: "call-opencode-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "Edited README.md",
+ },
+ {
+ name: "opencode edit result patch",
+ provider: "opencode",
+ writeFixture: writeStructuredOpenCodeEditPatchResultFixture,
+ toolCallID: "call-opencode-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "Edited README.md",
+ resultPatch: []string{"--- README.md", "-old", "+new"},
+ },
+ {
+ name: "groq opencode alias edit",
+ provider: "groq",
+ writeFixture: writeStructuredOpenCodeEditFixture,
+ toolCallID: "call-opencode-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "Edited README.md",
+ },
+ {
+ name: "cerebras opencode alias edit",
+ provider: "cerebras",
+ writeFixture: writeStructuredOpenCodeEditFixture,
+ toolCallID: "call-opencode-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "Edited README.md",
+ },
+ {
+ name: "mimocode bash",
+ provider: "mimocode",
+ writeFixture: writeStructuredMimoCodeBashFixture,
+ toolCallID: "call-mimocode-bash",
+ toolName: "Bash",
+ inputKind: "command",
+ inputCommand: "go test ./...",
+ resultKind: "bash",
+ resultStdout: "ok ./...",
+ resultExit: intPtr(0),
+ },
+ {
+ name: "mimocode bash git diff stays command",
+ provider: "mimocode",
+ writeFixture: writeStructuredMimoCodeBashDiffFixture,
+ toolCallID: "call-mimocode-diff",
+ toolName: "Bash",
+ inputKind: "command",
+ inputCommand: "git diff -- src/app.ts",
+ resultKind: "bash",
+ resultStdout: "diff --git a/src/app.ts b/src/app.ts\n@@\n-old\n+new",
+ },
+ {
+ name: "claude bash nested toolUseResult",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeBashToolUseResultFixture,
+ toolCallID: "call-claude-bash",
+ toolName: "Bash",
+ inputKind: "command",
+ inputCommand: "npm test",
+ resultKind: "bash",
+ resultStdout: "tests passed\n",
+ resultExit: intPtr(0),
+ },
+ {
+ name: "claude kill shell",
+ provider: "claude",
+ writeFixture: writeStructuredClaudeKillShellFixture,
+ toolCallID: "call-claude-kill",
+ toolName: "KillShell",
+ inputKind: "task",
+ resultKind: "bash",
+ resultStdout: "Shell shell-123 killed",
+ },
+ {
+ name: "pi read",
+ provider: "pi",
+ writeFixture: writeStructuredPiReadFixture,
+ toolCallID: "call-pi-read",
+ toolName: "read",
+ inputKind: "file",
+ inputFilePath: "README.md",
+ resultKind: "read",
+ resultFile: "README.md",
+ resultContent: "Pi file data",
+ },
+ {
+ name: "pi edit result patch",
+ provider: "pi",
+ writeFixture: writeStructuredPiEditPatchFixture,
+ toolCallID: "call-pi-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "README.md",
+ resultKind: "edit",
+ resultFile: "README.md",
+ resultContent: "Edited README.md",
+ resultPatch: []string{"--- README.md", "-old", "+new"},
+ },
+ {
+ name: "omp pi alias read",
+ provider: "omp",
+ writeFixture: writeStructuredPiReadFixture,
+ toolCallID: "call-pi-read",
+ toolName: "read",
+ inputKind: "file",
+ inputFilePath: "README.md",
+ resultKind: "read",
+ resultFile: "README.md",
+ resultContent: "Pi file data",
+ },
+ {
+ name: "kiro acp write result patch",
+ provider: "kiro",
+ writeFixture: writeStructuredKiroWritePatchFixture,
+ toolCallID: "call-kiro-write",
+ toolName: "write",
+ inputKind: "write",
+ inputFilePath: "notes.txt",
+ inputText: "hello kiro\n",
+ resultKind: "write",
+ resultFile: "notes.txt",
+ resultPatch: []string{"*** Update File: notes.txt", "-old", "+hello kiro"},
+ },
+ {
+ name: "amp stream-json edit result patch",
+ provider: "amp",
+ writeFixture: writeStructuredAmpEditPatchFixture,
+ toolCallID: "call-amp-edit",
+ toolName: "edit_file",
+ inputKind: "patch",
+ inputFilePath: "notes.txt",
+ resultKind: "edit",
+ resultFile: "notes.txt",
+ resultPatch: []string{"*** Update File: notes.txt", "-old", "+new"},
+ resultOldString: "old",
+ resultNewString: "new",
+ },
+ {
+ name: "cursor stream-json write",
+ provider: "cursor",
+ writeFixture: writeStructuredCursorWriteFixture,
+ toolCallID: "call-cursor-write",
+ toolName: "Write",
+ inputKind: "write",
+ inputFilePath: "notes.txt",
+ inputText: "hello cursor\n",
+ resultKind: "write",
+ resultFile: "notes.txt",
+ resultContent: "hello cursor",
+ resultAbsent: []string{"fileText", "linesCreated", "fileSize"},
+ },
+ {
+ name: "cursor stream-json read",
+ provider: "cursor",
+ writeFixture: writeStructuredCursorReadFixture,
+ toolCallID: "call-cursor-read",
+ toolName: "Read",
+ inputKind: "file",
+ inputFilePath: "src/app.ts",
+ resultKind: "read",
+ resultFile: "src/app.ts",
+ resultContent: "export const app = true;",
+ resultAbsent: []string{"readToolCall", "toolCallId", "totalLines", "totalChars"},
+ },
+ {
+ name: "cursor stream-json bash",
+ provider: "cursor",
+ writeFixture: writeStructuredCursorBashFixture,
+ toolCallID: "call-cursor-bash",
+ toolName: "Bash",
+ inputKind: "command",
+ inputCommand: "npm test",
+ resultKind: "bash",
+ resultStdout: "ok\n",
+ resultExit: intPtr(0),
+ resultAbsent: []string{"exitCode"},
+ },
+ {
+ name: "grok acp edit result patch",
+ provider: "grok",
+ writeFixture: writeStructuredGrokACPEditPatchFixture,
+ toolCallID: "call-grok-edit",
+ toolName: "search_replace",
+ inputKind: "patch",
+ inputFilePath: "notes.txt",
+ resultKind: "edit",
+ resultFile: "notes.txt",
+ resultPatch: []string{"*** Update File: notes.txt", "-old", "+new"},
+ resultOldString: "old",
+ resultNewString: "new",
+ },
+ {
+ name: "auggie acp edit result patch",
+ provider: "auggie",
+ writeFixture: writeStructuredAuggieACPEditPatchFixture,
+ toolCallID: "call-auggie-edit",
+ toolName: "str-replace-editor",
+ inputKind: "patch",
+ inputFilePath: "notes.txt",
+ resultKind: "edit",
+ resultFile: "notes.txt",
+ resultPatch: []string{"*** Update File: notes.txt", "-old", "+new"},
+ resultOldString: "old",
+ resultNewString: "new",
+ },
+ {
+ name: "antigravity write",
+ provider: "antigravity",
+ writeFixture: writeStructuredAntigravityWriteFixture,
+ toolCallID: "call-antigravity-write",
+ toolName: "Write",
+ inputKind: "write",
+ inputFilePath: "notes.txt",
+ inputText: "hello structured world",
+ resultKind: "write",
+ resultFile: "notes.txt",
+ resultContent: "wrote notes.txt",
+ },
+ {
+ name: "antigravity write result patch",
+ provider: "antigravity",
+ writeFixture: writeStructuredAntigravityEditPatchFixture,
+ toolCallID: "call-antigravity-edit",
+ toolName: "Edit",
+ inputKind: "patch",
+ inputFilePath: "notes.txt",
+ resultKind: "edit",
+ resultFile: "notes.txt",
+ resultContent: "Edited notes.txt",
+ resultPatch: []string{"--- notes.txt", "-old", "+new"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: tt.provider, WorkDir: workDir, Provider: tt.provider, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ tt.writeFixture(t, searchBase, info.WorkDir, info.SessionKey)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Format != "structured" {
+ t.Fatalf("Format = %q, want structured", resp.Format)
+ }
+ if resp.SchemaVersion != sessionStructuredSchemaVersion {
+ t.Fatalf("SchemaVersion = %q, want %q", resp.SchemaVersion, sessionStructuredSchemaVersion)
+ }
+ if resp.History == nil || resp.History.TranscriptStreamID == "" {
+ t.Fatalf("structured response missing history envelope: %+v", resp.History)
+ }
+
+ toolUse, toolResult := findStructuredToolPair(structuredTranscriptMessages(resp), tt.toolCallID)
+ if toolUse == nil {
+ t.Fatalf("missing tool_use %q in structured messages: %+v", tt.toolCallID, structuredTranscriptMessages(resp))
+ }
+ if toolResult == nil {
+ t.Fatalf("missing tool_result %q in structured messages: %+v", tt.toolCallID, structuredTranscriptMessages(resp))
+ }
+ if toolUse.Name != tt.toolName {
+ t.Fatalf("tool name = %q, want %q", toolUse.Name, tt.toolName)
+ }
+ if toolUse.Input == nil {
+ t.Fatalf("tool input is nil")
+ }
+ assertStructuredInput(t, toolUse.Input, tt.inputKind, tt.inputFilePath, tt.inputURL, tt.inputPrompt, tt.inputQuestion, tt.inputOptions, tt.inputCommand, tt.inputQuery, tt.inputPattern, tt.inputText, tt.inputPlan, tt.inputStepCount, tt.inputTodoCount)
+ assertStructuredInputArguments(t, toolUse.Input.Arguments, tt.inputArguments)
+ assertStructuredResult(t, toolResult.Structured, tt.resultKind, tt.resultFile, tt.resultContent, tt.resultStdout, tt.resultExit, tt.resultFiles, tt.resultItemURLs, tt.resultMode, tt.resultQuery, tt.resultCount, tt.resultURL, tt.resultStatus, tt.resultStatusText, tt.resultBytes, tt.resultDuration, tt.resultAppliedLimit, tt.resultTruncated, tt.resultQuestion, tt.resultQuestions, tt.resultAnswer, tt.resultAnswers, tt.resultPlan, tt.resultStepCount, tt.resultOldTodos, tt.resultNewTodos, tt.resultPatch, tt.resultOldString, tt.resultNewString, tt.resultOriginalFile, tt.resultReplaceAll, tt.resultUserModified, tt.resultAbsent)
+
+ assertNoStructuredWireLeak(t, body)
+ })
+ }
+}
+
+func TestHandleSessionTranscriptStructuredGracefullyDowngradesAllBuiltinProviders(t *testing.T) {
+ isolateProviderDiscovery(t)
+ for _, provider := range config.BuiltinProviderOrder() {
+ t.Run(provider, func(t *testing.T) {
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: provider, WorkDir: t.TempDir(), Provider: provider, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ fs.sp.SetPeekOutput(info.SessionName, provider+" pane output")
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0&include_thinking=true", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ var resp sessionTranscriptGetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Format != "structured" {
+ t.Fatalf("Format = %q, want structured; body: %s", resp.Format, w.Body.String())
+ }
+ if resp.SchemaVersion != sessionStructuredSchemaVersion {
+ t.Fatalf("SchemaVersion = %q, want %q", resp.SchemaVersion, sessionStructuredSchemaVersion)
+ }
+ if resp.History == nil {
+ t.Fatal("History is nil, want degraded structured history")
+ }
+ if resp.History.Continuity.Status != "degraded" {
+ t.Fatalf("History continuity = %q, want degraded", resp.History.Continuity.Status)
+ }
+ if len(resp.History.Diagnostics) == 0 || resp.History.Diagnostics[0].Code != structuredTranscriptUnavailableCode {
+ t.Fatalf("Diagnostics = %+v, want transcript_unavailable", resp.History.Diagnostics)
+ }
+ resume, ok := decodeStructuredResumeToken(resp.History.Cursor.ResumeToken)
+ if !ok || !resume.IncludeThinking {
+ t.Fatalf("fallback resume token = %+v, valid=%t; want include_thinking=true", resume, ok)
+ }
+ if len(structuredTranscriptMessages(resp)) != 1 {
+ t.Fatalf("StructuredMessages len = %d, want 1: %+v", len(structuredTranscriptMessages(resp)), structuredTranscriptMessages(resp))
+ }
+ msg := structuredTranscriptMessages(resp)[0]
+ if msg.Provider != provider {
+ t.Fatalf("message provider = %q, want %q", msg.Provider, provider)
+ }
+ if msg.Role != "assistant" {
+ t.Fatalf("message role = %q, want assistant for pane-output fallback", msg.Role)
+ }
+ if len(msg.Blocks) != 1 || msg.Blocks[0].Type != "text" || !strings.Contains(msg.Blocks[0].Text, provider+" pane output") {
+ t.Fatalf("message blocks = %+v, want provider-neutral text fallback", msg.Blocks)
+ }
+ })
+ }
+}
+
+func TestHandleSessionTranscriptStructuredSkipsCodexUnknownEvents(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "codex", WorkDir: workDir, Provider: "codex", Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeStructuredCodexFixture(t, searchBase, info.WorkDir, "2026-06-01T00-05-00", info.SessionKey, []string{
+ `{"timestamp":"2026-06-01T00:05:01Z","type":"event_msg","payload":{"type":"shutdown_complete","data":"provider-native event"}}`,
+ `{"timestamp":"2026-06-01T00:05:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"text":"assistant survived unknown event"}]}}`,
+ })
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(structuredTranscriptMessages(resp)) != 1 {
+ t.Fatalf("StructuredMessages = %+v, want only assistant message", structuredTranscriptMessages(resp))
+ }
+ got := structuredTranscriptMessages(resp)[0]
+ if got.Role != "assistant" || len(got.Blocks) != 1 || got.Blocks[0].Text != "assistant survived unknown event" {
+ t.Fatalf("structured messages = %+v, want assistant text only", structuredTranscriptMessages(resp))
+ }
+ assertNoStructuredWireLeak(t, body, "shutdown_complete", "provider-native event")
+}
+
+func TestHandleSessionTranscriptStructuredNormalizesCodexSystemErrors(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "codex", WorkDir: workDir, Provider: "codex", Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeStructuredCodexFixture(t, searchBase, info.WorkDir, "2026-06-01T00-06-00", info.SessionKey, []string{
+ `{"timestamp":"2026-06-01T00:06:01Z","type":"event_msg","payload":{"type":"error","message":"You've hit your usage limit.","codex_error_info":"usage_limit_exceeded"}}`,
+ `{"timestamp":"2026-06-01T00:06:02Z","type":"event_msg","payload":{"type":"stream_error","message":"stream interrupted"}}`,
+ `{"timestamp":"2026-06-01T00:06:03Z","type":"event_msg","payload":{"type":"turn_aborted","message":"turn was aborted"}}`,
+ })
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ wants := []SessionStructuredSystemEvent{
+ {Kind: "error", Category: "usage_limit", Code: "usage_limit_exceeded", Message: "You've hit your usage limit."},
+ {Kind: "error", Category: "stream_error", Message: "stream interrupted"},
+ {Kind: "turn_aborted", Category: "turn_aborted", Message: "turn was aborted"},
+ }
+ if len(structuredTranscriptMessages(resp)) != len(wants) {
+ t.Fatalf("StructuredMessages = %+v, want %d system events", structuredTranscriptMessages(resp), len(wants))
+ }
+ for i, want := range wants {
+ msg := structuredTranscriptMessages(resp)[i]
+ if msg.Role != "system" {
+ t.Fatalf("[%d] role = %q, want system; msg = %+v", i, msg.Role, msg)
+ }
+ if msg.SystemEvent == nil {
+ t.Fatalf("[%d] system_event is nil; msg = %+v", i, msg)
+ }
+ if *msg.SystemEvent != want {
+ t.Fatalf("[%d] system_event = %+v, want %+v", i, *msg.SystemEvent, want)
+ }
+ if len(msg.Blocks) != 1 || msg.Blocks[0].Type != "text" || msg.Blocks[0].Text != want.Message {
+ t.Fatalf("[%d] blocks = %+v, want clean system message text %q", i, msg.Blocks, want.Message)
+ }
+ }
+ assertNoStructuredWireLeak(t, body)
+}
+
+func TestHandleSessionTranscriptStructuredNormalizesGeminiSystemError(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "gemini", WorkDir: workDir, Provider: "gemini", Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeStructuredGeminiErrorFixture(t, searchBase, info.WorkDir, info.SessionKey)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(structuredTranscriptMessages(resp)) != 1 {
+ t.Fatalf("StructuredMessages = %+v, want one Gemini system event", structuredTranscriptMessages(resp))
+ }
+ msg := structuredTranscriptMessages(resp)[0]
+ if msg.Role != "system" {
+ t.Fatalf("role = %q, want system; msg = %+v", msg.Role, msg)
+ }
+ want := SessionStructuredSystemEvent{Kind: "error", Category: "provider_error", Message: "Gemini stream interrupted"}
+ if msg.SystemEvent == nil || *msg.SystemEvent != want {
+ t.Fatalf("system_event = %+v, want %+v", msg.SystemEvent, want)
+ }
+ if len(msg.Blocks) != 1 || msg.Blocks[0].Type != "text" || msg.Blocks[0].Text != want.Message {
+ t.Fatalf("blocks = %+v, want clean Gemini error text %q", msg.Blocks, want.Message)
+ }
+ assertNoStructuredWireLeak(t, body)
+}
+
+func TestHandleSessionTranscriptStructuredNormalizesClaudeTaskOutput(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeStructuredClaudeTaskOutputFixture(t, searchBase, info.WorkDir, info.SessionKey)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ toolUse, toolResult := findStructuredToolPair(structuredTranscriptMessages(resp), "call-claude-task")
+ if toolUse == nil || toolResult == nil {
+ t.Fatalf("missing task tool pair in structured messages: %+v", structuredTranscriptMessages(resp))
+ }
+ if toolUse.Input == nil || toolUse.Input.Kind != "task" || toolUse.Input.TaskID != "task-123" {
+ t.Fatalf("task input = %+v, want neutral task input with task-123", toolUse.Input)
+ }
+ if toolResult.Structured == nil {
+ t.Fatal("task structured result is nil")
+ }
+ got := toolResult.Structured
+ if got.Kind != "task" || got.TaskID != "task-123" || got.TaskType != "subagent" || got.TaskStatus != "completed" {
+ t.Fatalf("task structured result = %+v, want task metadata", got)
+ }
+ if got.Description != "Run delegated check" || got.Output != "delegated check passed" {
+ t.Fatalf("task result text = description %q output %q, want typed task output; result = %+v", got.Description, got.Output, got)
+ }
+ if got.ExitCode == nil || *got.ExitCode != 0 {
+ t.Fatalf("task exit_code = %v, want 0; result = %+v", got.ExitCode, got)
+ }
+ if got.TotalDurationMs != 1234 || got.TotalTokens != 321 || got.TotalToolUseCount != 4 {
+ t.Fatalf("task aggregate metrics = duration %d tokens %d tools %d, want 1234/321/4; result = %+v", got.TotalDurationMs, got.TotalTokens, got.TotalToolUseCount, got)
+ }
+
+ assertNoStructuredWireLeak(t, body)
+}
+
+func TestHandleSessionTranscriptStructuredNormalizesClaudeBashOutput(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeStructuredClaudeBashOutputFixture(t, searchBase, info.WorkDir, info.SessionKey)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ toolUse, toolResult := findStructuredToolPair(structuredTranscriptMessages(resp), "call-claude-bash-output")
+ if toolUse == nil || toolResult == nil {
+ t.Fatalf("missing bash output tool pair in structured messages: %+v", structuredTranscriptMessages(resp))
+ }
+ if toolUse.Input == nil || toolUse.Input.Kind != "task" || toolUse.Input.TaskID != "shell-123" {
+ t.Fatalf("bash output input = %+v, want neutral task input with shell-123", toolUse.Input)
+ }
+ if toolResult.Structured == nil {
+ t.Fatal("bash output structured result is nil")
+ }
+ got := toolResult.Structured
+ if got.Kind != "bash" || got.TaskID != "shell-123" || got.Command != "npm test" || got.TaskStatus != "completed" {
+ t.Fatalf("bash output structured result = %+v, want bash shell metadata", got)
+ }
+ if got.Stdout != "ok\n" || got.Stderr != "warn\n" || got.StdoutLines != 1 || got.StderrLines != 1 || got.Timestamp != "2026-06-01T00:00:02Z" {
+ t.Fatalf("bash output streams = %+v, want stdout/stderr line metadata and timestamp", got)
+ }
+ if got.ExitCode == nil || *got.ExitCode != 0 {
+ t.Fatalf("bash output exit_code = %v, want 0; result = %+v", got.ExitCode, got)
+ }
+
+ assertNoStructuredWireLeak(t, body)
+}
+
+func TestHandleSessionTranscriptStructuredLinksClaudeWriteStdin(t *testing.T) {
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeStructuredClaudeWriteStdinFixture(t, searchBase, info.WorkDir, info.SessionKey)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ body := w.Body.Bytes()
+ var resp sessionTranscriptGetResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ bashUse, bashResult := findStructuredToolPair(structuredTranscriptMessages(resp), "call-claude-bash")
+ if bashUse == nil || bashResult == nil {
+ t.Fatalf("missing bash tool pair in structured messages: %+v", structuredTranscriptMessages(resp))
+ }
+ if bashResult.Structured == nil || bashResult.Structured.Kind != "bash" || bashResult.Structured.TaskID != "42" || bashResult.Structured.Command != "claude --resume" {
+ t.Fatalf("bash structured result = %+v, want command and neutral shell id", bashResult.Structured)
+ }
+ stdinUse, stdinResult := findStructuredToolPair(structuredTranscriptMessages(resp), "call-claude-stdin")
+ if stdinUse == nil || stdinResult == nil {
+ t.Fatalf("missing stdin tool pair in structured messages: %+v", structuredTranscriptMessages(resp))
+ }
+ if stdinUse.Input == nil || stdinUse.Input.Kind != "stdin" || stdinUse.Input.TaskID != "42" || stdinUse.Input.Text != "hello\n" {
+ t.Fatalf("stdin input = %+v, want typed stdin task/text", stdinUse.Input)
+ }
+ if stdinUse.Input.LinkedCommand != "claude --resume" {
+ t.Fatalf("stdin linked_command = %q, want claude --resume; input = %+v", stdinUse.Input.LinkedCommand, stdinUse.Input)
+ }
+ if stdinResult.Structured == nil || stdinResult.Structured.Kind != "stdin" || stdinResult.Structured.TaskID != "42" || stdinResult.Structured.Content != "sent" {
+ t.Fatalf("stdin structured result = %+v, want typed stdin result", stdinResult.Structured)
+ }
+
+ assertNoStructuredWireLeak(t, body)
+}
+
+func TestHandleSessionStreamStructuredGracefullyDowngradesWithoutTranscript(t *testing.T) {
+ isolateProviderDiscovery(t)
+ for _, provider := range config.BuiltinProviderOrder() {
+ t.Run(provider, func(t *testing.T) {
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: provider, WorkDir: t.TempDir(), Provider: provider, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ fs.sp.SetPeekOutput(info.SessionName, provider+" pane output")
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ req := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/stream?format=structured", nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ body := waitForRecorderSubstring(t, rec, `"format":"structured"`, 500*time.Millisecond)
+ if !strings.Contains(body, `"format":"structured"`) {
+ t.Fatalf("stream body missing structured fallback event: %s", body)
+ }
+ if !strings.Contains(body, structuredTranscriptUnavailableCode) {
+ t.Fatalf("stream body missing degraded diagnostic: %s", body)
+ }
+ if !strings.Contains(body, provider+" pane output") {
+ t.Fatalf("stream body missing text fallback: %s", body)
+ }
+ if !strings.Contains(body, `"role":"assistant"`) {
+ t.Fatalf("stream body fallback role is not assistant: %s", body)
+ }
+ cancel()
+ <-done
+ })
+ }
+}
+
+func TestHandleSessionStreamStructuredUsesRelocatedSessionStoreForPaneFallback(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ relocated := beads.NewMemStore()
+ fs.sessionsBeadStore = relocated
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{t.TempDir()}
+
+ mgr := session.NewManagerWithOptions(relocated, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Relocated",
+ Command: "cursor",
+ WorkDir: t.TempDir(),
+ Provider: "cursor",
+ Resume: session.ProviderResume{},
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ fs.sp.SetPeekOutput(info.SessionName, "relocated pane output")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/stream?format=structured", nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ body := waitForRecorderSubstring(t, rec, "relocated pane output", 10*time.Second)
+ if !strings.Contains(body, structuredTranscriptUnavailableCode) {
+ t.Fatalf("stream body missing degraded diagnostic: %s", body)
+ }
+ cancel()
+ <-done
+}
+
+func TestSessionStreamStructuredPromotesFallbackToHistoryWithoutReconnect(t *testing.T) {
+ for _, surface := range []string{"city-huma", "legacy"} {
+ t.Run(surface, func(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ structuredPeekPoll := make(chan time.Time)
+ srv.structuredPeekPoll = structuredPeekPoll
+ humaHandler := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Promote",
+ Command: "claude",
+ WorkDir: workDir,
+ Provider: "claude",
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ fs.sp.SetPeekOutput(info.SessionName, "pane fallback before history")
+
+ handler := humaHandler
+ path := cityURL(fs, "/session/") + info.ID + "/stream?format=structured"
+ if surface == "legacy" {
+ handler = srv.legacySessionHandler()
+ path = "/v0/session/" + info.ID + "/stream?format=structured"
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ handler.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ fallbackBody := waitForRecorderSubstring(t, rec, "pane fallback before history", 10*time.Second)
+ if !strings.Contains(fallbackBody, structuredTranscriptUnavailableCode) {
+ t.Fatalf("fallback body missing degraded diagnostic: %s", fallbackBody)
+ }
+
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"m1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":"authoritative history"},"timestamp":"2025-01-01T00:00:00Z"}`,
+ )
+ select {
+ case structuredPeekPoll <- time.Now():
+ case <-time.After(testutil.GoroutineRaceTimeout):
+ t.Fatal("structured peek stream did not consume the injected poll tick")
+ }
+
+ body := waitForRecorderSubstring(t, rec, `"reset_reason":"stream_changed"`, 10*time.Second)
+ cancel()
+ <-done
+
+ var promoted *SessionStreamStructuredMessageEvent
+ for _, frame := range parseSSETestFrames(body) {
+ if frame.Event != "structured" {
+ continue
+ }
+ var update SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(frame.Data), &update); err != nil {
+ t.Fatalf("decode structured frame: %v; data=%s", err, frame.Data)
+ }
+ if update.Operation == sessionStructuredOperationReset {
+ promoted = &update
+ }
+ }
+ if promoted == nil || promoted.ResetReason != sessionStructuredResetStreamChanged {
+ t.Fatalf("structured frames did not promote with reset/stream_changed: %s", body)
+ }
+ if got := structuredMessageIDs(promoted.StructuredMessages); !equalStrings(got, []string{"m1"}) {
+ t.Fatalf("promoted message IDs = %v, want [m1]", got)
+ }
+ if len(promoted.StructuredMessages[0].Blocks) != 1 || promoted.StructuredMessages[0].Blocks[0].Text != "authoritative history" {
+ t.Fatalf("promoted message blocks = %+v, want authoritative history", promoted.StructuredMessages[0].Blocks)
+ }
+ })
+ }
+}
+
+func TestHandleSessionStreamStructuredClosedWithoutHistoryMatchesTranscriptSnapshot(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{t.TempDir()}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Closed",
+ Command: "cursor",
+ WorkDir: t.TempDir(),
+ Provider: "cursor",
+ Resume: session.ProviderResume{},
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{
+ "session_origin": "manual",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if err := mgr.Close(info.ID); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ restRec := httptest.NewRecorder()
+ restReq := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/transcript?format=structured", nil)
+ h.ServeHTTP(restRec, restReq)
+ if restRec.Code != http.StatusOK {
+ t.Fatalf("REST status = %d, want %d; body: %s", restRec.Code, http.StatusOK, restRec.Body.String())
+ }
+ var rest sessionTranscriptGetResponse
+ if err := json.NewDecoder(restRec.Body).Decode(&rest); err != nil {
+ t.Fatalf("decode REST snapshot: %v", err)
+ }
+ if rest.Operation != sessionStructuredOperationSnapshot {
+ t.Fatalf("REST operation = %q, want %q", rest.Operation, sessionStructuredOperationSnapshot)
+ }
+ if rest.History == nil || rest.History.Cursor.ResumeToken == "" {
+ t.Fatalf("REST history cursor = %+v, want resume token", rest.History)
+ }
+
+ streamRec := httptest.NewRecorder()
+ streamReq := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/stream?format=structured", nil)
+ h.ServeHTTP(streamRec, streamReq)
+ if streamRec.Code != http.StatusOK {
+ t.Fatalf("SSE status = %d, want %d; body: %s", streamRec.Code, http.StatusOK, streamRec.Body.String())
+ }
+ frame := firstSSETestFrame(t, streamRec.Body.String(), "structured")
+ var streamed SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(frame.Data), &streamed); err != nil {
+ t.Fatalf("decode SSE snapshot: %v; data=%s", err, frame.Data)
+ }
+
+ if !reflect.DeepEqual(streamed.History, rest.History) {
+ t.Fatalf("SSE history = %+v, want REST history %+v", streamed.History, rest.History)
+ }
+ if !reflect.DeepEqual(streamed.StructuredMessages, structuredTranscriptMessages(rest)) {
+ t.Fatalf("SSE messages = %+v, want REST messages %+v", streamed.StructuredMessages, structuredTranscriptMessages(rest))
+ }
+ if streamed.History == nil || streamed.History.Continuity.Status != "degraded" {
+ t.Fatalf("SSE history = %+v, want degraded structured fallback", streamed.History)
+ }
+}
+
+func TestHandleSessionStreamStructuredAfterCursorSuppressesRESTSnapshotReplay(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Resume",
+ Command: "claude",
+ WorkDir: workDir,
+ Provider: "claude",
+ Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ },
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{"session_origin": "manual"},
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"m1","parentUuid":"","type":"user","message":"{\"role\":\"user\",\"content\":\"hello\"}","timestamp":"2025-01-01T00:00:00Z"}`,
+ )
+
+ restRec := httptest.NewRecorder()
+ restReq := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/transcript?format=structured", nil)
+ h.ServeHTTP(restRec, restReq)
+ if restRec.Code != http.StatusOK {
+ t.Fatalf("REST status = %d, want %d; body: %s", restRec.Code, http.StatusOK, restRec.Body.String())
+ }
+ var snapshot sessionTranscriptGetResponse
+ if err := json.NewDecoder(restRec.Body).Decode(&snapshot); err != nil {
+ t.Fatalf("decode REST snapshot: %v", err)
+ }
+ if snapshot.History == nil || snapshot.History.Cursor.ResumeToken == "" {
+ t.Fatalf("REST history cursor = %+v, want resume token", snapshot.History)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ path := cityURL(fs, "/session/") + info.ID + "/stream?format=structured&after_cursor=" + url.QueryEscape(snapshot.History.Cursor.ResumeToken)
+ req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ body := waitForRecorderSubstring(t, rec, "event: activity", 10*time.Second)
+ cancel()
+ <-done
+ if strings.Contains(body, "event: structured") {
+ t.Fatalf("stream replayed the exact REST snapshot: %s", body)
+ }
+}
+
+func TestHandleSessionStreamStructuredResumesFromPaginatedRESTSnapshot(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Paginated resume",
+ Command: "claude",
+ WorkDir: workDir,
+ Provider: "claude",
+ Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ },
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{"session_origin": "manual"},
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"m1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":"one"},"timestamp":"2025-01-01T00:00:00Z"}`,
+ `{"uuid":"m2","parentUuid":"m1","type":"assistant","message":{"role":"assistant","content":"two"},"timestamp":"2025-01-01T00:00:01Z"}`,
+ `{"uuid":"m3","parentUuid":"m2","type":"assistant","message":{"role":"assistant","content":"three"},"timestamp":"2025-01-01T00:00:02Z"}`,
+ `{"uuid":"m4","parentUuid":"m3","type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":"four"},"timestamp":"2025-01-01T00:00:03Z"}`,
+ )
+
+ restRec := httptest.NewRecorder()
+ restReq := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&after=m2", nil)
+ h.ServeHTTP(restRec, restReq)
+ if restRec.Code != http.StatusOK {
+ t.Fatalf("REST status = %d, want %d; body: %s", restRec.Code, http.StatusOK, restRec.Body.String())
+ }
+ var snapshot sessionTranscriptGetResponse
+ if err := json.NewDecoder(restRec.Body).Decode(&snapshot); err != nil {
+ t.Fatalf("decode REST snapshot: %v", err)
+ }
+ if got := structuredMessageIDs(structuredTranscriptMessages(snapshot)); !equalStrings(got, []string{"m3", "m4"}) {
+ t.Fatalf("paginated REST message IDs = %v, want [m3 m4]", got)
+ }
+ if snapshot.History == nil || snapshot.History.Cursor.ResumeToken == "" {
+ t.Fatalf("REST history cursor = %+v, want resume token", snapshot.History)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*testutil.GoroutineRaceTimeout)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ path := cityURL(fs, "/session/") + info.ID + "/stream?format=structured&after_cursor=" + url.QueryEscape(snapshot.History.Cursor.ResumeToken)
+ req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ initialBody := waitForRecorderSubstring(t, rec, "event: activity", testutil.GoroutineRaceTimeout)
+ if strings.Contains(initialBody, "event: structured") {
+ cancel()
+ <-done
+ t.Fatalf("stream reset or replayed the paginated REST snapshot: %s", initialBody)
+ }
+
+ logPath := filepath.Join(searchBase, sessionlog.ProjectSlug(workDir), info.SessionKey+".jsonl")
+ file, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ cancel()
+ <-done
+ t.Fatalf("open transcript for append: %v", err)
+ }
+ _, writeErr := fmt.Fprintln(file, `{"uuid":"m5","parentUuid":"m4","type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":"five"},"timestamp":"2025-01-01T00:00:04Z"}`)
+ closeErr := file.Close()
+ if writeErr != nil {
+ cancel()
+ <-done
+ t.Fatalf("append transcript: %v", writeErr)
+ }
+ if closeErr != nil {
+ cancel()
+ <-done
+ t.Fatalf("close transcript: %v", closeErr)
+ }
+
+ body := waitForRecorderSubstring(t, rec, "event: structured", testutil.GoroutineRaceTimeout)
+ cancel()
+ <-done
+ frame := firstSSETestFrame(t, body, "structured")
+ var update SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(frame.Data), &update); err != nil {
+ t.Fatalf("decode structured upsert: %v; data=%s", err, frame.Data)
+ }
+ if update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("operation = %q, want %q", update.Operation, sessionStructuredOperationUpsert)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, []string{"m4", "m5"}) {
+ t.Fatalf("upsert IDs = %v, want inclusive tail [m4 m5]", got)
+ }
+}
+
+func TestHandleSessionStreamStructuredResumesFromEmptyPaginatedRESTSnapshot(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Empty paginated resume",
+ Command: "claude",
+ WorkDir: workDir,
+ Provider: "claude",
+ Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ },
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{"session_origin": "manual"},
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"m1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":"one"},"timestamp":"2025-01-01T00:00:00Z"}`,
+ `{"uuid":"m2","parentUuid":"m1","type":"assistant","message":{"role":"assistant","content":"two"},"timestamp":"2025-01-01T00:00:01Z"}`,
+ `{"uuid":"m3","parentUuid":"m2","type":"assistant","message":{"role":"assistant","content":"three"},"timestamp":"2025-01-01T00:00:02Z"}`,
+ `{"uuid":"m4","parentUuid":"m3","type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":"four"},"timestamp":"2025-01-01T00:00:03Z"}`,
+ )
+
+ restRec := httptest.NewRecorder()
+ restReq := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&after=m4", nil)
+ h.ServeHTTP(restRec, restReq)
+ if restRec.Code != http.StatusOK {
+ t.Fatalf("REST status = %d, want %d; body: %s", restRec.Code, http.StatusOK, restRec.Body.String())
+ }
+ var snapshot sessionTranscriptGetResponse
+ if err := json.NewDecoder(restRec.Body).Decode(&snapshot); err != nil {
+ t.Fatalf("decode REST snapshot: %v", err)
+ }
+ if got := structuredMessageIDs(structuredTranscriptMessages(snapshot)); len(got) != 0 {
+ t.Fatalf("empty paginated REST message IDs = %v, want none", got)
+ }
+ if snapshot.History == nil || snapshot.History.Cursor.ResumeToken == "" {
+ t.Fatalf("REST history cursor = %+v, want resume token", snapshot.History)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*testutil.GoroutineRaceTimeout)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ path := cityURL(fs, "/session/") + info.ID + "/stream?format=structured&after_cursor=" + url.QueryEscape(snapshot.History.Cursor.ResumeToken)
+ req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ initialBody := waitForRecorderSubstring(t, rec, "event: structured", testutil.GoroutineRaceTimeout)
+ initialFrame := firstSSETestFrame(t, initialBody, "structured")
+ var initialUpdate SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(initialFrame.Data), &initialUpdate); err != nil {
+ cancel()
+ <-done
+ t.Fatalf("decode initial structured upsert: %v; data=%s", err, initialFrame.Data)
+ }
+ if initialUpdate.Operation != sessionStructuredOperationUpsert {
+ cancel()
+ <-done
+ t.Fatalf("initial operation = %q, want %q", initialUpdate.Operation, sessionStructuredOperationUpsert)
+ }
+ if got := structuredMessageIDs(initialUpdate.StructuredMessages); !equalStrings(got, []string{"m4"}) {
+ cancel()
+ <-done
+ t.Fatalf("initial upsert IDs = %v, want bounded anchor [m4]", got)
+ }
+
+ logPath := filepath.Join(searchBase, sessionlog.ProjectSlug(workDir), info.SessionKey+".jsonl")
+ file, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ cancel()
+ <-done
+ t.Fatalf("open transcript for append: %v", err)
+ }
+ _, writeErr := fmt.Fprintln(file, `{"uuid":"m5","parentUuid":"m4","type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":"five"},"timestamp":"2025-01-01T00:00:04Z"}`)
+ closeErr := file.Close()
+ if writeErr != nil {
+ cancel()
+ <-done
+ t.Fatalf("append transcript: %v", writeErr)
+ }
+ if closeErr != nil {
+ cancel()
+ <-done
+ t.Fatalf("close transcript: %v", closeErr)
+ }
+
+ body := waitForRecorderSubstring(t, rec, `"id":"m5"`, testutil.GoroutineRaceTimeout)
+ cancel()
+ <-done
+ var frame sseTestFrame
+ for _, candidate := range parseSSETestFrames(body) {
+ if candidate.Event == "structured" {
+ frame = candidate
+ }
+ }
+ if frame.Event == "" {
+ t.Fatalf("appended structured event not found in body: %s", body)
+ }
+ var update SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(frame.Data), &update); err != nil {
+ t.Fatalf("decode structured upsert: %v; data=%s", err, frame.Data)
+ }
+ if update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("operation = %q, want %q", update.Operation, sessionStructuredOperationUpsert)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, []string{"m4", "m5"}) {
+ t.Fatalf("upsert IDs = %v, want inclusive tail [m4 m5]", got)
+ }
+}
+
+func TestHandleSessionStreamStructuredInvalidCursorEmitsResetSnapshot(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Reset",
+ Command: "claude",
+ WorkDir: workDir,
+ Provider: "claude",
+ Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ },
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{"session_origin": "manual"},
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"m1","parentUuid":"","type":"user","message":"{\"role\":\"user\",\"content\":\"hello\"}","timestamp":"2025-01-01T00:00:00Z"}`,
+ )
+ if err := mgr.Close(info.ID); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/stream?format=structured&after_cursor=not-a-token", nil)
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("stream status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ frame := firstSSETestFrame(t, rec.Body.String(), "structured")
+ var update SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(frame.Data), &update); err != nil {
+ t.Fatalf("decode structured reset: %v; data=%s", err, frame.Data)
+ }
+ if update.Operation != sessionStructuredOperationReset || update.ResetReason != sessionStructuredResetResumeInvalid {
+ t.Fatalf("reset operation = %q reason = %q, want reset/%s", update.Operation, update.ResetReason, sessionStructuredResetResumeInvalid)
+ }
+ if len(update.StructuredMessages) != 1 || update.StructuredMessages[0].ID != "m1" {
+ t.Fatalf("reset messages = %+v, want full m1 snapshot", update.StructuredMessages)
+ }
+ if frame.ID == "" || update.History == nil || frame.ID != update.History.Cursor.ResumeToken {
+ t.Fatalf("SSE id = %q history = %+v, want matching resume token", frame.ID, update.History)
+ }
+}
+
+func TestHandleSessionStreamStructuredResumeEmitsInclusiveTailUpsert(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Append",
+ Command: "claude",
+ WorkDir: workDir,
+ Provider: "claude",
+ Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ },
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{"session_origin": "manual"},
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl",
+ `{"uuid":"m1","parentUuid":"","type":"user","message":"{\"role\":\"user\",\"content\":\"first\"}","timestamp":"2025-01-01T00:00:00Z"}`,
+ )
+
+ restRec := httptest.NewRecorder()
+ restReq := httptest.NewRequest(http.MethodGet, cityURL(fs, "/session/")+info.ID+"/transcript?format=structured", nil)
+ h.ServeHTTP(restRec, restReq)
+ if restRec.Code != http.StatusOK {
+ t.Fatalf("REST status = %d, want %d; body: %s", restRec.Code, http.StatusOK, restRec.Body.String())
+ }
+ var snapshot sessionTranscriptGetResponse
+ if err := json.NewDecoder(restRec.Body).Decode(&snapshot); err != nil {
+ t.Fatalf("decode REST snapshot: %v", err)
+ }
+ if snapshot.History == nil || snapshot.History.Cursor.ResumeToken == "" {
+ t.Fatalf("REST history cursor = %+v, want resume token", snapshot.History)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ path := cityURL(fs, "/session/") + info.ID + "/stream?format=structured&after_cursor=" + url.QueryEscape(snapshot.History.Cursor.ResumeToken)
+ req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ h.ServeHTTP(rec, req)
+ close(done)
+ }()
+ initialBody := waitForRecorderSubstring(t, rec, "event: activity", 10*time.Second)
+ if !strings.Contains(initialBody, "event: activity") {
+ t.Fatalf("stream readiness activity did not arrive: %s", initialBody)
+ }
+
+ logPath := filepath.Join(searchBase, sessionlog.ProjectSlug(workDir), info.SessionKey+".jsonl")
+ file, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ t.Fatalf("open transcript for append: %v", err)
+ }
+ _, writeErr := fmt.Fprintln(file, `{"uuid":"m2","parentUuid":"m1","type":"assistant","message":"{\"role\":\"assistant\",\"content\":\"second\"}","timestamp":"2025-01-01T00:00:01Z"}`)
+ closeErr := file.Close()
+ if writeErr != nil {
+ t.Fatalf("append transcript: %v", writeErr)
+ }
+ if closeErr != nil {
+ t.Fatalf("close transcript: %v", closeErr)
+ }
+
+ body := waitForRecorderSubstring(t, rec, "event: structured", 10*time.Second)
+ cancel()
+ <-done
+ frame := firstSSETestFrame(t, body, "structured")
+ var update SessionStreamStructuredMessageEvent
+ if err := json.Unmarshal([]byte(frame.Data), &update); err != nil {
+ t.Fatalf("decode structured upsert: %v; data=%s", err, frame.Data)
+ }
+ if update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("operation = %q, want %q", update.Operation, sessionStructuredOperationUpsert)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, []string{"m1", "m2"}) {
+ t.Fatalf("upsert IDs = %v, want inclusive tail [m1 m2]", got)
+ }
+}
+
+func TestLegacySessionTranscriptStructuredGracefullyDowngrades(t *testing.T) {
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "cursor", WorkDir: t.TempDir(), Provider: "cursor", Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ fs.sp.SetPeekOutput(info.SessionName, "cursor pane output")
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/v0/session/"+info.ID+"/transcript?format=structured&tail=0&include_thinking=true", nil)
+ srv.legacySessionHandler().ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
+ }
+
+ var resp sessionTranscriptGetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Format != "structured" {
+ t.Fatalf("Format = %q, want structured; body: %s", resp.Format, w.Body.String())
+ }
+ if resp.History == nil || resp.History.Continuity.Status != "degraded" {
+ t.Fatalf("History = %+v, want degraded structured fallback", resp.History)
+ }
+ resume, ok := decodeStructuredResumeToken(resp.History.Cursor.ResumeToken)
+ if !ok || !resume.IncludeThinking {
+ t.Fatalf("legacy fallback resume token = %+v, valid=%t; want include_thinking=true", resume, ok)
+ }
+ if len(structuredTranscriptMessages(resp)) != 1 || structuredTranscriptMessages(resp)[0].Role != "assistant" || !strings.Contains(structuredTranscriptMessages(resp)[0].Blocks[0].Text, "cursor pane output") {
+ t.Fatalf("StructuredMessages = %+v, want cursor pane output text fallback", structuredTranscriptMessages(resp))
+ }
+}
+
+func TestLegacySessionStreamStructuredGracefullyDowngrades(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ srv := New(fs)
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "cursor", WorkDir: t.TempDir(), Provider: "cursor", Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ fs.sp.SetPeekOutput(info.SessionName, "cursor pane output")
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ rec := newSyncResponseRecorder()
+ req := httptest.NewRequest("GET", "/v0/session/"+info.ID+"/stream?format=structured", nil).WithContext(ctx)
+ done := make(chan struct{})
+ go func() {
+ srv.legacySessionHandler().ServeHTTP(rec, req)
+ close(done)
+ }()
+
+ body := waitForRecorderSubstring(t, rec, `"format":"structured"`, 500*time.Millisecond)
+ if !strings.Contains(body, "event: structured") {
+ t.Fatalf("stream body missing structured event name: %s", body)
+ }
+ if !strings.Contains(body, structuredTranscriptUnavailableCode) {
+ t.Fatalf("stream body missing degraded diagnostic: %s", body)
+ }
+ if !strings.Contains(body, "cursor pane output") {
+ t.Fatalf("stream body missing text fallback: %s", body)
+ }
+ if !strings.Contains(body, `"role":"assistant"`) {
+ t.Fatalf("stream body fallback role is not assistant: %s", body)
+ }
+ cancel()
+ <-done
+}
+
+func findStructuredToolPair(messages []SessionStructuredMessage, toolCallID string) (*SessionStructuredBlock, *SessionStructuredBlock) {
+ var toolUse *SessionStructuredBlock
+ var toolResult *SessionStructuredBlock
+ for i := range messages {
+ for j := range messages[i].Blocks {
+ block := &messages[i].Blocks[j]
+ switch block.Type {
+ case "tool_use":
+ if block.ID == toolCallID || block.ToolCallID == toolCallID {
+ toolUse = block
+ }
+ case "tool_result":
+ if block.ToolCallID == toolCallID {
+ toolResult = block
+ }
+ }
+ }
+ }
+ return toolUse, toolResult
+}
+
+func assertStructuredInput(t *testing.T, input *SessionStructuredToolInput, kind, filePath, url, prompt, question string, options []string, command, query, pattern, text, plan string, stepCount int, todoCount int) {
+ t.Helper()
+ if input.Kind != kind {
+ t.Fatalf("input kind = %q, want %q; input = %+v", input.Kind, kind, input)
+ }
+ if filePath != "" && input.FilePath != filePath {
+ t.Fatalf("input file_path = %q, want %q; input = %+v", input.FilePath, filePath, input)
+ }
+ if url != "" && input.URL != url {
+ t.Fatalf("input url = %q, want %q; input = %+v", input.URL, url, input)
+ }
+ if prompt != "" && input.Prompt != prompt {
+ t.Fatalf("input prompt = %q, want %q; input = %+v", input.Prompt, prompt, input)
+ }
+ if question != "" && input.Question != question {
+ t.Fatalf("input question = %q, want %q; input = %+v", input.Question, question, input)
+ }
+ for _, want := range options {
+ if !stringSliceContains(input.Options, want) {
+ t.Fatalf("input options = %#v, missing %q; input = %+v", input.Options, want, input)
+ }
+ }
+ if command != "" && input.Command != command {
+ t.Fatalf("input command = %q, want %q; input = %+v", input.Command, command, input)
+ }
+ if query != "" && input.Query != query {
+ t.Fatalf("input query = %q, want %q; input = %+v", input.Query, query, input)
+ }
+ if pattern != "" && input.Pattern != pattern {
+ t.Fatalf("input pattern = %q, want %q; input = %+v", input.Pattern, pattern, input)
+ }
+ if text != "" && input.Text != text {
+ t.Fatalf("input text = %q, want %q; input = %+v", input.Text, text, input)
+ }
+ if plan != "" && input.Plan != plan {
+ t.Fatalf("input plan = %q, want %q; input = %+v", input.Plan, plan, input)
+ }
+ if stepCount != 0 && len(input.Steps) != stepCount {
+ t.Fatalf("input steps = %#v, want %d steps; input = %+v", input.Steps, stepCount, input)
+ }
+ if todoCount != 0 && len(input.Todos) != todoCount {
+ t.Fatalf("input todos = %#v, want %d todo items; input = %+v", input.Todos, todoCount, input)
+ }
+}
+
+func assertStructuredInputArguments(t *testing.T, args []SessionStructuredArgument, wants map[string]string) {
+ t.Helper()
+ for name, wantSubstring := range wants {
+ found := false
+ for _, arg := range args {
+ if arg.Name == name && strings.Contains(arg.Value, wantSubstring) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("input arguments = %+v, missing %s containing %q", args, name, wantSubstring)
+ }
+ }
+}
+
+func assertStructuredResult(t *testing.T, result *SessionStructuredToolResult, kind, filePath, content, stdout string, exitCode *int, filenames []string, resultItemURLs []string, mode, query string, numResults int, url string, statusCode int, statusText string, bytesValue int, durationMs int, appliedLimit int, truncated bool, question string, questionCount int, answer string, answerCount int, plan string, stepCount int, oldTodoCount int, newTodoCount int, patchSubstrings []string, oldString string, newString string, originalFile string, replaceAll *bool, userModified *bool, absentSubstrings []string) {
+ t.Helper()
+ if result == nil {
+ t.Fatal("structured result is nil")
+ }
+ if result.Kind != kind {
+ t.Fatalf("result kind = %q, want %q; result = %+v", result.Kind, kind, result)
+ }
+ if filePath != "" && result.FilePath != filePath {
+ t.Fatalf("result file_path = %q, want %q; result = %+v", result.FilePath, filePath, result)
+ }
+ if content != "" && !strings.Contains(result.Content, content) {
+ t.Fatalf("result content = %q, want substring %q; result = %+v", result.Content, content, result)
+ }
+ for _, absent := range absentSubstrings {
+ if strings.Contains(result.Content, absent) || strings.Contains(result.Stdout, absent) || strings.Contains(result.Text, absent) {
+ t.Fatalf("result contains unwanted substring %q; result = %+v", absent, result)
+ }
+ }
+ if stdout != "" && result.Stdout != stdout {
+ t.Fatalf("result stdout = %q, want %q; result = %+v", result.Stdout, stdout, result)
+ }
+ if exitCode != nil {
+ if result.ExitCode == nil || *result.ExitCode != *exitCode {
+ t.Fatalf("result exit_code = %v, want %d; result = %+v", result.ExitCode, *exitCode, result)
+ }
+ }
+ if mode != "" && result.Mode != mode {
+ t.Fatalf("result mode = %q, want %q; result = %+v", result.Mode, mode, result)
+ }
+ if query != "" && result.Query != query {
+ t.Fatalf("result query = %q, want %q; result = %+v", result.Query, query, result)
+ }
+ if numResults != 0 && result.NumResults != numResults {
+ t.Fatalf("result num_results = %d, want %d; result = %+v", result.NumResults, numResults, result)
+ }
+ if url != "" && result.URL != url {
+ t.Fatalf("result url = %q, want %q; result = %+v", result.URL, url, result)
+ }
+ if statusCode != 0 && result.StatusCode != statusCode {
+ t.Fatalf("result status_code = %d, want %d; result = %+v", result.StatusCode, statusCode, result)
+ }
+ if statusText != "" && result.StatusText != statusText {
+ t.Fatalf("result status_text = %q, want %q; result = %+v", result.StatusText, statusText, result)
+ }
+ if bytesValue != 0 && result.Bytes != bytesValue {
+ t.Fatalf("result bytes = %d, want %d; result = %+v", result.Bytes, bytesValue, result)
+ }
+ if durationMs != 0 && result.DurationMs != durationMs {
+ t.Fatalf("result duration_ms = %d, want %d; result = %+v", result.DurationMs, durationMs, result)
+ }
+ if appliedLimit != 0 && result.AppliedLimit != appliedLimit {
+ t.Fatalf("result applied_limit = %d, want %d; result = %+v", result.AppliedLimit, appliedLimit, result)
+ }
+ if truncated && !result.Truncated {
+ t.Fatalf("result truncated = false, want true; result = %+v", result)
+ }
+ if question != "" && result.Question != question {
+ t.Fatalf("result question = %q, want %q; result = %+v", result.Question, question, result)
+ }
+ if questionCount != 0 {
+ if len(result.Questions) != questionCount {
+ t.Fatalf("result questions = %#v, want %d questions; result = %+v", result.Questions, questionCount, result)
+ }
+ if result.Questions[0].Question == "" || result.Questions[0].Header == "" || !result.Questions[0].MultiSelect || len(result.Questions[0].Options) == 0 || result.Questions[0].Options[0].Description == "" {
+ t.Fatalf("result questions = %#v, want question text, header, multi-select, and option descriptions", result.Questions)
+ }
+ }
+ if answer != "" && result.Answer != answer {
+ t.Fatalf("result answer = %q, want %q; result = %+v", result.Answer, answer, result)
+ }
+ if answerCount != 0 && len(result.Answers) != answerCount {
+ t.Fatalf("result answers = %#v, want %d answers; result = %+v", result.Answers, answerCount, result)
+ }
+ if plan != "" && result.Plan != plan {
+ t.Fatalf("result plan = %q, want %q; result = %+v", result.Plan, plan, result)
+ }
+ if stepCount != 0 && len(result.Steps) != stepCount {
+ t.Fatalf("result steps = %#v, want %d steps; result = %+v", result.Steps, stepCount, result)
+ }
+ if len(filenames) > 0 && result.NumFiles != len(filenames) {
+ t.Fatalf("result num_files = %d, want %d; result = %+v", result.NumFiles, len(filenames), result)
+ }
+ if oldTodoCount != 0 && len(result.OldTodos) != oldTodoCount {
+ t.Fatalf("result old_todos = %#v, want %d items; result = %+v", result.OldTodos, oldTodoCount, result)
+ }
+ if newTodoCount != 0 && len(result.NewTodos) != newTodoCount {
+ t.Fatalf("result new_todos = %#v, want %d items; result = %+v", result.NewTodos, newTodoCount, result)
+ }
+ for _, want := range filenames {
+ if !stringSliceContains(result.Filenames, want) {
+ t.Fatalf("result filenames = %#v, missing %q; result = %+v", result.Filenames, want, result)
+ }
+ }
+ for _, want := range resultItemURLs {
+ if !structuredResultItemsContainURL(result.ResultItems, want) {
+ t.Fatalf("result_items = %#v, missing URL %q; result = %+v", result.ResultItems, want, result)
+ }
+ }
+ for _, want := range patchSubstrings {
+ if !strings.Contains(result.Patch, want) {
+ t.Fatalf("result patch = %q, missing %q; result = %+v", result.Patch, want, result)
+ }
+ }
+ isPatchResultKind := kind == "edit" || kind == "write"
+ if isPatchResultKind && len(patchSubstrings) > 0 && len(result.PatchHunks) == 0 {
+ t.Fatalf("%s result has patch %q but no typed patch_hunks; result = %+v", kind, result.Patch, result)
+ }
+ if isPatchResultKind && len(patchSubstrings) == 0 && result.Patch != "" {
+ t.Fatalf("%s result unexpectedly has generated patch %q; result = %+v", kind, result.Patch, result)
+ }
+ if oldString != "" && result.OldString != oldString {
+ t.Fatalf("result old_string = %q, want %q; result = %+v", result.OldString, oldString, result)
+ }
+ if newString != "" && result.NewString != newString {
+ t.Fatalf("result new_string = %q, want %q; result = %+v", result.NewString, newString, result)
+ }
+ if originalFile != "" && result.OriginalFile != originalFile {
+ t.Fatalf("result original_file = %q, want %q; result = %+v", result.OriginalFile, originalFile, result)
+ }
+ if replaceAll != nil {
+ if result.ReplaceAll == nil || *result.ReplaceAll != *replaceAll {
+ t.Fatalf("result replace_all = %v, want %v; result = %+v", result.ReplaceAll, *replaceAll, result)
+ }
+ }
+ if userModified != nil {
+ if result.UserModified == nil || *result.UserModified != *userModified {
+ t.Fatalf("result user_modified = %v, want %v; result = %+v", result.UserModified, *userModified, result)
+ }
+ }
+ if !isPatchResultKind && result.Patch != "" {
+ t.Fatalf("non-edit result unexpectedly has patch %q; result = %+v", result.Patch, result)
+ }
+}
+
+func writeStructuredClaudeReadFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-read","name":"Read","input":{"file_path":"README.md"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-2","parentUuid":"claude-1","type":"tool_result","toolUseID":"call-claude-read","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-read","content":"read complete"}]},"toolUseResult":{"type":"text","file":{"filePath":"README.md","content":"Gas City README\n","numLines":1,"startLine":1,"totalLines":1,"language":"markdown"}},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeEditFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-edit-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-edit","name":"Edit","input":{"file_path":"README.md","old_string":"old line","new_string":"new line"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-edit-2","parentUuid":"claude-edit-1","type":"tool_result","toolUseID":"call-claude-edit","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-edit","content":"The file README.md has been updated successfully."}]},"toolUseResult":{"filePath":"README.md","oldString":"old line","newString":"new line","originalFile":"export const message = \"old line\";\n","structuredPatch":[{"oldStart":1,"oldLines":1,"newStart":1,"newLines":1,"lines":["-export const message = \"old line\";","+export const message = \"new line\";"]}],"userModified":false,"replaceAll":false},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeBashToolUseResultFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-bash-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-bash","name":"Bash","input":{"command":"npm test"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-bash-2","parentUuid":"claude-bash-1","type":"tool_result","toolUseID":"call-claude-bash","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-bash","content":"command completed"}]},"toolUseResult":{"stdout":"tests passed\n","stderr":"","exitCode":0},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeGlobFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-glob-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-glob","name":"Glob","input":{"pattern":"**/*.go","path":"internal"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-glob-2","parentUuid":"claude-glob-1","type":"tool_result","toolUseID":"call-claude-glob","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-glob","content":"found files"}]},"toolUseResult":{"filenames":["internal/api/session_structured_types.go","internal/worker/structured_tool.go"],"durationMs":27,"numFiles":2,"truncated":true},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeGrepFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-grep-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-grep","name":"Grep","input":{"pattern":"needle","path":"README.md"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-grep-2","parentUuid":"claude-grep-1","type":"tool_result","toolUseID":"call-claude-grep","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-grep","content":"grep complete"}]},"toolUseResult":{"mode":"content","filenames":["README.md"],"content":"README.md:1:needle\n","numLines":1,"appliedLimit":100},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeWebSearchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-search-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-search","name":"WebSearch","input":{"query":"structured stream format"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-search-2","parentUuid":"claude-search-1","type":"tool_result","toolUseID":"call-claude-search","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-search","content":"search complete"}]},"toolUseResult":{"query":"structured stream format","durationSeconds":1.25,"results":[{"tool_use_id":"native-call","content":[{"title":"Structured Stream Format","url":"https://example.com/structured","snippet":"Provider-neutral typed data."}]}]},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeWebFetchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-fetch-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-fetch","name":"WebFetch","input":{"url":"https://example.com/spec","prompt":"Extract the structured contract"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-fetch-2","parentUuid":"claude-fetch-1","type":"tool_result","toolUseID":"call-claude-fetch","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-fetch","content":"fetched"}]},"toolUseResult":{"url":"https://example.com/spec","code":200,"codeText":"OK","bytes":4096,"durationMs":83,"result":"Fetched structured spec content.\nSecond line."},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeTodoWriteFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-todo-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-todo","name":"TodoWrite","input":{"todos":[{"content":"Review raw provider data","status":"in_progress","activeForm":"Reviewing raw provider data","priority":"high","id":"todo-1"}]}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-todo-2","parentUuid":"claude-todo-1","type":"tool_result","toolUseID":"call-claude-todo","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-todo","content":"todos updated"}]},"toolUseResult":{"oldTodos":[{"content":"Review raw provider data","status":"in_progress","activeForm":"Reviewing raw provider data"}],"newTodos":[{"content":"Review raw provider data","status":"completed","activeForm":"Reviewing raw provider data"},{"content":"Normalize typed todos","status":"pending","activeForm":"Normalizing typed todos"}]},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeExitPlanFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-plan-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-plan","name":"ExitPlanMode","input":{"plan":"Inspect MC and expose typed plan data."}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-plan-2","parentUuid":"claude-plan-1","type":"tool_result","toolUseID":"call-claude-plan","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-plan","content":"plan captured"}]},"toolUseResult":{"plan":"Inspect MC and expose typed plan data."},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeAskQuestionFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-question-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-question","name":"AskUserQuestion","input":{"question":"Proceed with typed question DTOs?","options":["Yes","No"]}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-question-2","parentUuid":"claude-question-1","type":"tool_result","toolUseID":"call-claude-question","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-question","content":"question answered"}]},"toolUseResult":{"questions":[{"question":"Select rollout scope","header":"Scope","options":[{"label":"All providers","description":"Validate first-class and graceful providers"},{"label":"Claude only","description":"Narrow smoke test"}],"multiSelect":true}],"answer":"All providers","answers":{"Select rollout scope":"All providers"}},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeTaskOutputFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-task-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-task","name":"TaskOutput","input":{"task_id":"task-123","block":true}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-task-2","parentUuid":"claude-task-1","type":"tool_result","toolUseID":"call-claude-task","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-task","content":"task completed"}]},"toolUseResult":{"taskId":"task-123","taskType":"subagent","status":"completed","description":"Run delegated check","output":"delegated check passed","exitCode":0,"totalDurationMs":1234,"totalTokens":321,"totalToolUseCount":4},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredClaudeBashOutputFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-bash-output-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-bash-output","name":"BashOutput","input":{"shellId":"shell-123","block":true}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-bash-output-2","parentUuid":"claude-bash-output-1","type":"tool_result","toolUseID":"call-claude-bash-output","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-bash-output","content":"bash output complete"}]},"toolUseResult":{"shellId":"shell-123","command":"npm test","status":"completed","exitCode":0,"stdout":"ok\n","stderr":"warn\n","stdoutLines":1,"stderrLines":1,"timestamp":"2026-06-01T00:00:02Z"},"timestamp":"2026-06-01T00:00:02Z"}`,
+ )
+}
+
+func writeStructuredClaudeWriteStdinFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-stdin-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-bash","name":"Bash","input":{"command":"claude --resume"}},{"type":"tool_use","id":"call-claude-stdin","name":"write_stdin","input":{"sessionId":42,"content":"hello\n"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-stdin-2","parentUuid":"claude-stdin-1","type":"tool_result","toolUseID":"call-claude-bash","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-bash","content":"Process running with session ID: 42"}]},"timestamp":"2026-06-01T00:00:01Z"}`,
+ `{"uuid":"claude-stdin-3","parentUuid":"claude-stdin-2","type":"tool_result","toolUseID":"call-claude-stdin","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-stdin","content":"sent"}]},"timestamp":"2026-06-01T00:00:02Z"}`,
+ )
+}
+
+func writeStructuredClaudeKillShellFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeNamedSessionJSONL(t, root, workDir, sessionKey+".jsonl",
+ `{"uuid":"claude-kill-1","parentUuid":"","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-claude-kill","name":"KillShell","input":{"shell_id":"shell-123"}}]},"timestamp":"2026-06-01T00:00:00Z"}`,
+ `{"uuid":"claude-kill-2","parentUuid":"claude-kill-1","type":"tool_result","toolUseID":"call-claude-kill","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-claude-kill","content":"kill complete"}]},"toolUseResult":{"shell_id":"shell-123","message":"Shell shell-123 killed"},"timestamp":"2026-06-01T00:00:01Z"}`,
+ )
+}
+
+func writeStructuredCodexPatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ dir := filepath.Join(root, "2026", "06", "01")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir codex dir: %v", err)
+ }
+ payload := strings.Join([]string{
+ fmt.Sprintf(`{"timestamp":"2026-06-01T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir),
+ `{"timestamp":"2026-06-01T00:00:01Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"call-codex-patch","name":"apply_patch","input":"*** Begin Patch\n*** Update File: city.toml\n@@\n+[workspace]\n*** End Patch\n"}}`,
+ `{"timestamp":"2026-06-01T00:00:02Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call-codex-patch","stdout":"Success. Updated the following files:\nM city.toml\n","stderr":"","success":true,"changes":{"city.toml":{"type":"update","unified_diff":"@@\n+[workspace]\n","move_path":null}},"status":"completed"}}`,
+ `{"timestamp":"2026-06-01T00:00:02Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call-codex-patch","output":"{\"output\":\"Success. Updated the following files:\\nM city.toml\\n\"}"}}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(filepath.Join(dir, structuredCodexFixtureFilename("2026-06-01T00-00-00", sessionKey)), []byte(payload), 0o644); err != nil {
+ t.Fatalf("write codex fixture: %v", err)
+ }
+}
+
+func writeStructuredCodexShellReadFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeStructuredCodexFixture(t, root, workDir, "2026-06-01T00-01-00", sessionKey, []string{
+ `{"timestamp":"2026-06-01T00:01:01Z","type":"response_item","payload":{"type":"function_call","call_id":"call-codex-read","name":"exec_command","arguments":"{\"cmd\":\"sed -n '12,14p' src/app.ts\"}"}}`,
+ `{"timestamp":"2026-06-01T00:01:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-codex-read","output":"Command: sed -n '12,14p' src/app.ts\nOutput:\nline 12\nline 13\nline 14\n"}}`,
+ })
+}
+
+func writeStructuredCodexWrappedShellReadFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeStructuredCodexFixture(t, root, workDir, "2026-06-01T00-01-30", sessionKey, []string{
+ `{"timestamp":"2026-06-01T00:01:31Z","type":"response_item","payload":{"type":"function_call","call_id":"call-codex-wrapped-read","name":"exec_command","arguments":"{\"cmd\":\"/usr/bin/env bash -lc \\\"sed -n '12,14p' src/app.ts\\\"\"}"}}`,
+ `{"timestamp":"2026-06-01T00:01:32Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-codex-wrapped-read","output":"Command: /usr/bin/env bash -lc \"sed -n '12,14p' src/app.ts\"\nOutput:\nline 12\nline 13\nline 14\n"}}`,
+ })
+}
+
+func writeStructuredCodexShellGrepFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeStructuredCodexFixture(t, root, workDir, "2026-06-01T00-02-00", sessionKey, []string{
+ `{"timestamp":"2026-06-01T00:02:01Z","type":"response_item","payload":{"type":"function_call","call_id":"call-codex-grep","name":"exec_command","arguments":"{\"cmd\":\"rg -n \\\"needle\\\" README.md src/app.ts\"}"}}`,
+ `{"timestamp":"2026-06-01T00:02:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-codex-grep","output":"Command: rg -n \"needle\" README.md src/app.ts\nOutput:\nREADME.md:1:needle\nsrc/app.ts:7:needle\n"}}`,
+ })
+}
+
+func writeStructuredCodexJSONStringCommandFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeStructuredCodexFixture(t, root, workDir, "2026-06-01T00-03-00", sessionKey, []string{
+ `{"timestamp":"2026-06-01T00:03:01Z","type":"response_item","payload":{"type":"function_call","call_id":"call-codex-json-command","name":"exec_command","arguments":"{\"cmd\":\"go test ./...\"}"}}`,
+ `{"timestamp":"2026-06-01T00:03:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-codex-json-command","output":"{\"stdout\":\"ok ./...\\n\",\"stderr\":\"\",\"exit_code\":0}"}}`,
+ })
+}
+
+func writeStructuredCodexWebSearchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ writeStructuredCodexFixture(t, root, workDir, "2026-06-01T00-04-00", sessionKey, []string{
+ `{"timestamp":"2026-06-01T00:04:01Z","type":"response_item","payload":{"type":"web_search_call","id":"call-codex-web-search","query":"structured tool result formats","input":{"query":"ignored fallback","scope":"web"},"action":{"type":"search","source":"web"}}}`,
+ `{"timestamp":"2026-06-01T00:04:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-codex-web-search","output":"Output:\nhttps://example.com/provider-format: Provider format notes\n"}}`,
+ })
+}
+
+func writeStructuredCodexFixture(t *testing.T, root, workDir, localTimestamp, sessionKey string, entries []string) {
+ t.Helper()
+ dir := filepath.Join(root, "2026", "06", "01")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir codex dir: %v", err)
+ }
+ lines := []string{fmt.Sprintf(`{"timestamp":"2026-06-01T00:00:00Z","type":"session_meta","payload":{"cwd":%q}}`, workDir)}
+ lines = append(lines, entries...)
+ payload := strings.Join(lines, "\n") + "\n"
+ if err := os.WriteFile(filepath.Join(dir, structuredCodexFixtureFilename(localTimestamp, sessionKey)), []byte(payload), 0o644); err != nil {
+ t.Fatalf("write codex fixture: %v", err)
+ }
+}
+
+func structuredCodexFixtureFilename(localTimestamp, sessionKey string) string {
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" {
+ return "rollout-" + localTimestamp + "-structured.jsonl"
+ }
+ return "rollout-" + localTimestamp + "-" + sessionKey + ".jsonl"
+}
+
+func writeStructuredGeminiGrepFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ projectDir := filepath.Join(root, "gemini-project")
+ chatsDir := filepath.Join(projectDir, "chats")
+ if err := os.MkdirAll(chatsDir, 0o755); err != nil {
+ t.Fatalf("mkdir gemini chats: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(projectDir, ".project_root"), []byte(workDir), 0o644); err != nil {
+ t.Fatalf("write gemini project root: %v", err)
+ }
+ body := `{
+ "sessionId": "gemini-structured",
+ "messages": [
+ {"id":"gemini-1","timestamp":"2026-06-01T00:00:00Z","type":"gemini","content":"searching","toolCalls":[{"id":"call-gemini-grep","name":"grep_search","args":{"pattern":"needle"},"result":[{"functionResponse":{"id":"call-gemini-grep","response":{"output":"main.go:7:needle\nREADME.md:1:needle\n"}}}]}]}
+ ]
+}`
+ if err := os.WriteFile(filepath.Join(chatsDir, "session-structured.json"), []byte(body), 0o644); err != nil {
+ t.Fatalf("write gemini fixture: %v", err)
+ }
+}
+
+func writeStructuredGeminiErrorFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ projectDir := filepath.Join(root, "gemini-project-error")
+ chatsDir := filepath.Join(projectDir, "chats")
+ if err := os.MkdirAll(chatsDir, 0o755); err != nil {
+ t.Fatalf("mkdir gemini chats: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(projectDir, ".project_root"), []byte(workDir), 0o644); err != nil {
+ t.Fatalf("write gemini project root: %v", err)
+ }
+ body := strings.Join([]string{
+ `{"sessionId":"gemini-error-message","kind":"main"}`,
+ `{"id":"err-1","timestamp":"2026-06-21T17:08:12Z","type":"error","content":[{"text":"Gemini stream interrupted"}]}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(filepath.Join(chatsDir, "session-error.jsonl"), []byte(body), 0o644); err != nil {
+ t.Fatalf("write gemini error fixture: %v", err)
+ }
+}
+
+func writeStructuredGeminiWriteFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ projectDir := filepath.Join(root, "gemini-project")
+ chatsDir := filepath.Join(projectDir, "chats")
+ if err := os.MkdirAll(chatsDir, 0o755); err != nil {
+ t.Fatalf("mkdir gemini chats: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(projectDir, ".project_root"), []byte(workDir), 0o644); err != nil {
+ t.Fatalf("write gemini project root: %v", err)
+ }
+ body := `{
+ "sessionId": "gemini-structured",
+ "messages": [
+ {"id":"gemini-1","timestamp":"2026-06-01T00:00:00Z","type":"gemini","content":"writing","toolCalls":[{"id":"call-gemini-write","name":"write_file","args":{"file_path":"notes.txt","content":"hello gemini"},"result":[{"functionResponse":{"id":"call-gemini-write","response":{"output":"Successfully created and wrote to new file: notes.txt"}}}],"resultDisplay":{"fileDiff":"Index: notes.txt\n===================================================================\n--- notes.txt\tOriginal\n+++ notes.txt\tWritten\n@@ -0,0 +1 @@\n+hello gemini","filePath":"notes.txt","originalContent":"","newContent":"hello gemini"}}]}
+ ]
+}`
+ if err := os.WriteFile(filepath.Join(chatsDir, "session-structured.json"), []byte(body), 0o644); err != nil {
+ t.Fatalf("write gemini fixture: %v", err)
+ }
+}
+
+func writeStructuredGeminiWriteContentPairFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ projectDir := filepath.Join(root, "gemini-project")
+ chatsDir := filepath.Join(projectDir, "chats")
+ if err := os.MkdirAll(chatsDir, 0o755); err != nil {
+ t.Fatalf("mkdir gemini chats: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(projectDir, ".project_root"), []byte(workDir), 0o644); err != nil {
+ t.Fatalf("write gemini project root: %v", err)
+ }
+ body := `{
+ "sessionId": "gemini-structured",
+ "messages": [
+ {"id":"gemini-1","timestamp":"2026-06-01T00:00:00Z","type":"gemini","content":"writing","toolCalls":[{"id":"call-gemini-write","name":"write_file","args":{"file_path":"notes.txt","content":"hello gemini"},"result":[{"functionResponse":{"id":"call-gemini-write","response":{"output":"Successfully created and wrote to new file: notes.txt"}}}],"resultDisplay":{"filePath":"notes.txt","originalContent":"old text","newContent":"hello gemini"}}]}
+ ]
+}`
+ if err := os.WriteFile(filepath.Join(chatsDir, "session-structured.json"), []byte(body), 0o644); err != nil {
+ t.Fatalf("write gemini fixture: %v", err)
+ }
+}
+
+func writeStructuredKimiReadFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ sum := md5.Sum([]byte(filepath.Clean(workDir)))
+ workHash := hex.EncodeToString(sum[:])
+ path := filepath.Join(root, workHash, sessionKey, "context.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir kimi context dir: %v", err)
+ }
+ payload := strings.Join([]string{
+ `{"role":"assistant","content":[],"tool_calls":[{"type":"function","id":"call-kimi-read","function":{"name":"Read","arguments":"{\"path\":\"README.md\"}"}}]}`,
+ `{"role":"tool","content":[{"type":"text","text":"Kimi file data"}],"tool_call_id":"call-kimi-read"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
+ t.Fatalf("write kimi fixture: %v", err)
+ }
+}
+
+func writeStructuredKimiEditPatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ sum := md5.Sum([]byte(filepath.Clean(workDir)))
+ workHash := hex.EncodeToString(sum[:])
+ path := filepath.Join(root, workHash, sessionKey, "context.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir kimi context dir: %v", err)
+ }
+ payload := strings.Join([]string{
+ `{"role":"assistant","content":[],"tool_calls":[{"type":"function","id":"call-kimi-edit","function":{"name":"Edit","arguments":"{\"filePath\":\"README.md\",\"oldString\":\"old\",\"newString\":\"new\"}"}}]}`,
+ `{"role":"tool","content":{"output":"Edited README.md","filePath":"README.md","patch":"--- README.md\n+++ README.md\n@@\n-old\n+new"},"tool_call_id":"call-kimi-edit"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
+ t.Fatalf("write kimi fixture: %v", err)
+ }
+}
+
+func writeStructuredOpenCodeEditFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ body := fmt.Sprintf(`{
+ "info": {"id":"opencode-structured","directory":%q},
+ "messages": [
+ {"info":{"id":"opencode-1","sessionID":"opencode-structured","role":"assistant","time":{"created":1780272000000}},"parts":[{"id":"part-tool","type":"tool","callID":"call-opencode-edit","tool":"Edit","state":{"status":"completed","input":{"filePath":"README.md","oldString":"old","newString":"new"},"output":"Edited README.md"}}]}
+ ]
+}`, workDir)
+ writeStructuredOpenCodeExport(t, filepath.Join(root, "opencode", "session-structured.json"), body)
+}
+
+func writeStructuredOpenCodeEditPatchResultFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ body := fmt.Sprintf(`{
+ "info": {"id":"opencode-structured","directory":%q},
+ "messages": [
+ {"info":{"id":"opencode-1","sessionID":"opencode-structured","role":"assistant","time":{"created":1780272000000}},"parts":[{"id":"part-tool","type":"tool","callID":"call-opencode-edit","tool":"Edit","state":{"status":"completed","input":{"filePath":"README.md","oldString":"old","newString":"new"},"output":{"output":"Edited README.md","filePath":"README.md","patch":"--- README.md\n+++ README.md\n@@\n-old\n+new"}}}]}
+ ]
+}`, workDir)
+ writeStructuredOpenCodeExport(t, filepath.Join(root, "opencode", "session-structured.json"), body)
+}
+
+func writeStructuredMimoCodeBashFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ body := fmt.Sprintf(`{
+ "info": {"id":"mimocode-structured","directory":%q},
+ "messages": [
+ {"info":{"id":"mimocode-1","sessionID":"mimocode-structured","role":"assistant","time":{"created":1780272000000}},"parts":[{"id":"part-tool","type":"tool","callID":"call-mimocode-bash","tool":"Bash","state":{"status":"completed","input":{"command":"go test ./..."},"output":{"stdout":"ok ./...","exitCode":0}}}]}
+ ]
+}`, workDir)
+ writeStructuredOpenCodeExport(t, filepath.Join(root, "mimocode", "session-structured.json"), body)
+}
+
+func writeStructuredMimoCodeBashDiffFixture(t *testing.T, root, workDir, _ string) {
+ t.Helper()
+ body := fmt.Sprintf(`{
+ "info": {"id":"mimocode-structured","directory":%q},
+ "messages": [
+ {"info":{"id":"mimocode-1","sessionID":"mimocode-structured","role":"assistant","time":{"created":1780272000000}},"parts":[{"id":"part-tool","type":"tool","callID":"call-mimocode-diff","tool":"Bash","state":{"status":"completed","input":{"command":"git diff -- src/app.ts"},"output":{"stdout":"diff --git a/src/app.ts b/src/app.ts\n@@\n-old\n+new","exitCode":0}}}]}
+ ]
+}`, workDir)
+ writeStructuredOpenCodeExport(t, filepath.Join(root, "mimocode", "session-structured.json"), body)
+}
+
+func writeStructuredPiReadFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ body := fmt.Sprintf(`{"type":"session","version":3,"id":%q,"timestamp":"2026-06-01T00:00:00.000Z","cwd":%q}
+{"type":"message","id":"pi-user-1","parentId":null,"timestamp":"2026-06-01T00:00:00.000Z","message":{"role":"user","content":"read the file","timestamp":1780272000000}}
+{"type":"message","id":"pi-assistant-1","parentId":"pi-user-1","timestamp":"2026-06-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call-pi-read","name":"read","arguments":{"path":"README.md"}}],"timestamp":1780272001000}}
+{"type":"message","id":"pi-tool-1","parentId":"pi-assistant-1","timestamp":"2026-06-01T00:00:02.000Z","message":{"role":"toolResult","toolCallId":"call-pi-read","toolName":"read","content":[{"type":"text","text":"Pi file data"}],"isError":false,"timestamp":1780272002000}}
+`, sessionKey, workDir)
+ path := filepath.Join(root, "pi", sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir pi fixture dir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write pi fixture: %v", err)
+ }
+}
+
+func writeStructuredPiEditPatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ body := fmt.Sprintf(`{"type":"session","version":3,"id":%q,"timestamp":"2026-06-01T00:00:00.000Z","cwd":%q}
+{"type":"message","id":"pi-assistant-1","parentId":null,"timestamp":"2026-06-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call-pi-edit","name":"Edit","arguments":{"filePath":"README.md","oldString":"old","newString":"new"}}],"timestamp":1780272001000}}
+{"type":"message","id":"pi-tool-1","parentId":"pi-assistant-1","timestamp":"2026-06-01T00:00:02.000Z","message":{"role":"toolResult","toolCallId":"call-pi-edit","toolName":"Edit","content":{"output":"Edited README.md","filePath":"README.md","patch":"--- README.md\n+++ README.md\n@@\n-old\n+new"},"isError":false,"timestamp":1780272002000}}
+`, sessionKey, workDir)
+ path := filepath.Join(root, "pi", sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir pi fixture dir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write pi fixture: %v", err)
+ }
+}
+
+func writeStructuredKiroWritePatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ sidecar := strings.TrimSuffix(path, filepath.Ext(path)) + ".json"
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir kiro fixture dir: %v", err)
+ }
+ if err := os.WriteFile(sidecar, []byte(fmt.Sprintf(`{"id":%q,"cwd":%q}`, sessionKey, workDir)), 0o644); err != nil {
+ t.Fatalf("write kiro sidecar: %v", err)
+ }
+ body := strings.Join([]string{
+ `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"` + sessionKey + `","update":{"sessionUpdate":"tool_call","toolCallId":"call-kiro-write","title":"write","kind":"edit","status":"pending","rawInput":{"path":"notes.txt","content":"hello kiro\n"}}}}`,
+ `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"` + sessionKey + `","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-kiro-write","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":"old\n","newText":"hello kiro\n"}]}}}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write kiro fixture: %v", err)
+ }
+}
+
+func writeStructuredAmpEditPatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir amp fixture dir: %v", err)
+ }
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"type":"system","subtype":"init","cwd":%q,"session_id":%q,"tools":["edit_file"],"mcp_servers":[]}`, workDir, sessionKey),
+ `{"type":"assistant","message":{"type":"message","role":"assistant","content":[{"type":"tool_use","id":"call-amp-edit","name":"edit_file","input":{"filePath":"notes.txt","oldString":"old","newString":"new"}}],"stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5,"max_tokens":968000}},"parent_tool_use_id":null,"session_id":"` + sessionKey + `"}`,
+ `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-amp-edit","content":"{\"filePath\":\"notes.txt\",\"patch\":\"*** Begin Patch\\n*** Update File: notes.txt\\n@@\\n-old\\n+new\\n*** End Patch\",\"oldString\":\"old\",\"newString\":\"new\"}","is_error":false}]},"parent_tool_use_id":null,"session_id":"` + sessionKey + `"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write amp fixture: %v", err)
+ }
+}
+
+func writeStructuredCursorWriteFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir cursor fixture dir: %v", err)
+ }
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"type":"system","subtype":"init","cwd":%q,"session_id":%q}`, workDir, sessionKey),
+ `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"writing"}]},"session_id":"` + sessionKey + `"}`,
+ `{"type":"tool_call","subtype":"started","call_id":"call-cursor-write","tool_call":{"writeToolCall":{"toolCallId":"call-cursor-write","args":{"path":"notes.txt","fileText":"hello cursor\n"}}},"session_id":"` + sessionKey + `"}`,
+ `{"type":"tool_call","subtype":"completed","call_id":"call-cursor-write","tool_call":{"writeToolCall":{"toolCallId":"call-cursor-write","args":{"path":"notes.txt","fileText":"hello cursor\n"},"result":{"success":{"path":"notes.txt","linesCreated":1,"fileSize":13}}}},"session_id":"` + sessionKey + `"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write cursor fixture: %v", err)
+ }
+}
+
+func writeStructuredCursorReadFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir cursor fixture dir: %v", err)
+ }
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"type":"system","subtype":"init","cwd":%q,"session_id":%q}`, workDir, sessionKey),
+ `{"type":"tool_call","subtype":"started","call_id":"call-cursor-read","tool_call":{"readToolCall":{"toolCallId":"call-cursor-read","args":{"path":"src/app.ts"}}},"session_id":"` + sessionKey + `"}`,
+ `{"type":"tool_call","subtype":"completed","call_id":"call-cursor-read","tool_call":{"readToolCall":{"toolCallId":"call-cursor-read","args":{"path":"src/app.ts"},"result":{"success":{"content":"export const app = true;\n","isEmpty":false,"exceededLimit":false,"totalLines":1,"totalChars":25}}}},"session_id":"` + sessionKey + `"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write cursor fixture: %v", err)
+ }
+}
+
+func writeStructuredCursorBashFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir cursor fixture dir: %v", err)
+ }
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"type":"system","subtype":"init","cwd":%q,"session_id":%q}`, workDir, sessionKey),
+ `{"type":"tool_call","subtype":"started","call_id":"call-cursor-bash","tool_call":{"function":{"name":"Bash","arguments":{"command":"npm test"}}},"session_id":"` + sessionKey + `"}`,
+ `{"type":"tool_call","subtype":"completed","call_id":"call-cursor-bash","tool_call":{"function":{"name":"Bash","arguments":{"command":"npm test"},"result":{"success":{"stdout":"ok\n","stderr":"","exitCode":0}}}},"session_id":"` + sessionKey + `"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write cursor fixture: %v", err)
+ }
+}
+
+func writeStructuredGrokACPEditPatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir grok fixture dir: %v", err)
+ }
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"sessionId":%q,"cwd":%q}}`, sessionKey, workDir),
+ `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"` + sessionKey + `","update":{"sessionUpdate":"tool_call","toolCallId":"call-grok-edit","title":"search_replace","kind":"edit","status":"pending","rawInput":{"path":"notes.txt","oldText":"old","newText":"new"}}}}`,
+ `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"` + sessionKey + `","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-grok-edit","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":"old\n","newText":"new\n"}]}}}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write grok fixture: %v", err)
+ }
+}
+
+func writeStructuredAuggieACPEditPatchFixture(t *testing.T, root, workDir, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir auggie fixture dir: %v", err)
+ }
+ body := strings.Join([]string{
+ fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"sessionId":%q,"cwd":%q}}`, sessionKey, workDir),
+ `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"` + sessionKey + `","update":{"sessionUpdate":"tool_call","toolCallId":"call-auggie-edit","title":"str-replace-editor","kind":"edit","status":"pending","rawInput":{"path":"notes.txt","oldText":"old","newText":"new"}}}}`,
+ `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"` + sessionKey + `","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-auggie-edit","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":"old\n","newText":"new\n"}]}}}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write auggie fixture: %v", err)
+ }
+}
+
+func writeStructuredOpenCodeExport(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir opencode export: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write opencode export: %v", err)
+ }
+}
+
+func writeStructuredAntigravityWriteFixture(t *testing.T, root, _ string, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey, ".system_generated", "logs", "transcript.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir antigravity logs: %v", err)
+ }
+ body := strings.Join([]string{
+ `{"step_index":1,"type":"PLANNER_RESPONSE","created_at":"2026-06-01T00:00:00Z","content":"writing","tool_calls":[{"id":"call-antigravity-write","name":"Write","args":{"path":"notes.txt","content":"hello structured world"}}]}`,
+ `{"step_index":2,"type":"WRITE_FILE","created_at":"2026-06-01T00:00:01Z","tool_call_id":"call-antigravity-write","content":"wrote notes.txt"}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write antigravity fixture: %v", err)
+ }
+}
+
+func writeStructuredAntigravityEditPatchFixture(t *testing.T, root, _ string, sessionKey string) {
+ t.Helper()
+ path := filepath.Join(root, sessionKey, ".system_generated", "logs", "transcript.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir antigravity logs: %v", err)
+ }
+ resultContent := `{"output":"Edited notes.txt","filePath":"notes.txt","diff":"--- notes.txt\n+++ notes.txt\n@@\n-old\n+new","exitCode":0}`
+ resultLine, err := json.Marshal(map[string]any{
+ "step_index": 2,
+ "type": "WRITE_FILE",
+ "created_at": "2026-06-01T00:00:01Z",
+ "tool_call_id": "call-antigravity-edit",
+ "content": resultContent,
+ })
+ if err != nil {
+ t.Fatalf("marshal antigravity result line: %v", err)
+ }
+ body := strings.Join([]string{
+ `{"step_index":1,"type":"PLANNER_RESPONSE","created_at":"2026-06-01T00:00:00Z","content":"editing","tool_calls":[{"id":"call-antigravity-edit","name":"Edit","args":{"filePath":"notes.txt","oldString":"old","newString":"new"}}]}`,
+ string(resultLine),
+ }, "\n") + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write antigravity fixture: %v", err)
+ }
+}
+
+func stringSliceContains(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
+
+func structuredResultItemsContainURL(items []SessionStructuredSearchResultItem, want string) bool {
+ for _, item := range items {
+ if item.URL == want {
+ return true
+ }
+ }
+ return false
+}
+
+func boolPtr(value bool) *bool {
+ return &value
+}
diff --git a/internal/api/session_structured_schema.go b/internal/api/session_structured_schema.go
new file mode 100644
index 0000000000..f6082001af
--- /dev/null
+++ b/internal/api/session_structured_schema.go
@@ -0,0 +1,363 @@
+package api
+
+import (
+ "fmt"
+ "reflect"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/gastownhall/gascity/internal/worker"
+)
+
+type structuredSchemaVariant struct {
+ value string
+ name string
+ fields []string
+ required []string
+}
+
+var structuredMessageSchemaVariants = []structuredSchemaVariant{
+ {
+ value: string(worker.ActorUnknown),
+ name: "SessionStructuredMessageUnknown",
+ fields: []string{"id", "provider", "timestamp", "model", "stop_reason", "usage", "user_prompt", "system_event", "status", "blocks"},
+ },
+ {
+ value: string(worker.ActorUser),
+ name: "SessionStructuredMessageUser",
+ fields: []string{"id", "provider", "timestamp", "user_prompt", "status", "blocks"},
+ },
+ {
+ value: string(worker.ActorAssistant),
+ name: "SessionStructuredMessageAssistant",
+ fields: []string{"id", "provider", "timestamp", "model", "stop_reason", "usage", "status", "blocks"},
+ },
+ {
+ value: string(worker.ActorSystem),
+ name: "SessionStructuredMessageSystem",
+ fields: []string{"id", "provider", "timestamp", "system_event", "status", "blocks"},
+ },
+ {
+ value: string(worker.ActorTool),
+ name: "SessionStructuredMessageTool",
+ fields: []string{"id", "provider", "timestamp", "status", "blocks"},
+ },
+}
+
+var structuredBlockSchemaVariants = []structuredSchemaVariant{
+ {value: string(worker.BlockKindText), name: "SessionStructuredBlockText", fields: []string{"text"}},
+ {value: string(worker.BlockKindThinking), name: "SessionStructuredBlockThinking", fields: []string{"thinking", "signature"}},
+ {value: string(worker.BlockKindToolUse), name: "SessionStructuredBlockToolUse", fields: []string{"id", "name", "file_path", "input"}},
+ {value: string(worker.BlockKindToolResult), name: "SessionStructuredBlockToolResult", fields: []string{"tool_call_id", "name", "file_path", "content", "is_error", "structured"}},
+ {value: string(worker.BlockKindInteraction), name: "SessionStructuredBlockInteraction", fields: []string{"interaction"}},
+ {value: string(worker.BlockKindImage), name: "SessionStructuredBlockImage", fields: []string{"text", "file_path", "image_url", "mime_type"}},
+ {
+ value: string(worker.BlockKindUnknown),
+ name: "SessionStructuredBlockUnknown",
+ fields: []string{"text", "thinking", "signature", "id", "tool_call_id", "name", "file_path", "image_url", "mime_type", "input", "content", "is_error", "structured", "interaction"},
+ },
+}
+
+var structuredToolInputSchemaVariants = []structuredSchemaVariant{
+ {
+ value: "unknown",
+ name: "SessionStructuredToolInputUnknown",
+ fields: []string{"text", "command", "linked_command", "code", "patch", "file_path", "language", "url", "prompt", "task_id", "task_type", "task_status", "description", "question", "options", "query", "pattern", "plan", "explanation", "steps", "todos", "arguments"},
+ },
+ {value: "command", name: "SessionStructuredToolInputCommand", fields: []string{"command", "arguments"}, required: []string{"command"}},
+ {value: "stdin", name: "SessionStructuredToolInputStdin", fields: []string{"task_id", "text", "linked_command"}},
+ {value: "code", name: "SessionStructuredToolInputCode", fields: []string{"code", "language"}, required: []string{"code"}},
+ {value: "patch", name: "SessionStructuredToolInputPatch", fields: []string{"patch", "file_path", "language"}, required: []string{"patch"}},
+ {value: "write", name: "SessionStructuredToolInputWrite", fields: []string{"file_path", "language", "text"}},
+ {value: "glob", name: "SessionStructuredToolInputGlob", fields: []string{"pattern", "query", "file_path", "arguments"}},
+ {value: "fetch", name: "SessionStructuredToolInputFetch", fields: []string{"url", "prompt"}},
+ {value: "search", name: "SessionStructuredToolInputSearch", fields: []string{"query", "pattern", "file_path", "command", "arguments"}},
+ {value: "file", name: "SessionStructuredToolInputFile", fields: []string{"file_path", "language", "command"}, required: []string{"file_path"}},
+ {value: "todo", name: "SessionStructuredToolInputTodo", fields: []string{"todos"}},
+ {value: "plan", name: "SessionStructuredToolInputPlan", fields: []string{"plan", "explanation", "steps"}},
+ {value: "question", name: "SessionStructuredToolInputQuestion", fields: []string{"question", "options"}},
+ {value: "task", name: "SessionStructuredToolInputTask", fields: []string{"task_id", "task_type", "task_status", "description", "prompt"}},
+ {value: "text", name: "SessionStructuredToolInputText", fields: []string{"text"}, required: []string{"text"}},
+ {value: "arguments", name: "SessionStructuredToolInputArguments", fields: []string{"arguments"}, required: []string{"arguments"}},
+}
+
+var structuredToolResultSchemaVariants = []structuredSchemaVariant{
+ {
+ value: "unknown", name: "SessionStructuredToolResultUnknown",
+ fields: []string{"text", "command", "stdout", "stderr", "exit_code", "interrupted", "truncated", "is_image", "mode", "query", "url", "task_id", "task_type", "task_status", "description", "total_duration_ms", "total_tokens", "total_tool_use_count", "output", "question", "questions", "answer", "options", "answers", "counts", "status_code", "status_text", "bytes", "filenames", "num_files", "num_results", "duration_ms", "applied_limit", "stdout_lines", "stderr_lines", "timestamp", "result_items", "content", "num_lines", "file_path", "file_paths", "language", "code", "plan", "explanation", "steps", "patch", "patch_hunks", "old_string", "new_string", "original_file", "replace_all", "user_modified", "old_todos", "new_todos", "start_line", "total_lines", "error"},
+ },
+ {value: "bash", name: "SessionStructuredToolResultBash", fields: []string{"text", "command", "stdout", "stderr", "exit_code", "interrupted", "truncated", "is_image", "task_id", "task_status", "stdout_lines", "stderr_lines", "timestamp", "content", "num_lines", "error"}},
+ {value: "python", name: "SessionStructuredToolResultPython", fields: []string{"text", "code", "stdout", "stderr", "exit_code", "interrupted", "truncated", "is_image", "error"}},
+ {value: "read", name: "SessionStructuredToolResultRead", fields: []string{"file_path", "language", "content", "num_lines", "start_line", "total_lines", "error"}},
+ {value: "glob", name: "SessionStructuredToolResultGlob", fields: []string{"filenames", "num_files", "duration_ms", "truncated", "content", "num_lines", "error"}},
+ {value: "grep", name: "SessionStructuredToolResultGrep", fields: []string{"mode", "query", "filenames", "num_files", "num_results", "counts", "duration_ms", "applied_limit", "result_items", "content", "num_lines", "error"}},
+ {value: "search", name: "SessionStructuredToolResultSearch", fields: []string{"mode", "query", "filenames", "num_files", "num_results", "counts", "duration_ms", "applied_limit", "result_items", "content", "num_lines", "error"}},
+ {value: "fetch", name: "SessionStructuredToolResultFetch", fields: []string{"text", "url", "status_code", "status_text", "bytes", "duration_ms", "content", "num_lines", "error"}},
+ {value: "todo", name: "SessionStructuredToolResultTodo", fields: []string{"text", "content", "old_todos", "new_todos", "error"}},
+ {value: "plan", name: "SessionStructuredToolResultPlan", fields: []string{"text", "content", "plan", "explanation", "steps", "error"}},
+ {value: "question", name: "SessionStructuredToolResultQuestion", fields: []string{"text", "content", "question", "questions", "answer", "options", "answers", "error"}},
+ {value: "stdin", name: "SessionStructuredToolResultStdin", fields: []string{"text", "task_id", "content", "num_lines", "error"}},
+ {value: "task", name: "SessionStructuredToolResultTask", fields: []string{"text", "task_id", "task_type", "task_status", "description", "total_duration_ms", "total_tokens", "total_tool_use_count", "output", "stdout", "stderr", "exit_code", "content", "error"}},
+ {value: "write", name: "SessionStructuredToolResultWrite", fields: []string{"text", "file_path", "file_paths", "language", "content", "num_lines", "patch", "patch_hunks", "start_line", "total_lines", "error"}},
+ {value: "edit", name: "SessionStructuredToolResultEdit", fields: []string{"file_path", "file_paths", "patch", "patch_hunks", "old_string", "new_string", "original_file", "replace_all", "user_modified", "content", "error"}},
+ {value: "text", name: "SessionStructuredToolResultText", fields: []string{"text", "content", "error"}},
+}
+
+type (
+ sessionStructuredMessageSchemaFields SessionStructuredMessage
+ sessionStructuredBlockSchemaFields SessionStructuredBlock
+ sessionStructuredToolInputSchemaFields SessionStructuredToolInput
+ sessionStructuredToolResultSchemaFields SessionStructuredToolResult
+ sessionStreamStructuredMessageEventSchemaFields SessionStreamStructuredMessageEvent
+ sessionTranscriptStructuredResponseSchemaFields sessionTranscriptStructuredResponse
+)
+
+// Schema registers SessionStructuredMessage as a named role-discriminated
+// union. The runtime struct remains a compact projection carrier while the
+// published contract gives generated clients closed role variants.
+func (SessionStructuredMessage) Schema(r huma.Registry) *huma.Schema {
+ return registerStructuredSchemaUnion(
+ r,
+ "SessionStructuredMessage",
+ "Structured transcript message",
+ "Provider-normalized transcript message discriminated by its closed role vocabulary.",
+ "role",
+ reflect.TypeOf(sessionStructuredMessageSchemaFields{}),
+ structuredMessageSchemaVariants,
+ []string{"blocks"},
+ )
+}
+
+// Schema registers SessionStructuredBlock as a named type-discriminated union.
+func (SessionStructuredBlock) Schema(r huma.Registry) *huma.Schema {
+ return registerStructuredSchemaUnion(
+ r,
+ "SessionStructuredBlock",
+ "Structured transcript block",
+ "Provider-normalized transcript block discriminated by its closed block type vocabulary.",
+ "type",
+ reflect.TypeOf(sessionStructuredBlockSchemaFields{}),
+ structuredBlockSchemaVariants,
+ nil,
+ )
+}
+
+// Schema registers SessionStructuredToolInput as a named kind-discriminated
+// union. Provider-native input remains available only through format=raw.
+func (SessionStructuredToolInput) Schema(r huma.Registry) *huma.Schema {
+ return registerStructuredSchemaUnion(
+ r,
+ "SessionStructuredToolInput",
+ "Structured tool input",
+ "Provider-neutral tool input discriminated by its closed kind vocabulary.",
+ "kind",
+ reflect.TypeOf(sessionStructuredToolInputSchemaFields{}),
+ structuredToolInputSchemaVariants,
+ nil,
+ )
+}
+
+// Schema registers SessionStructuredToolResult as a named kind-discriminated
+// union. Provider-native results remain available only through format=raw.
+func (SessionStructuredToolResult) Schema(r huma.Registry) *huma.Schema {
+ return registerStructuredSchemaUnion(
+ r,
+ "SessionStructuredToolResult",
+ "Structured tool result",
+ "Provider-neutral tool result discriminated by its closed kind vocabulary.",
+ "kind",
+ reflect.TypeOf(sessionStructuredToolResultSchemaFields{}),
+ structuredToolResultSchemaVariants,
+ nil,
+ )
+}
+
+// Schema registers the structured SSE payload with literal format and schema
+// values plus the required non-null REST-to-SSE handoff fields.
+func (SessionStreamStructuredMessageEvent) Schema(r huma.Registry) *huma.Schema {
+ const name = "SessionStreamStructuredMessageEvent"
+ if _, ok := r.Map()[name]; !ok {
+ schema := huma.SchemaFromType(r, reflect.TypeOf(sessionStreamStructuredMessageEventSchemaFields{}))
+ schema.Title = "Structured session stream message"
+ schema.Description = "Provider-neutral structured transcript update with explicit snapshot, upsert, or reset application semantics."
+ // Keep this as a field-addressable object rather than a top-level oneOf:
+ // oapi-codegen represents such unions as raw JSON wrappers. The closed
+ // operation/reset-reason enums and the field's conditional-presence
+ // documentation are the most precise contract that preserves typed Go
+ // client fields; runtime construction enforces the combination.
+ constrainStructuredEnvelopeSchema(schema)
+ r.Map()[name] = schema
+ }
+ return &huma.Schema{Ref: schemaRefPrefix + name}
+}
+
+// Schema registers the structured REST response with the same literal and
+// required-field contract as the structured SSE payload.
+func (sessionTranscriptStructuredResponse) Schema(r huma.Registry) *huma.Schema {
+ const name = "SessionTranscriptStructuredResponse"
+ if _, ok := r.Map()[name]; !ok {
+ schema := huma.SchemaFromType(r, reflect.TypeOf(sessionTranscriptStructuredResponseSchemaFields{}))
+ schema.Title = "Structured session transcript response"
+ schema.Description = "Provider-neutral structured transcript snapshot."
+ constrainStructuredSnapshotEnvelopeSchema(schema)
+ r.Map()[name] = schema
+ }
+ return &huma.Schema{Ref: schemaRefPrefix + name}
+}
+
+func registerStructuredSchemaUnion(
+ r huma.Registry,
+ name string,
+ title string,
+ description string,
+ discriminator string,
+ fieldsType reflect.Type,
+ variants []structuredSchemaVariant,
+ requiredNonNullableFields []string,
+) *huma.Schema {
+ if _, ok := r.Map()[name]; !ok {
+ fields := huma.SchemaFromType(r, fieldsType)
+ oneOf := make([]*huma.Schema, 0, len(variants))
+ mapping := make(map[string]string, len(variants))
+ for _, variant := range variants {
+ ref := schemaRefPrefix + variant.name
+ if _, ok := r.Map()[variant.name]; !ok {
+ variantSchema := selectStructuredSchemaFields(fields, variant.name, append([]string{discriminator}, variant.fields...))
+ variantSchema.Title = variant.name
+ setStructuredSchemaLiteral(variantSchema, discriminator, variant.value)
+ requireStructuredSchemaFields(variantSchema, discriminator)
+ for _, field := range variant.required {
+ requireStructuredSchemaFields(variantSchema, field)
+ setStructuredSchemaNonNullable(variantSchema, field)
+ }
+ for _, field := range requiredNonNullableFields {
+ requireStructuredSchemaFields(variantSchema, field)
+ setStructuredSchemaNonNullable(variantSchema, field)
+ }
+ r.Map()[variant.name] = variantSchema
+ }
+ oneOf = append(oneOf, &huma.Schema{Ref: ref})
+ mapping[variant.value] = ref
+ }
+ r.Map()[name] = &huma.Schema{
+ Title: title,
+ Description: description,
+ OneOf: oneOf,
+ Discriminator: &huma.Discriminator{
+ PropertyName: discriminator,
+ Mapping: mapping,
+ },
+ }
+ }
+ return &huma.Schema{Ref: schemaRefPrefix + name}
+}
+
+func selectStructuredSchemaFields(source *huma.Schema, variantName string, fieldNames []string) *huma.Schema {
+ selected := &huma.Schema{
+ Type: huma.TypeObject,
+ AdditionalProperties: false,
+ Properties: make(map[string]*huma.Schema, len(fieldNames)),
+ }
+ required := make(map[string]bool, len(source.Required))
+ for _, field := range source.Required {
+ required[field] = true
+ }
+ for _, field := range fieldNames {
+ property, ok := source.Properties[field]
+ if !ok || property == nil {
+ panic(fmt.Sprintf("structured schema variant %s names unknown field %q", variantName, field))
+ }
+ clone := *property
+ selected.Properties[field] = &clone
+ if required[field] {
+ selected.Required = append(selected.Required, field)
+ }
+ }
+ return selected
+}
+
+func constrainStructuredEnvelopeSchema(schema *huma.Schema) {
+ setStructuredSchemaLiteral(schema, "format", "structured")
+ setStructuredSchemaLiteral(schema, "schema_version", sessionStructuredSchemaVersion)
+ requireStructuredSchemaFields(schema, "format", "schema_version", "history", "structured_messages")
+ setStructuredSchemaNonNullable(schema, "history")
+ setStructuredSchemaNonNullable(schema, "structured_messages")
+}
+
+func constrainStructuredSnapshotEnvelopeSchema(schema *huma.Schema) {
+ constrainStructuredEnvelopeSchema(schema)
+ setStructuredSchemaLiteral(schema, "operation", sessionStructuredOperationSnapshot)
+ requireStructuredSchemaFields(schema, "operation")
+ delete(schema.Properties, "reset_reason")
+}
+
+func setStructuredSchemaLiteral(schema *huma.Schema, field, value string) {
+ property, ok := schema.Properties[field]
+ if !ok || property == nil {
+ property = &huma.Schema{Type: huma.TypeString}
+ } else {
+ clone := *property
+ property = &clone
+ }
+ property.Nullable = false
+ property.Enum = nil
+ property.Extensions = map[string]any{"const": value}
+ schema.Properties[field] = property
+}
+
+func setStructuredSchemaNonNullable(schema *huma.Schema, field string) {
+ property, ok := schema.Properties[field]
+ if !ok || property == nil {
+ return
+ }
+ clone := *property
+ clone.Nullable = false
+ schema.Properties[field] = &clone
+}
+
+func requireStructuredSchemaFields(schema *huma.Schema, fields ...string) {
+ seen := make(map[string]bool, len(schema.Required)+len(fields))
+ for _, field := range schema.Required {
+ seen[field] = true
+ }
+ for _, field := range fields {
+ if seen[field] {
+ continue
+ }
+ schema.Required = append(schema.Required, field)
+ seen[field] = true
+ }
+}
+
+func closedStructuredSchemaValue(value string, variants []structuredSchemaVariant) string {
+ for _, variant := range variants {
+ if value == variant.value {
+ return value
+ }
+ }
+ return "unknown"
+}
+
+func sessionStructuredMessageRole(actor worker.Actor) string {
+ return closedStructuredSchemaValue(string(actor), structuredMessageSchemaVariants)
+}
+
+func sessionStructuredMessageStatus(status worker.ResultStatus) string {
+ switch status {
+ case worker.ResultStatusUnknown, worker.ResultStatusFinal, worker.ResultStatusPartial, worker.ResultStatusSuperseded:
+ return string(status)
+ default:
+ return string(worker.ResultStatusUnknown)
+ }
+}
+
+func sessionStructuredBlockType(kind worker.BlockKind) string {
+ return closedStructuredSchemaValue(string(kind), structuredBlockSchemaVariants)
+}
+
+func sessionStructuredToolInputKind(kind string) string {
+ return closedStructuredSchemaValue(kind, structuredToolInputSchemaVariants)
+}
+
+func sessionStructuredToolResultKind(kind string) string {
+ return closedStructuredSchemaValue(kind, structuredToolResultSchemaVariants)
+}
diff --git a/internal/api/session_structured_schema_test.go b/internal/api/session_structured_schema_test.go
new file mode 100644
index 0000000000..f564b165c7
--- /dev/null
+++ b/internal/api/session_structured_schema_test.go
@@ -0,0 +1,687 @@
+package api
+
+import (
+ "encoding/json"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/worker"
+)
+
+func TestSessionTranscriptRuntimeContainerDoesNotCustomizeJSON(t *testing.T) {
+ if _, ok := reflect.TypeOf(sessionTranscriptGetResponse{}).MethodByName("MarshalJSON"); ok {
+ t.Fatal("sessionTranscriptGetResponse defines MarshalJSON; typed control-plane wire types must use ordinary struct fields")
+ }
+
+ structured, err := json.Marshal(sessionTranscriptGetResponse{
+ Format: "structured",
+ StructuredMessages: structuredMessagesField(nil),
+ })
+ if err != nil {
+ t.Fatalf("marshal structured response: %v", err)
+ }
+ if !strings.Contains(string(structured), `"structured_messages":[]`) {
+ t.Fatalf("structured response = %s, want required empty structured_messages array", structured)
+ }
+
+ raw, err := json.Marshal(sessionTranscriptGetResponse{Format: "raw"})
+ if err != nil {
+ t.Fatalf("marshal raw response: %v", err)
+ }
+ if strings.Contains(string(raw), `"structured_messages"`) {
+ t.Fatalf("raw response = %s, want structured_messages omitted", raw)
+ }
+
+ rawWithMessages, err := json.Marshal(sessionTranscriptGetResponse{
+ Format: "raw",
+ Messages: rawMessagesField(nil),
+ })
+ if err != nil {
+ t.Fatalf("marshal raw response with messages field: %v", err)
+ }
+ if !strings.Contains(string(rawWithMessages), `"messages":[]`) {
+ t.Fatalf("raw response = %s, want required empty messages array", rawWithMessages)
+ }
+
+ structuredNoMessages, err := json.Marshal(sessionTranscriptGetResponse{Format: "structured"})
+ if err != nil {
+ t.Fatalf("marshal structured response without messages field: %v", err)
+ }
+ if strings.Contains(string(structuredNoMessages), `"messages"`) {
+ t.Fatalf("structured response = %s, want messages omitted", structuredNoMessages)
+ }
+}
+
+func TestLiveStructuredTranscriptSchemaPublishesNamedDiscriminatedUnions(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+
+ for _, tc := range []struct {
+ name string
+ discriminator string
+ variants map[string]string
+ }{
+ {
+ name: "SessionStructuredMessage",
+ discriminator: "role",
+ variants: map[string]string{
+ "unknown": "SessionStructuredMessageUnknown",
+ "user": "SessionStructuredMessageUser",
+ "assistant": "SessionStructuredMessageAssistant",
+ "system": "SessionStructuredMessageSystem",
+ "tool": "SessionStructuredMessageTool",
+ },
+ },
+ {
+ name: "SessionStructuredBlock",
+ discriminator: "type",
+ variants: map[string]string{
+ "text": "SessionStructuredBlockText",
+ "thinking": "SessionStructuredBlockThinking",
+ "tool_use": "SessionStructuredBlockToolUse",
+ "tool_result": "SessionStructuredBlockToolResult",
+ "interaction": "SessionStructuredBlockInteraction",
+ "image": "SessionStructuredBlockImage",
+ "unknown": "SessionStructuredBlockUnknown",
+ },
+ },
+ {
+ name: "SessionStructuredToolInput",
+ discriminator: "kind",
+ variants: map[string]string{
+ "unknown": "SessionStructuredToolInputUnknown",
+ "command": "SessionStructuredToolInputCommand",
+ "stdin": "SessionStructuredToolInputStdin",
+ "code": "SessionStructuredToolInputCode",
+ "patch": "SessionStructuredToolInputPatch",
+ "write": "SessionStructuredToolInputWrite",
+ "glob": "SessionStructuredToolInputGlob",
+ "fetch": "SessionStructuredToolInputFetch",
+ "search": "SessionStructuredToolInputSearch",
+ "file": "SessionStructuredToolInputFile",
+ "todo": "SessionStructuredToolInputTodo",
+ "plan": "SessionStructuredToolInputPlan",
+ "question": "SessionStructuredToolInputQuestion",
+ "task": "SessionStructuredToolInputTask",
+ "text": "SessionStructuredToolInputText",
+ "arguments": "SessionStructuredToolInputArguments",
+ },
+ },
+ {
+ name: "SessionStructuredToolResult",
+ discriminator: "kind",
+ variants: map[string]string{
+ "unknown": "SessionStructuredToolResultUnknown",
+ "bash": "SessionStructuredToolResultBash",
+ "python": "SessionStructuredToolResultPython",
+ "read": "SessionStructuredToolResultRead",
+ "glob": "SessionStructuredToolResultGlob",
+ "grep": "SessionStructuredToolResultGrep",
+ "search": "SessionStructuredToolResultSearch",
+ "fetch": "SessionStructuredToolResultFetch",
+ "todo": "SessionStructuredToolResultTodo",
+ "plan": "SessionStructuredToolResultPlan",
+ "question": "SessionStructuredToolResultQuestion",
+ "stdin": "SessionStructuredToolResultStdin",
+ "task": "SessionStructuredToolResultTask",
+ "write": "SessionStructuredToolResultWrite",
+ "edit": "SessionStructuredToolResultEdit",
+ "text": "SessionStructuredToolResultText",
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assertStructuredDiscriminatedUnion(t, schemas, tc.name, tc.discriminator, tc.variants)
+ })
+ }
+}
+
+func TestLiveStructuredTranscriptSchemaRequiresNonNullHistoryMessagesAndBlocks(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+
+ for _, schemaName := range []string{
+ "SessionStreamStructuredMessageEvent",
+ "SessionTranscriptStructuredResponse",
+ } {
+ schema, ok := schemas[schemaName]
+ if !ok {
+ t.Fatalf("components.schemas missing %s", schemaName)
+ }
+ assertRequiredFields(t, schemaName, "structured", schema, []string{
+ "format", "schema_version", "history", "structured_messages",
+ })
+ properties := structuredSchemaProperties(t, schemaName, schema)
+ assertSchemaLiteral(t, schemaName+".format", properties["format"], "structured")
+ assertSchemaLiteral(t, schemaName+".schema_version", properties["schema_version"], sessionStructuredSchemaVersion)
+ assertNonNullableRef(t, schemaName+".history", properties["history"], "#/components/schemas/SessionStructuredHistory")
+ assertNonNullableArrayRef(t, schemaName+".structured_messages", properties["structured_messages"], "#/components/schemas/SessionStructuredMessage")
+ }
+
+ messageUnion := schemas["SessionStructuredMessage"]
+ discriminator := structuredDiscriminatorMapping(t, "SessionStructuredMessage", messageUnion, "role")
+ for role, ref := range discriminator {
+ variant := schemaByRef(t, schemas, ref)
+ assertRequiredFields(t, "SessionStructuredMessage", role, variant, []string{"id", "role", "status", "blocks"})
+ properties := structuredSchemaProperties(t, ref, variant)
+ status, ok := properties["status"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s.status schema = %#v, want object", ref, properties["status"])
+ }
+ if got := status["enum"]; !reflect.DeepEqual(got, []any{"unknown", "final", "partial", "superseded"}) {
+ t.Fatalf("%s.status enum = %#v, want closed result-status vocabulary", ref, got)
+ }
+ assertNonNullableArrayRef(t, ref+".blocks", properties["blocks"], "#/components/schemas/SessionStructuredBlock")
+ for _, excluded := range []string{"is_subagent", "parent_tool_call_id"} {
+ if _, ok := properties[excluded]; ok {
+ t.Fatalf("%s exposes out-of-scope v1 field %q", ref, excluded)
+ }
+ }
+ }
+}
+
+func TestLiveStructuredTranscriptSchemaClosesToolErrorCategory(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+ schema, ok := schemas["SessionStructuredToolError"]
+ if !ok {
+ t.Fatal("components.schemas missing SessionStructuredToolError")
+ }
+ assertRequiredFields(t, "SessionStructuredToolError", "error", schema, []string{"category"})
+ properties := structuredSchemaProperties(t, "SessionStructuredToolError", schema)
+ category, ok := properties["category"].(map[string]any)
+ if !ok {
+ t.Fatalf("SessionStructuredToolError.category schema = %#v, want object", properties["category"])
+ }
+ want := []any{
+ "user_rejection",
+ "user_rejection_with_reason",
+ "command_failure",
+ "file_error",
+ "validation_error",
+ "timeout",
+ "network_error",
+ "unknown",
+ }
+ if got := category["enum"]; !reflect.DeepEqual(got, want) {
+ t.Fatalf("SessionStructuredToolError.category enum = %#v, want %#v", got, want)
+ }
+}
+
+func TestLiveStructuredTranscriptSchemaPinsRESTToSnapshotOperation(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+ schema, ok := schemas["SessionTranscriptStructuredResponse"]
+ if !ok {
+ t.Fatal("components.schemas missing SessionTranscriptStructuredResponse")
+ }
+
+ assertRequiredFields(t, "SessionTranscriptStructuredResponse", "structured", schema, []string{"operation"})
+ properties := structuredSchemaProperties(t, "SessionTranscriptStructuredResponse", schema)
+ assertSchemaLiteral(t, "SessionTranscriptStructuredResponse.operation", properties["operation"], sessionStructuredOperationSnapshot)
+ if _, ok := properties["reset_reason"]; ok {
+ t.Fatal("SessionTranscriptStructuredResponse exposes reset_reason; REST structured transcripts are always snapshots")
+ }
+}
+
+func TestLiveStructuredStreamSchemaDocumentsResetReasonCondition(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+ schema, ok := schemas["SessionStreamStructuredMessageEvent"]
+ if !ok {
+ t.Fatal("components.schemas missing SessionStreamStructuredMessageEvent")
+ }
+
+ assertRequiredFields(t, "SessionStreamStructuredMessageEvent", "structured", schema, []string{"operation"})
+ properties := structuredSchemaProperties(t, "SessionStreamStructuredMessageEvent", schema)
+ operation, ok := properties["operation"].(map[string]any)
+ if !ok {
+ t.Fatalf("SessionStreamStructuredMessageEvent.operation schema = %#v, want object", properties["operation"])
+ }
+ if got := operation["enum"]; !reflect.DeepEqual(got, []any{
+ sessionStructuredOperationSnapshot,
+ sessionStructuredOperationUpsert,
+ sessionStructuredOperationReset,
+ }) {
+ t.Fatalf("SessionStreamStructuredMessageEvent.operation enum = %#v, want closed operation vocabulary", got)
+ }
+ resetReason, ok := properties["reset_reason"].(map[string]any)
+ if !ok {
+ t.Fatalf("SessionStreamStructuredMessageEvent.reset_reason schema = %#v, want object", properties["reset_reason"])
+ }
+ description, _ := resetReason["description"].(string)
+ if !strings.Contains(description, "Present if and only if operation is reset") {
+ t.Fatalf("SessionStreamStructuredMessageEvent.reset_reason description = %q, want conditional presence contract", description)
+ }
+}
+
+func TestLiveStructuredTranscriptSchemaVariantsExcludeImpossibleFields(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+
+ for _, tc := range []struct {
+ name string
+ present []string
+ absent []string
+ }{
+ {name: "SessionStructuredMessageUser", present: []string{"user_prompt"}, absent: []string{"model", "usage", "system_event"}},
+ {name: "SessionStructuredMessageAssistant", present: []string{"model", "usage"}, absent: []string{"user_prompt", "system_event"}},
+ {name: "SessionStructuredBlockText", present: []string{"text"}, absent: []string{"input", "structured", "interaction"}},
+ {name: "SessionStructuredBlockToolResult", present: []string{"structured", "tool_call_id"}, absent: []string{"input", "thinking", "image_url"}},
+ {name: "SessionStructuredToolInputCommand", present: []string{"command"}, absent: []string{"code", "patch", "query", "question"}},
+ {name: "SessionStructuredToolInputQuestion", present: []string{"question", "options"}, absent: []string{"command", "code", "patch"}},
+ {name: "SessionStructuredToolResultRead", present: []string{"file_path", "content"}, absent: []string{"stdout", "patch", "questions"}},
+ {name: "SessionStructuredToolResultBash", present: []string{"stdout", "exit_code"}, absent: []string{"patch", "questions", "result_items"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ schema, ok := schemas[tc.name]
+ if !ok {
+ t.Fatalf("components.schemas missing %s", tc.name)
+ }
+ properties := structuredSchemaProperties(t, tc.name, schema)
+ for _, field := range tc.present {
+ if _, ok := properties[field]; !ok {
+ t.Errorf("%s missing relevant field %q", tc.name, field)
+ }
+ }
+ for _, field := range tc.absent {
+ if _, ok := properties[field]; ok {
+ t.Errorf("%s exposes impossible cross-kind field %q", tc.name, field)
+ }
+ }
+ })
+ }
+}
+
+func TestStructuredProjectionAllocatesRequiredEmptyArraysAndClosesDiscriminators(t *testing.T) {
+ messages, ids := historySnapshotStructuredMessages(nil, false)
+ if messages == nil || ids == nil {
+ t.Fatalf("nil snapshot = messages %#v ids %#v, want allocated empty arrays", messages, ids)
+ }
+
+ messages, _ = historySnapshotStructuredMessages(&worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "entry-1",
+ Actor: worker.Actor("provider-special-role"),
+ Status: worker.ResultStatus("provider-special-status"),
+ }},
+ }, false)
+ if len(messages) != 1 {
+ t.Fatalf("messages = %#v, want one message", messages)
+ }
+ if messages[0].Role != string(worker.ActorUnknown) {
+ t.Fatalf("message role = %q, want closed fallback %q", messages[0].Role, worker.ActorUnknown)
+ }
+ if messages[0].Status != string(worker.ResultStatusUnknown) {
+ t.Fatalf("message status = %q, want closed fallback %q", messages[0].Status, worker.ResultStatusUnknown)
+ }
+ if messages[0].Blocks == nil {
+ t.Fatal("message blocks = nil, want allocated empty array")
+ }
+
+ input := sessionStructuredToolInputFromWorker(&worker.StructuredToolInput{Kind: "provider-special-input"})
+ if input == nil || input.Kind != "unknown" {
+ t.Fatalf("input = %#v, want unknown-kind projection", input)
+ }
+ result := sessionStructuredToolResultFromWorker(&worker.StructuredToolResult{Kind: "provider-special-result"})
+ if result == nil || result.Kind != "unknown" {
+ t.Fatalf("result = %#v, want unknown-kind projection", result)
+ }
+ block := historyBlockToStructuredBlock(worker.HistoryBlock{Kind: worker.BlockKind("provider-special-block")}, false)
+ if block == nil || block.Type != string(worker.BlockKindUnknown) {
+ t.Fatalf("block = %#v, want unknown-type projection", block)
+ }
+
+ wire, err := json.Marshal(messages[0])
+ if err != nil {
+ t.Fatalf("marshal message: %v", err)
+ }
+ if string(wire) == "" || !jsonHasAllocatedArray(t, wire, "blocks") {
+ t.Fatalf("message wire = %s, want blocks:[]", wire)
+ }
+
+ fallback := structuredFallbackMessages("session-1", "pane", "partial pane output")
+ if len(fallback) != 1 || fallback[0].Status != string(worker.ResultStatusPartial) {
+ t.Fatalf("fallback messages = %#v, want one partial result", fallback)
+ }
+}
+
+func TestStructuredProjectionDropsImpossibleCrossVariantFields(t *testing.T) {
+ user := historyEntryToStructuredMessage(worker.HistoryEntry{
+ ID: "user-1",
+ Actor: worker.ActorUser,
+ Status: worker.ResultStatusFinal,
+ Model: "wrong-role-model",
+ Usage: &worker.HistoryUsage{InputTokens: 1},
+ UserPrompt: &worker.HistoryUserPrompt{Text: "hello"},
+ SystemEvent: &worker.HistorySystemEvent{Kind: "wrong-role-event"},
+ }, false)
+ if user.UserPrompt == nil {
+ t.Fatal("user prompt missing from user variant")
+ }
+ if user.Model != "" || user.Usage != nil || user.SystemEvent != nil {
+ t.Fatalf("user variant leaked assistant/system fields: %#v", user)
+ }
+
+ toolUse := historyBlockToStructuredBlock(worker.HistoryBlock{
+ Kind: worker.BlockKindToolUse,
+ Text: "wrong-kind-text",
+ ToolUseID: "tool-1",
+ Name: "Read",
+ ContentText: "wrong-kind-content",
+ }, false)
+ if toolUse == nil || toolUse.ID != "tool-1" {
+ t.Fatalf("tool-use projection = %#v, want typed id", toolUse)
+ }
+ if toolUse.ToolCallID != "" || toolUse.Text != "" || toolUse.Content != "" {
+ t.Fatalf("tool-use variant leaked result/text fields: %#v", toolUse)
+ }
+
+ toolResult := historyBlockToStructuredBlock(worker.HistoryBlock{
+ Kind: worker.BlockKindToolResult,
+ Text: "fallback result",
+ ToolUseID: "tool-1",
+ ContentText: "result content",
+ ImageURL: "wrong-kind-image",
+ }, false)
+ if toolResult == nil || toolResult.ToolCallID != "tool-1" || toolResult.Content != "result content" {
+ t.Fatalf("tool-result projection = %#v, want typed result fields", toolResult)
+ }
+ if toolResult.ID != "" || toolResult.Text != "" || toolResult.ImageURL != "" {
+ t.Fatalf("tool-result variant leaked use/text/image fields: %#v", toolResult)
+ }
+}
+
+func TestStructuredToolProjectionMatchesClosedVariantSchemas(t *testing.T) {
+ schemas := componentSchemas(t, readLiveSupervisorOpenAPISpec(t))
+ falseValue := false
+ exitCode := 7
+
+ input := worker.StructuredToolInput{
+ Text: "text",
+ Command: "command",
+ LinkedCommand: "linked command",
+ Code: "code",
+ Patch: "patch",
+ FilePath: "file.txt",
+ Language: "text",
+ URL: "https://example.com",
+ Prompt: "prompt",
+ TaskID: "task-1",
+ TaskType: "worker",
+ TaskStatus: "completed",
+ Description: "description",
+ Question: "question",
+ Options: []string{"option"},
+ Query: "query",
+ Pattern: "pattern",
+ Plan: "plan",
+ Explanation: "explanation",
+ Steps: []worker.StructuredPlanStep{{Step: "step", Status: "done"}},
+ Todos: []worker.StructuredTodoItem{{ID: "todo-1", Content: "todo"}},
+ Arguments: []worker.StructuredArgument{{Name: "argument", Value: "value"}},
+ }
+ for kind, schemaName := range map[string]string{
+ "unknown": "SessionStructuredToolInputUnknown",
+ "command": "SessionStructuredToolInputCommand",
+ "stdin": "SessionStructuredToolInputStdin",
+ "code": "SessionStructuredToolInputCode",
+ "patch": "SessionStructuredToolInputPatch",
+ "write": "SessionStructuredToolInputWrite",
+ "glob": "SessionStructuredToolInputGlob",
+ "fetch": "SessionStructuredToolInputFetch",
+ "search": "SessionStructuredToolInputSearch",
+ "file": "SessionStructuredToolInputFile",
+ "todo": "SessionStructuredToolInputTodo",
+ "plan": "SessionStructuredToolInputPlan",
+ "question": "SessionStructuredToolInputQuestion",
+ "task": "SessionStructuredToolInputTask",
+ "text": "SessionStructuredToolInputText",
+ "arguments": "SessionStructuredToolInputArguments",
+ } {
+ t.Run("input/"+kind, func(t *testing.T) {
+ input.Kind = kind
+ assertStructuredProjectionKeysMatchSchema(t, schemas, schemaName, sessionStructuredToolInputFromWorker(&input))
+ })
+ }
+
+ result := worker.StructuredToolResult{
+ Text: "text",
+ Command: "command",
+ Stdout: "stdout",
+ Stderr: "stderr",
+ ExitCode: &exitCode,
+ Interrupted: true,
+ Truncated: true,
+ IsImage: true,
+ Mode: "mode",
+ Query: "query",
+ URL: "https://example.com",
+ TaskID: "task-1",
+ TaskType: "worker",
+ TaskStatus: "completed",
+ Description: "description",
+ TotalDurationMs: 1,
+ TotalTokens: 2,
+ TotalToolUseCount: 3,
+ Output: "output",
+ Question: "question",
+ Questions: []worker.StructuredQuestion{{Question: "question"}},
+ Answer: "answer",
+ Options: []string{"option"},
+ Answers: []worker.StructuredArgument{{Name: "answer", Value: "value"}},
+ Counts: []worker.StructuredArgument{{Name: "count", Value: "1"}},
+ StatusCode: 200,
+ StatusText: "OK",
+ Bytes: 4,
+ Filenames: []string{"file.txt"},
+ NumFiles: 1,
+ NumResults: 1,
+ DurationMs: 5,
+ AppliedLimit: 6,
+ StdoutLines: 7,
+ StderrLines: 8,
+ Timestamp: "2026-01-01T00:00:00Z",
+ ResultItems: []worker.StructuredSearchResultItem{{Title: "result"}},
+ Content: "content",
+ NumLines: 9,
+ FilePath: "file.txt",
+ FilePaths: []string{"file.txt"},
+ Language: "text",
+ Code: "code",
+ Plan: "plan",
+ Explanation: "explanation",
+ Steps: []worker.StructuredPlanStep{{Step: "step", Status: "done"}},
+ Patch: "patch",
+ PatchHunks: []worker.StructuredPatchHunk{{FilePath: "file.txt"}},
+ OldString: "old",
+ NewString: "new",
+ OriginalFile: "original",
+ ReplaceAll: &falseValue,
+ UserModified: &falseValue,
+ OldTodos: []worker.StructuredTodoItem{{ID: "old"}},
+ NewTodos: []worker.StructuredTodoItem{{ID: "new"}},
+ StartLine: 10,
+ TotalLines: 11,
+ Error: &worker.StructuredToolError{Category: "unknown", Message: "error"},
+ }
+ for kind, schemaName := range map[string]string{
+ "unknown": "SessionStructuredToolResultUnknown",
+ "bash": "SessionStructuredToolResultBash",
+ "python": "SessionStructuredToolResultPython",
+ "read": "SessionStructuredToolResultRead",
+ "glob": "SessionStructuredToolResultGlob",
+ "grep": "SessionStructuredToolResultGrep",
+ "search": "SessionStructuredToolResultSearch",
+ "fetch": "SessionStructuredToolResultFetch",
+ "todo": "SessionStructuredToolResultTodo",
+ "plan": "SessionStructuredToolResultPlan",
+ "question": "SessionStructuredToolResultQuestion",
+ "stdin": "SessionStructuredToolResultStdin",
+ "task": "SessionStructuredToolResultTask",
+ "write": "SessionStructuredToolResultWrite",
+ "edit": "SessionStructuredToolResultEdit",
+ "text": "SessionStructuredToolResultText",
+ } {
+ t.Run("result/"+kind, func(t *testing.T) {
+ result.Kind = kind
+ assertStructuredProjectionKeysMatchSchema(t, schemas, schemaName, sessionStructuredToolResultFromWorker(&result))
+ })
+ }
+}
+
+func TestStructuredSearchInputProjectionOmitsOtherVariantFields(t *testing.T) {
+ projected := sessionStructuredToolInputFromWorker(&worker.StructuredToolInput{
+ Kind: "search",
+ Query: "structured transcripts",
+ URL: "https://provider.example/search",
+ TaskID: "provider-task-1",
+ Description: "provider search metadata",
+ })
+ if projected == nil {
+ t.Fatal("search input projection is nil")
+ }
+ if projected.Kind != "search" || projected.Query != "structured transcripts" {
+ t.Fatalf("search input projection = %#v, want typed search query", projected)
+ }
+ if projected.URL != "" || projected.TaskID != "" || projected.Description != "" {
+ t.Fatalf("search input projection leaked fetch/task fields: %#v", projected)
+ }
+}
+
+func assertStructuredProjectionKeysMatchSchema(t *testing.T, schemas map[string]map[string]any, schemaName string, projection any) {
+ t.Helper()
+ wire, err := json.Marshal(projection)
+ if err != nil {
+ t.Fatalf("marshal projection: %v", err)
+ }
+ var object map[string]any
+ if err := json.Unmarshal(wire, &object); err != nil {
+ t.Fatalf("decode projection: %v", err)
+ }
+ schema, ok := schemas[schemaName]
+ if !ok {
+ t.Fatalf("components.schemas missing %s", schemaName)
+ }
+ properties := structuredSchemaProperties(t, schemaName, schema)
+ for key := range object {
+ if _, ok := properties[key]; !ok {
+ t.Errorf("%s runtime projection emits schema-forbidden field %q: %s", schemaName, key, wire)
+ }
+ }
+ for key := range properties {
+ if _, ok := object[key]; !ok {
+ t.Errorf("%s schema field %q is not projected from a populated neutral source: %s", schemaName, key, wire)
+ }
+ }
+}
+
+func assertStructuredDiscriminatedUnion(t *testing.T, schemas map[string]map[string]any, name, property string, variants map[string]string) {
+ t.Helper()
+ union, ok := schemas[name]
+ if !ok {
+ t.Fatalf("components.schemas missing %s", name)
+ }
+ oneOf, ok := union["oneOf"].([]any)
+ if !ok || len(oneOf) != len(variants) {
+ t.Fatalf("%s oneOf = %#v, want %d named variants", name, union["oneOf"], len(variants))
+ }
+ mapping := structuredDiscriminatorMapping(t, name, union, property)
+ if len(mapping) != len(variants) {
+ t.Fatalf("%s discriminator mapping has %d entries, want %d", name, len(mapping), len(variants))
+ }
+ for value, variantName := range variants {
+ wantRef := "#/components/schemas/" + variantName
+ if got := mapping[value]; got != wantRef {
+ t.Fatalf("%s mapping[%q] = %q, want %q", name, value, got, wantRef)
+ }
+ variant := schemaByRef(t, schemas, wantRef)
+ properties := structuredSchemaProperties(t, variantName, variant)
+ assertSchemaLiteral(t, variantName+"."+property, properties[property], value)
+ assertRequiredFields(t, name, value, variant, []string{property})
+ }
+}
+
+func structuredDiscriminatorMapping(t *testing.T, name string, union map[string]any, property string) map[string]string {
+ t.Helper()
+ raw, ok := union["discriminator"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s discriminator missing: %#v", name, union)
+ }
+ if got, _ := raw["propertyName"].(string); got != property {
+ t.Fatalf("%s discriminator property = %q, want %q", name, got, property)
+ }
+ rawMapping, ok := raw["mapping"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s discriminator mapping missing: %#v", name, raw)
+ }
+ mapping := make(map[string]string, len(rawMapping))
+ for value, rawRef := range rawMapping {
+ ref, ok := rawRef.(string)
+ if !ok {
+ t.Fatalf("%s discriminator mapping[%q] is not a string: %#v", name, value, rawRef)
+ }
+ mapping[value] = ref
+ }
+ return mapping
+}
+
+func structuredSchemaProperties(t *testing.T, name string, schema map[string]any) map[string]any {
+ t.Helper()
+ properties, ok := schema["properties"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s properties missing: %#v", name, schema)
+ }
+ return properties
+}
+
+func assertSchemaLiteral(t *testing.T, name string, raw any, want string) {
+ t.Helper()
+ schema, ok := raw.(map[string]any)
+ if !ok {
+ t.Fatalf("%s schema missing: %#v", name, raw)
+ }
+ if got, _ := schema["const"].(string); got != want {
+ t.Fatalf("%s const = %q, want %q; schema=%#v", name, got, want, schema)
+ }
+}
+
+func assertNonNullableRef(t *testing.T, name string, raw any, wantRef string) {
+ t.Helper()
+ schema, ok := raw.(map[string]any)
+ if !ok {
+ t.Fatalf("%s schema missing: %#v", name, raw)
+ }
+ if got, _ := schema["$ref"].(string); got != wantRef {
+ t.Fatalf("%s ref = %q, want %q; schema=%#v", name, got, wantRef, schema)
+ }
+ if nullable, _ := schema["nullable"].(bool); nullable {
+ t.Fatalf("%s is nullable: %#v", name, schema)
+ }
+}
+
+func assertNonNullableArrayRef(t *testing.T, name string, raw any, wantItemRef string) {
+ t.Helper()
+ schema, ok := raw.(map[string]any)
+ if !ok {
+ t.Fatalf("%s schema missing: %#v", name, raw)
+ }
+ if got, _ := schema["type"].(string); got != "array" {
+ t.Fatalf("%s type = %q, want array; schema=%#v", name, got, schema)
+ }
+ if nullable, _ := schema["nullable"].(bool); nullable {
+ t.Fatalf("%s is nullable: %#v", name, schema)
+ }
+ items, ok := schema["items"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s items missing: %#v", name, schema)
+ }
+ if got, _ := items["$ref"].(string); got != wantItemRef {
+ t.Fatalf("%s item ref = %q, want %q; schema=%#v", name, got, wantItemRef, schema)
+ }
+}
+
+func jsonHasAllocatedArray(t *testing.T, wire []byte, field string) bool {
+ t.Helper()
+ var object map[string]any
+ if err := json.Unmarshal(wire, &object); err != nil {
+ t.Fatalf("decode JSON: %v", err)
+ }
+ array, ok := object[field].([]any)
+ return ok && len(array) == 0
+}
diff --git a/internal/api/session_structured_stream.go b/internal/api/session_structured_stream.go
new file mode 100644
index 0000000000..46eae9b54f
--- /dev/null
+++ b/internal/api/session_structured_stream.go
@@ -0,0 +1,283 @@
+package api
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "strings"
+)
+
+const (
+ sessionStructuredOperationSnapshot = "snapshot"
+ sessionStructuredOperationUpsert = "upsert"
+ sessionStructuredOperationReset = "reset"
+
+ sessionStructuredResetResumeInvalid = "resume_invalid"
+ sessionStructuredResetStreamChanged = "stream_changed"
+ sessionStructuredResetCursorInvalidated = "cursor_invalidated"
+ sessionStructuredResetHistoryRewritten = "history_rewritten"
+
+ sessionStructuredResumeTokenPrefix = "st1."
+ sessionStructuredResumeTokenMaxLen = 2048
+)
+
+type sessionStructuredResumeTokenV1 struct {
+ Version int `json:"v"`
+ StreamSHA256 string `json:"stream_sha256"`
+ AfterEntryID string `json:"after_entry_id,omitempty"`
+ MessageCount int `json:"message_count"`
+ PrefixSHA256 string `json:"prefix_sha256"`
+ ProjectionSHA256 string `json:"projection_sha256"`
+ IncludeThinking bool `json:"include_thinking"`
+ SuffixWindow bool `json:"suffix_window,omitempty"`
+}
+
+// buildStructuredStreamUpdate compares an opaque client resume token with the
+// current authoritative projection. A nil result means the client already has
+// this exact projection. Upserts replay the previous mutable tail inclusively,
+// so a partial message can become final without changing its stable ID.
+func buildStructuredStreamUpdate(resumeToken string, projection SessionStreamStructuredMessageEvent, includeThinking bool) *SessionStreamStructuredMessageEvent {
+ currentToken := structuredResumeToken(projection, includeThinking)
+ currentEncoded := encodeStructuredResumeToken(currentToken)
+ current := cloneStructuredStreamProjection(projection, currentEncoded)
+
+ if strings.TrimSpace(resumeToken) == "" {
+ current.Operation = sessionStructuredOperationSnapshot
+ return ¤t
+ }
+
+ previous, ok := decodeStructuredResumeToken(resumeToken)
+ if !ok || previous.IncludeThinking != includeThinking {
+ return structuredResetUpdate(current, sessionStructuredResetResumeInvalid)
+ }
+ if previous.StreamSHA256 != currentToken.StreamSHA256 {
+ return structuredResetUpdate(current, sessionStructuredResetStreamChanged)
+ }
+ if previous.SuffixWindow {
+ windowStart, cursorIndex, resetReason := structuredSuffixWindowRange(previous, current.StructuredMessages)
+ if resetReason != "" {
+ return structuredResetUpdate(current, resetReason)
+ }
+ if previous.MessageCount > 0 && cursorIndex == len(current.StructuredMessages)-1 {
+ window := projection
+ window.StructuredMessages = append([]SessionStructuredMessage(nil), projection.StructuredMessages[windowStart:cursorIndex+1]...)
+ window.Pagination = nil
+ if previous.ProjectionSHA256 == hashStructuredProjection(window, includeThinking) {
+ return nil
+ }
+ }
+
+ current.Operation = sessionStructuredOperationUpsert
+ current.StructuredMessages = append([]SessionStructuredMessage(nil), current.StructuredMessages[cursorIndex:]...)
+ return ¤t
+ }
+ if previous.ProjectionSHA256 == currentToken.ProjectionSHA256 {
+ return nil
+ }
+ if previous.MessageCount > len(current.StructuredMessages) {
+ return structuredResetUpdate(current, sessionStructuredResetCursorInvalidated)
+ }
+ if previous.MessageCount > 0 {
+ cursorIndex := previous.MessageCount - 1
+ if current.StructuredMessages[cursorIndex].ID != previous.AfterEntryID {
+ return structuredResetUpdate(current, sessionStructuredResetCursorInvalidated)
+ }
+ if hashStructuredMessages(current.StructuredMessages[:cursorIndex]) != previous.PrefixSHA256 {
+ return structuredResetUpdate(current, sessionStructuredResetHistoryRewritten)
+ }
+ }
+
+ start := 0
+ if previous.MessageCount > 0 {
+ start = previous.MessageCount - 1
+ }
+ current.Operation = sessionStructuredOperationUpsert
+ current.StructuredMessages = append([]SessionStructuredMessage(nil), current.StructuredMessages[start:]...)
+ if current.StructuredMessages == nil {
+ current.StructuredMessages = []SessionStructuredMessage{}
+ }
+ return ¤t
+}
+
+func structuredSuffixWindowRange(token sessionStructuredResumeTokenV1, messages []SessionStructuredMessage) (int, int, string) {
+ cursorIndex := -1
+ for i := range messages {
+ if messages[i].ID == token.AfterEntryID {
+ cursorIndex = i
+ break
+ }
+ }
+ if cursorIndex < 0 {
+ return 0, 0, sessionStructuredResetCursorInvalidated
+ }
+ windowStart := cursorIndex + 1
+ prefixEnd := windowStart
+ if token.MessageCount > 0 {
+ windowStart = cursorIndex - token.MessageCount + 1
+ prefixEnd = cursorIndex
+ }
+ if windowStart < 0 {
+ return 0, 0, sessionStructuredResetCursorInvalidated
+ }
+ if hashStructuredMessages(messages[windowStart:prefixEnd]) != token.PrefixSHA256 {
+ return 0, 0, sessionStructuredResetHistoryRewritten
+ }
+ return windowStart, cursorIndex, ""
+}
+
+func structuredSnapshotProjection(projection SessionStreamStructuredMessageEvent, includeThinking bool) SessionStreamStructuredMessageEvent {
+ return *buildStructuredStreamUpdate("", projection, includeThinking)
+}
+
+func structuredTranscriptResponseFromEvent(event SessionStreamStructuredMessageEvent) sessionTranscriptGetResponse {
+ return sessionTranscriptGetResponse{
+ ID: event.ID,
+ Template: event.Template,
+ Provider: event.Provider,
+ Format: event.Format,
+ SchemaVersion: event.SchemaVersion,
+ Operation: event.Operation,
+ ResetReason: event.ResetReason,
+ History: event.History,
+ StructuredMessages: structuredMessagesField(event.StructuredMessages),
+ Pagination: event.Pagination,
+ }
+}
+
+func structuredResetUpdate(current SessionStreamStructuredMessageEvent, reason string) *SessionStreamStructuredMessageEvent {
+ current.Operation = sessionStructuredOperationReset
+ current.ResetReason = reason
+ current.StructuredMessages = nonNilStructuredMessages(current.StructuredMessages)
+ return ¤t
+}
+
+func cloneStructuredStreamProjection(projection SessionStreamStructuredMessageEvent, resumeToken string) SessionStreamStructuredMessageEvent {
+ projection.Operation = ""
+ projection.ResetReason = ""
+ projection.StructuredMessages = append([]SessionStructuredMessage(nil), projection.StructuredMessages...)
+ if projection.StructuredMessages == nil {
+ projection.StructuredMessages = []SessionStructuredMessage{}
+ }
+ if projection.History == nil {
+ projection.History = &SessionStructuredHistory{}
+ } else {
+ history := *projection.History
+ projection.History = &history
+ }
+ projection.History.Cursor.ResumeToken = resumeToken
+ return projection
+}
+
+func structuredResumeToken(projection SessionStreamStructuredMessageEvent, includeThinking bool) sessionStructuredResumeTokenV1 {
+ messages := nonNilStructuredMessages(projection.StructuredMessages)
+ suffixWindow := projection.Pagination != nil && projection.Pagination.HasOlderMessages
+ afterEntryID := ""
+ if len(messages) > 0 {
+ afterEntryID = messages[len(messages)-1].ID
+ } else if suffixWindow && projection.History != nil {
+ afterEntryID = projection.History.Cursor.AfterEntryID
+ }
+ prefixEnd := len(messages)
+ if prefixEnd > 0 {
+ prefixEnd--
+ }
+ projectionForHash := projection
+ if suffixWindow {
+ projectionForHash.Pagination = nil
+ }
+ return sessionStructuredResumeTokenV1{
+ Version: 1,
+ StreamSHA256: structuredStreamIdentityHash(projection.History),
+ AfterEntryID: afterEntryID,
+ MessageCount: len(messages),
+ PrefixSHA256: hashStructuredMessages(messages[:prefixEnd]),
+ ProjectionSHA256: hashStructuredProjection(projectionForHash, includeThinking),
+ IncludeThinking: includeThinking,
+ SuffixWindow: suffixWindow,
+ }
+}
+
+func structuredStreamIdentityHash(history *SessionStructuredHistory) string {
+ if history == nil {
+ return sha256Hex(nil)
+ }
+ identity := history.TranscriptStreamID + "\x00" + history.ProviderSessionID + "\x00" + history.LogicalConversationID
+ return sha256Hex([]byte(identity))
+}
+
+func hashStructuredProjection(projection SessionStreamStructuredMessageEvent, includeThinking bool) string {
+ projection.Operation = ""
+ projection.ResetReason = ""
+ projection.StructuredMessages = nonNilStructuredMessages(projection.StructuredMessages)
+ if projection.History != nil {
+ history := *projection.History
+ history.Cursor.ResumeToken = ""
+ // Generation currently carries file observation evidence (mtime:size),
+ // which changes on an ordinary append. It is not transcript identity.
+ history.Generation = SessionStructuredGeneration{}
+ projection.History = &history
+ }
+ digestInput := struct {
+ Projection SessionStreamStructuredMessageEvent `json:"projection"`
+ IncludeThinking bool `json:"include_thinking"`
+ }{Projection: projection, IncludeThinking: includeThinking}
+ data, err := json.Marshal(digestInput)
+ if err != nil {
+ return sha256Hex(nil)
+ }
+ return sha256Hex(data)
+}
+
+func hashStructuredMessages(messages []SessionStructuredMessage) string {
+ data, err := json.Marshal(nonNilStructuredMessages(messages))
+ if err != nil {
+ return sha256Hex(nil)
+ }
+ return sha256Hex(data)
+}
+
+func sha256Hex(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func encodeStructuredResumeToken(token sessionStructuredResumeTokenV1) string {
+ data, err := json.Marshal(token)
+ if err != nil {
+ return ""
+ }
+ return sessionStructuredResumeTokenPrefix + base64.RawURLEncoding.EncodeToString(data)
+}
+
+func decodeStructuredResumeToken(encoded string) (sessionStructuredResumeTokenV1, bool) {
+ var token sessionStructuredResumeTokenV1
+ encoded = strings.TrimSpace(encoded)
+ if len(encoded) > sessionStructuredResumeTokenMaxLen || !strings.HasPrefix(encoded, sessionStructuredResumeTokenPrefix) {
+ return token, false
+ }
+ data, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encoded, sessionStructuredResumeTokenPrefix))
+ if err != nil {
+ return token, false
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&token); err != nil {
+ return token, false
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ return token, false
+ }
+ if token.Version != 1 || token.MessageCount < 0 || token.StreamSHA256 == "" || token.PrefixSHA256 == "" || token.ProjectionSHA256 == "" {
+ return token, false
+ }
+ if token.SuffixWindow && token.AfterEntryID == "" {
+ return token, false
+ }
+ if !token.SuffixWindow && (token.MessageCount == 0) != (token.AfterEntryID == "") {
+ return token, false
+ }
+ return token, true
+}
diff --git a/internal/api/session_structured_stream_test.go b/internal/api/session_structured_stream_test.go
new file mode 100644
index 0000000000..b585b4d660
--- /dev/null
+++ b/internal/api/session_structured_stream_test.go
@@ -0,0 +1,287 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/sessionlog"
+)
+
+func TestBuildStructuredStreamUpdateStartsWithSnapshot(t *testing.T) {
+ projection := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "partial"))
+
+ update := buildStructuredStreamUpdate("", projection, false)
+ if update == nil {
+ t.Fatal("update = nil, want initial snapshot")
+ }
+ if update.Operation != sessionStructuredOperationSnapshot {
+ t.Fatalf("operation = %q, want %q", update.Operation, sessionStructuredOperationSnapshot)
+ }
+ if update.ResetReason != "" {
+ t.Fatalf("reset_reason = %q, want empty", update.ResetReason)
+ }
+ if update.History == nil || update.History.Cursor.ResumeToken == "" {
+ t.Fatalf("history cursor = %+v, want resume token", update.History)
+ }
+ if len(update.StructuredMessages) != 1 || update.StructuredMessages[0].ID != "m1" {
+ t.Fatalf("messages = %+v, want full snapshot", update.StructuredMessages)
+ }
+}
+
+func TestBuildStructuredStreamUpdateSuppressesExactResume(t *testing.T) {
+ projection := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+ initial := buildStructuredStreamUpdate("", projection, false)
+
+ if got := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, projection, false); got != nil {
+ t.Fatalf("exact resume update = %+v, want nil", got)
+ }
+}
+
+func TestBuildStructuredStreamUpdateEmitsInclusiveTailUpsert(t *testing.T) {
+ previous := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("m1", "final"),
+ testStructuredMessage("m2", "partial"),
+ )
+ initial := buildStructuredStreamUpdate("", previous, false)
+ current := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("m1", "final"),
+ testStructuredMessage("m2", "final"),
+ testStructuredMessage("m3", "partial"),
+ )
+
+ update := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, current, false)
+ if update == nil {
+ t.Fatal("update = nil, want upsert")
+ }
+ if update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("operation = %q, want %q", update.Operation, sessionStructuredOperationUpsert)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, []string{"m2", "m3"}) {
+ t.Fatalf("upsert message IDs = %v, want [m2 m3]", got)
+ }
+}
+
+func TestBuildStructuredStreamUpdateEmitsSameIDFinalization(t *testing.T) {
+ previous := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "partial"))
+ initial := buildStructuredStreamUpdate("", previous, false)
+ current := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+
+ update := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, current, false)
+ if update == nil || update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("update = %+v, want upsert", update)
+ }
+ if len(update.StructuredMessages) != 1 || update.StructuredMessages[0].Status != "final" {
+ t.Fatalf("messages = %+v, want finalized m1", update.StructuredMessages)
+ }
+}
+
+func TestBuildStructuredStreamUpdateResetsOnInvalidResume(t *testing.T) {
+ projection := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+
+ update := buildStructuredStreamUpdate("not-a-token", projection, false)
+ assertStructuredReset(t, update, sessionStructuredResetResumeInvalid, []string{"m1"})
+}
+
+func TestBuildStructuredStreamUpdateResetsOnStreamChange(t *testing.T) {
+ previous := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+ initial := buildStructuredStreamUpdate("", previous, false)
+ current := testStructuredStreamProjection("stream-b", testStructuredMessage("n1", "final"))
+
+ update := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, current, false)
+ assertStructuredReset(t, update, sessionStructuredResetStreamChanged, []string{"n1"})
+}
+
+func TestBuildStructuredStreamUpdateResetsOnCursorInvalidationIncludingEmptyReplacement(t *testing.T) {
+ previous := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+ initial := buildStructuredStreamUpdate("", previous, false)
+ current := testStructuredStreamProjection("stream-a")
+
+ update := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, current, false)
+ assertStructuredReset(t, update, sessionStructuredResetCursorInvalidated, []string{})
+ if update.StructuredMessages == nil {
+ t.Fatal("reset messages = nil, want non-nil empty replacement")
+ }
+}
+
+func TestBuildStructuredStreamUpdateResetsOnHistoryRewrite(t *testing.T) {
+ previous := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("m1", "final"),
+ testStructuredMessage("m2", "partial"),
+ )
+ initial := buildStructuredStreamUpdate("", previous, false)
+ current := testStructuredStreamProjection("stream-a",
+ testStructuredMessageWithText("m1", "final", "rewritten"),
+ testStructuredMessage("m2", "final"),
+ )
+
+ update := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, current, false)
+ assertStructuredReset(t, update, sessionStructuredResetHistoryRewritten, []string{"m1", "m2"})
+}
+
+func TestBuildStructuredStreamUpdateIgnoresObservationGenerationChanges(t *testing.T) {
+ previous := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+ previous.History.Generation = SessionStructuredGeneration{ID: "1:10", ObservedAt: "2026-01-01T00:00:00Z"}
+ initial := buildStructuredStreamUpdate("", previous, false)
+ current := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+ current.History.Generation = SessionStructuredGeneration{ID: "2:20", ObservedAt: "2026-01-02T00:00:00Z"}
+
+ if got := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, current, false); got != nil {
+ t.Fatalf("generation-only update = %+v, want nil", got)
+ }
+}
+
+func TestBuildStructuredStreamUpdateRejectsThinkingModeTokenReuse(t *testing.T) {
+ projection := testStructuredStreamProjection("stream-a", testStructuredMessage("m1", "final"))
+ initial := buildStructuredStreamUpdate("", projection, false)
+
+ update := buildStructuredStreamUpdate(initial.History.Cursor.ResumeToken, projection, true)
+ assertStructuredReset(t, update, sessionStructuredResetResumeInvalid, []string{"m1"})
+}
+
+func TestBuildStructuredStreamUpdateResetsWhenEmptySuffixAnchorDisappears(t *testing.T) {
+ page := testStructuredStreamProjection("stream-a")
+ page.History.Cursor.AfterEntryID = "m4"
+ page.Pagination = &sessionlog.PaginationInfo{
+ HasOlderMessages: true,
+ TotalMessageCount: 4,
+ ReturnedMessageCount: 0,
+ }
+ snapshot := structuredSnapshotProjection(page, false)
+
+ rewritten := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("x", "final"),
+ testStructuredMessage("y", "final"),
+ )
+ update := buildStructuredStreamUpdate(snapshot.History.Cursor.ResumeToken, rewritten, false)
+ assertStructuredReset(t, update, sessionStructuredResetCursorInvalidated, []string{"x", "y"})
+}
+
+func TestBuildStructuredStreamUpdateReplaysEmptySuffixAnchorInclusively(t *testing.T) {
+ page := testStructuredStreamProjection("stream-a")
+ page.History.Cursor.AfterEntryID = "m4"
+ page.Pagination = &sessionlog.PaginationInfo{
+ HasOlderMessages: true,
+ TotalMessageCount: 4,
+ ReturnedMessageCount: 0,
+ }
+ snapshot := structuredSnapshotProjection(page, false)
+
+ current := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("m1", "final"),
+ testStructuredMessage("m2", "final"),
+ testStructuredMessage("m3", "final"),
+ testStructuredMessageWithText("m4", "final", "rewritten anchor"),
+ )
+ current.History.Cursor.AfterEntryID = "m4"
+ update := buildStructuredStreamUpdate(snapshot.History.Cursor.ResumeToken, current, false)
+ if update == nil || update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("update = %+v, want bounded anchor upsert", update)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, []string{"m4"}) {
+ t.Fatalf("upsert IDs = %v, want [m4]", got)
+ }
+ if got := update.StructuredMessages[0].Blocks[0].Text; got != "rewritten anchor" {
+ t.Fatalf("anchor text = %q, want rewritten anchor", got)
+ }
+}
+
+func TestBuildStructuredStreamUpdateResumesFromInteriorPaginatedWindow(t *testing.T) {
+ page := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("m2", "final"),
+ testStructuredMessage("m3", "final"),
+ )
+ page.History.Cursor.AfterEntryID = "m4"
+ page.Pagination = &sessionlog.PaginationInfo{
+ HasOlderMessages: true,
+ HasNewerMessages: true,
+ TotalMessageCount: 4,
+ ReturnedMessageCount: 2,
+ }
+ snapshot := structuredSnapshotProjection(page, false)
+
+ current := testStructuredStreamProjection("stream-a",
+ testStructuredMessage("m1", "final"),
+ testStructuredMessage("m2", "final"),
+ testStructuredMessage("m3", "final"),
+ testStructuredMessage("m4", "final"),
+ )
+ current.History.Cursor.AfterEntryID = "m4"
+ update := buildStructuredStreamUpdate(snapshot.History.Cursor.ResumeToken, current, false)
+ if update == nil || update.Operation != sessionStructuredOperationUpsert {
+ t.Fatalf("update = %+v, want upsert", update)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, []string{"m3", "m4"}) {
+ t.Fatalf("upsert IDs = %v, want inclusive tail [m3 m4]", got)
+ }
+}
+
+func testStructuredStreamProjection(streamID string, messages ...SessionStructuredMessage) SessionStreamStructuredMessageEvent {
+ return SessionStreamStructuredMessageEvent{
+ ID: "gc-1",
+ Template: "myrig/worker",
+ Provider: "test",
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ History: &SessionStructuredHistory{
+ GCSessionID: "gc-1",
+ ProviderSessionID: streamID + "-provider",
+ TranscriptStreamID: streamID,
+ Generation: SessionStructuredGeneration{ID: "volatile"},
+ Cursor: SessionStructuredCursor{},
+ Continuity: SessionStructuredContinuity{Status: "continuous"},
+ TailState: SessionStructuredTailState{Activity: "idle"},
+ },
+ StructuredMessages: messages,
+ }
+}
+
+func testStructuredMessage(id, status string) SessionStructuredMessage {
+ return testStructuredMessageWithText(id, status, id+" text")
+}
+
+func testStructuredMessageWithText(id, status, text string) SessionStructuredMessage {
+ return SessionStructuredMessage{
+ ID: id,
+ Role: "assistant",
+ Status: status,
+ Blocks: []SessionStructuredBlock{{Type: "text", Text: text}},
+ }
+}
+
+func structuredMessageIDs(messages []SessionStructuredMessage) []string {
+ ids := make([]string, 0, len(messages))
+ for _, message := range messages {
+ ids = append(ids, message.ID)
+ }
+ return ids
+}
+
+func assertStructuredReset(t *testing.T, update *SessionStreamStructuredMessageEvent, reason string, wantIDs []string) {
+ t.Helper()
+ if update == nil {
+ t.Fatal("update = nil, want reset")
+ }
+ if update.Operation != sessionStructuredOperationReset {
+ t.Fatalf("operation = %q, want %q", update.Operation, sessionStructuredOperationReset)
+ }
+ if update.ResetReason != reason {
+ t.Fatalf("reset_reason = %q, want %q", update.ResetReason, reason)
+ }
+ if got := structuredMessageIDs(update.StructuredMessages); !equalStrings(got, wantIDs) {
+ t.Fatalf("reset message IDs = %v, want %v", got, wantIDs)
+ }
+ if update.History == nil || update.History.Cursor.ResumeToken == "" {
+ t.Fatalf("history cursor = %+v, want replacement resume token", update.History)
+ }
+}
+
+func equalStrings(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/api/session_structured_types.go b/internal/api/session_structured_types.go
new file mode 100644
index 0000000000..b59b648ac9
--- /dev/null
+++ b/internal/api/session_structured_types.go
@@ -0,0 +1,970 @@
+package api
+
+import (
+ "strings"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/sessionlog"
+ "github.com/gastownhall/gascity/internal/worker"
+)
+
+const sessionStructuredSchemaVersion = "session.structured.v1"
+
+const (
+ structuredTranscriptUnavailableCode = "transcript_unavailable"
+ structuredTranscriptUnavailableMessage = "provider transcript is unavailable; using provider-neutral text fallback"
+)
+
+// SessionStreamStructuredMessageEvent carries provider-normalized structured
+// transcript messages on the session SSE stream.
+type SessionStreamStructuredMessageEvent struct {
+ ID string `json:"id"`
+ Template string `json:"template"`
+ Provider string `json:"provider" doc:"Producing provider identifier (claude, codex, gemini, opencode, etc.)."`
+ Format string `json:"format" enum:"structured" doc:"Always structured for this event."`
+ SchemaVersion string `json:"schema_version" enum:"session.structured.v1" doc:"Structured session transcript schema version."`
+ Operation string `json:"operation" enum:"snapshot,upsert,reset" doc:"How the client applies this structured frame: replace from a snapshot/reset or merge an upsert."`
+ ResetReason string `json:"reset_reason,omitempty" enum:"resume_invalid,stream_changed,cursor_invalidated,history_rewritten" doc:"Present if and only if operation is reset; absent for snapshot and upsert. Identifies why the reset replaced the client transcript."`
+ History *SessionStructuredHistory `json:"history" doc:"Normalized worker-history envelope for this snapshot or stream batch."`
+ StructuredMessages []SessionStructuredMessage `json:"structured_messages" doc:"Provider-normalized structured messages."`
+ Pagination *sessionlog.PaginationInfo `json:"pagination,omitempty"`
+}
+
+// SessionStructuredHistory is the normalized worker-history envelope projected
+// onto the session transcript API.
+type SessionStructuredHistory struct {
+ GCSessionID string `json:"gc_session_id,omitempty"`
+ LogicalConversationID string `json:"logical_conversation_id,omitempty"`
+ ProviderSessionID string `json:"provider_session_id,omitempty"`
+ TranscriptStreamID string `json:"transcript_stream_id"`
+ Generation SessionStructuredGeneration `json:"generation"`
+ Cursor SessionStructuredCursor `json:"cursor"`
+ Continuity SessionStructuredContinuity `json:"continuity"`
+ TailState SessionStructuredTailState `json:"tail_state"`
+ Diagnostics []SessionStructuredDiagnostic `json:"diagnostics,omitempty"`
+}
+
+// SessionStructuredGeneration identifies a raw transcript stream instance.
+type SessionStructuredGeneration struct {
+ ID string `json:"id"`
+ ObservedAt string `json:"observed_at,omitempty"`
+}
+
+// SessionStructuredCursor identifies the normalized transcript tip.
+type SessionStructuredCursor struct {
+ AfterEntryID string `json:"after_entry_id,omitempty"`
+ ResumeToken string `json:"resume_token" doc:"Opaque cursor for an exact structured REST-to-SSE handoff or SSE reconnect."`
+}
+
+// SessionStructuredContinuity describes compaction/branch evidence.
+type SessionStructuredContinuity struct {
+ Status string `json:"status"`
+ CompactionCount int `json:"compaction_count,omitempty"`
+ HasBranches bool `json:"has_branches,omitempty"`
+ Note string `json:"note,omitempty"`
+}
+
+// SessionStructuredTailState captures the current transcript tail state.
+type SessionStructuredTailState struct {
+ Activity string `json:"activity"`
+ LastEntryID string `json:"last_entry_id,omitempty"`
+ OpenToolCallIDs []string `json:"open_tool_call_ids,omitempty"`
+ PendingInteractionIDs []string `json:"pending_interaction_ids,omitempty"`
+ Degraded bool `json:"degraded,omitempty"`
+ DegradedReason string `json:"degraded_reason,omitempty"`
+}
+
+// SessionStructuredDiagnostic records normalized-history diagnostics.
+type SessionStructuredDiagnostic struct {
+ Code string `json:"code"`
+ Message string `json:"message,omitempty"`
+ Count int `json:"count,omitempty"`
+}
+
+// SessionStructuredMessage is one provider-normalized transcript message.
+type SessionStructuredMessage struct {
+ ID string `json:"id"`
+ Role string `json:"role"`
+ Provider string `json:"provider,omitempty"`
+ Timestamp string `json:"timestamp,omitempty"`
+ Model string `json:"model,omitempty"`
+ StopReason string `json:"stop_reason,omitempty"`
+ Usage *SessionStructuredUsage `json:"usage,omitempty"`
+ UserPrompt *SessionStructuredUserPrompt `json:"user_prompt,omitempty"`
+ SystemEvent *SessionStructuredSystemEvent `json:"system_event,omitempty"`
+ Status string `json:"status" enum:"unknown,final,partial,superseded"`
+ Blocks []SessionStructuredBlock `json:"blocks"`
+}
+
+// SessionStructuredSystemEvent carries provider-neutral system-event metadata
+// extracted from a provider transcript.
+type SessionStructuredSystemEvent struct {
+ Kind string `json:"kind,omitempty"`
+ Category string `json:"category,omitempty"`
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// SessionStructuredUserPrompt carries provider-neutral prompt text and metadata
+// extracted from a user message.
+type SessionStructuredUserPrompt struct {
+ Text string `json:"text,omitempty"`
+ OpenedFiles []string `json:"opened_files,omitempty"`
+ UploadedFiles []SessionStructuredUploadedFile `json:"uploaded_files,omitempty"`
+ Selections []SessionStructuredIDESelection `json:"selections,omitempty"`
+}
+
+// SessionStructuredUploadedFile is one uploaded-file attachment referenced by
+// a user prompt.
+type SessionStructuredUploadedFile struct {
+ OriginalName string `json:"original_name,omitempty"`
+ Size string `json:"size,omitempty"`
+ MIMEType string `json:"mime_type,omitempty"`
+ FilePath string `json:"file_path,omitempty"`
+ PreviewURL string `json:"preview_url,omitempty"`
+}
+
+// SessionStructuredIDESelection is one IDE selection metadata item referenced
+// by a user prompt.
+type SessionStructuredIDESelection struct {
+ Text string `json:"text,omitempty"`
+}
+
+// SessionStructuredUsage is provider-neutral token usage for one structured
+// transcript message.
+type SessionStructuredUsage struct {
+ InputTokens int `json:"input_tokens,omitempty"`
+ OutputTokens int `json:"output_tokens,omitempty"`
+ ReasoningTokens int `json:"reasoning_tokens,omitempty"`
+ CacheReadTokens int `json:"cache_read_tokens,omitempty"`
+ CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
+ ContextWindowTokens int `json:"context_window_tokens,omitempty"`
+ ContextUsedTokens int `json:"context_used_tokens,omitempty"`
+ ContextPercent int `json:"context_percent,omitempty"`
+}
+
+// SessionStructuredBlock is one structured content/tool/interaction block.
+type SessionStructuredBlock struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ Thinking string `json:"thinking,omitempty"`
+ Signature string `json:"signature,omitempty"`
+ ID string `json:"id,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ FilePath string `json:"file_path,omitempty"`
+ ImageURL string `json:"image_url,omitempty"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Input *SessionStructuredToolInput `json:"input,omitempty"`
+ Content string `json:"content,omitempty"`
+ IsError bool `json:"is_error,omitempty"`
+ Structured *SessionStructuredToolResult `json:"structured,omitempty"`
+ Interaction *SessionStructuredInteraction `json:"interaction,omitempty"`
+}
+
+// SessionStructuredToolInput is a provider-neutral projection of a tool call's
+// input. Provider-native input JSON is available only through format=raw.
+type SessionStructuredToolInput struct {
+ Kind string `json:"kind,omitempty" doc:"Provider-neutral input kind such as command, code, patch, glob, fetch, search, file, arguments, or text."`
+ Text string `json:"text,omitempty"`
+ Command string `json:"command,omitempty"`
+ LinkedCommand string `json:"linked_command,omitempty"`
+ Code string `json:"code,omitempty"`
+ Patch string `json:"patch,omitempty"`
+ FilePath string `json:"file_path,omitempty"`
+ Language string `json:"language,omitempty"`
+ URL string `json:"url,omitempty"`
+ Prompt string `json:"prompt,omitempty"`
+ TaskID string `json:"task_id,omitempty"`
+ TaskType string `json:"task_type,omitempty"`
+ TaskStatus string `json:"task_status,omitempty"`
+ Description string `json:"description,omitempty"`
+ Question string `json:"question,omitempty"`
+ Options []string `json:"options,omitempty"`
+ Query string `json:"query,omitempty"`
+ Pattern string `json:"pattern,omitempty"`
+ Plan string `json:"plan,omitempty"`
+ Explanation string `json:"explanation,omitempty"`
+ Steps []SessionStructuredPlanStep `json:"steps,omitempty"`
+ Todos []SessionStructuredTodoItem `json:"todos,omitempty"`
+ Arguments []SessionStructuredArgument `json:"arguments,omitempty"`
+}
+
+// SessionStructuredArgument is one provider-neutral string argument.
+type SessionStructuredArgument struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// SessionStructuredPlanStep is one provider-neutral plan step.
+type SessionStructuredPlanStep struct {
+ Step string `json:"step,omitempty"`
+ Status string `json:"status,omitempty"`
+}
+
+// SessionStructuredToolResult is a typed structured tool-result projection.
+// The Kind field discriminates which fields are populated.
+type SessionStructuredToolResult struct {
+ Kind string `json:"kind"`
+ Text string `json:"text,omitempty"`
+ Command string `json:"command,omitempty"`
+ Stdout string `json:"stdout,omitempty"`
+ Stderr string `json:"stderr,omitempty"`
+ ExitCode *int `json:"exit_code,omitempty"`
+ Interrupted bool `json:"interrupted,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+ IsImage bool `json:"is_image,omitempty"`
+ Mode string `json:"mode,omitempty"`
+ Query string `json:"query,omitempty"`
+ URL string `json:"url,omitempty"`
+ TaskID string `json:"task_id,omitempty"`
+ TaskType string `json:"task_type,omitempty"`
+ TaskStatus string `json:"task_status,omitempty"`
+ Description string `json:"description,omitempty"`
+ TotalDurationMs int `json:"total_duration_ms,omitempty"`
+ TotalTokens int `json:"total_tokens,omitempty"`
+ TotalToolUseCount int `json:"total_tool_use_count,omitempty"`
+ Output string `json:"output,omitempty"`
+ Question string `json:"question,omitempty"`
+ Questions []SessionStructuredQuestion `json:"questions,omitempty"`
+ Answer string `json:"answer,omitempty"`
+ Options []string `json:"options,omitempty"`
+ Answers []SessionStructuredArgument `json:"answers,omitempty"`
+ Counts []SessionStructuredArgument `json:"counts,omitempty"`
+ StatusCode int `json:"status_code,omitempty"`
+ StatusText string `json:"status_text,omitempty"`
+ Bytes int `json:"bytes,omitempty"`
+ Filenames []string `json:"filenames,omitempty"`
+ NumFiles int `json:"num_files,omitempty"`
+ NumResults int `json:"num_results,omitempty"`
+ DurationMs int `json:"duration_ms,omitempty"`
+ AppliedLimit int `json:"applied_limit,omitempty"`
+ StdoutLines int `json:"stdout_lines,omitempty"`
+ StderrLines int `json:"stderr_lines,omitempty"`
+ Timestamp string `json:"timestamp,omitempty"`
+ ResultItems []SessionStructuredSearchResultItem `json:"result_items,omitempty"`
+ Content string `json:"content,omitempty"`
+ NumLines int `json:"num_lines,omitempty"`
+ FilePath string `json:"file_path,omitempty"`
+ FilePaths []string `json:"file_paths,omitempty"`
+ Language string `json:"language,omitempty"`
+ Code string `json:"code,omitempty"`
+ Plan string `json:"plan,omitempty"`
+ Explanation string `json:"explanation,omitempty"`
+ Steps []SessionStructuredPlanStep `json:"steps,omitempty"`
+ Patch string `json:"patch,omitempty"`
+ PatchHunks []SessionStructuredPatchHunk `json:"patch_hunks,omitempty"`
+ OldString string `json:"old_string,omitempty"`
+ NewString string `json:"new_string,omitempty"`
+ OriginalFile string `json:"original_file,omitempty"`
+ ReplaceAll *bool `json:"replace_all,omitempty"`
+ UserModified *bool `json:"user_modified,omitempty"`
+ OldTodos []SessionStructuredTodoItem `json:"old_todos,omitempty"`
+ NewTodos []SessionStructuredTodoItem `json:"new_todos,omitempty"`
+ StartLine int `json:"start_line,omitempty"`
+ TotalLines int `json:"total_lines,omitempty"`
+ Error *SessionStructuredToolError `json:"error,omitempty"`
+}
+
+// SessionStructuredToolError is provider-neutral typed error data for a failed
+// tool result.
+type SessionStructuredToolError struct {
+ Category string `json:"category" enum:"user_rejection,user_rejection_with_reason,command_failure,file_error,validation_error,timeout,network_error,unknown" doc:"Provider-neutral category: user_rejection, user_rejection_with_reason, command_failure, file_error, validation_error, timeout, network_error, or unknown."`
+ Message string `json:"message,omitempty"`
+ UserReason string `json:"user_reason,omitempty"`
+}
+
+// SessionStructuredPatchHunk is one provider-neutral unified diff hunk.
+type SessionStructuredPatchHunk struct {
+ FilePath string `json:"file_path,omitempty"`
+ OldStart int `json:"old_start,omitempty"`
+ OldLines int `json:"old_lines,omitempty"`
+ NewStart int `json:"new_start,omitempty"`
+ NewLines int `json:"new_lines,omitempty"`
+ Lines []string `json:"lines,omitempty"`
+}
+
+// SessionStructuredSearchResultItem is one provider-neutral web/search result
+// item.
+type SessionStructuredSearchResultItem struct {
+ Title string `json:"title,omitempty"`
+ URL string `json:"url,omitempty"`
+ Snippet string `json:"snippet,omitempty"`
+}
+
+// SessionStructuredQuestionOption is one provider-neutral selectable answer
+// option.
+type SessionStructuredQuestionOption struct {
+ Label string `json:"label,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+// SessionStructuredQuestion is one provider-neutral user question.
+type SessionStructuredQuestion struct {
+ Question string `json:"question,omitempty"`
+ Header string `json:"header,omitempty"`
+ Options []SessionStructuredQuestionOption `json:"options,omitempty"`
+ MultiSelect bool `json:"multi_select,omitempty"`
+}
+
+// SessionStructuredTodoItem is one provider-neutral todo item.
+type SessionStructuredTodoItem struct {
+ ID string `json:"id,omitempty"`
+ Content string `json:"content,omitempty"`
+ Status string `json:"status,omitempty"`
+ ActiveForm string `json:"active_form,omitempty"`
+ Priority string `json:"priority,omitempty"`
+}
+
+// SessionStructuredInteraction is a provider-neutral required interaction
+// embedded in normalized history.
+type SessionStructuredInteraction struct {
+ RequestID string `json:"request_id,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ State string `json:"state"`
+ Prompt string `json:"prompt,omitempty"`
+ Options []string `json:"options,omitempty"`
+ Action string `json:"action,omitempty"`
+}
+
+func structuredHistoryFromSnapshot(snapshot *worker.HistorySnapshot) *SessionStructuredHistory {
+ if snapshot == nil {
+ return nil
+ }
+ diagnostics := make([]SessionStructuredDiagnostic, 0, len(snapshot.Diagnostics))
+ for _, d := range snapshot.Diagnostics {
+ diagnostics = append(diagnostics, SessionStructuredDiagnostic{
+ Code: d.Code,
+ Message: d.Message,
+ Count: d.Count,
+ })
+ }
+ return &SessionStructuredHistory{
+ GCSessionID: snapshot.GCSessionID,
+ LogicalConversationID: snapshot.LogicalConversationID,
+ ProviderSessionID: snapshot.ProviderSessionID,
+ TranscriptStreamID: opaqueTranscriptStreamID(snapshot),
+ Generation: SessionStructuredGeneration{
+ ID: opaqueGenerationID(snapshot.Generation.ID),
+ },
+ Cursor: SessionStructuredCursor{
+ AfterEntryID: snapshot.Cursor.AfterEntryID,
+ },
+ Continuity: SessionStructuredContinuity{
+ Status: string(snapshot.Continuity.Status),
+ CompactionCount: snapshot.Continuity.CompactionCount,
+ HasBranches: snapshot.Continuity.HasBranches,
+ Note: snapshot.Continuity.Note,
+ },
+ TailState: SessionStructuredTailState{
+ Activity: string(snapshot.TailState.Activity),
+ LastEntryID: snapshot.TailState.LastEntryID,
+ OpenToolCallIDs: append([]string(nil), snapshot.TailState.OpenToolUseIDs...),
+ PendingInteractionIDs: append([]string(nil), snapshot.TailState.PendingInteractionIDs...),
+ Degraded: snapshot.TailState.Degraded,
+ DegradedReason: snapshot.TailState.DegradedReason,
+ },
+ Diagnostics: diagnostics,
+ }
+}
+
+// opaqueTranscriptStreamID derives a stable, path-free wire identity for a
+// transcript stream. The worker's HistorySnapshot.TranscriptStreamID is the
+// absolute server-side transcript file path, which must never reach the
+// structured wire: it discloses the OS username, the on-disk directory layout,
+// the project working directory, and the provider session UUID. Hashing the
+// path together with the provider and logical conversation IDs yields an
+// identifier that is stable for a given stream and changes when the transcript
+// rotates to a new path — all a client needs for stream identity — while
+// revealing none of the underlying filesystem detail.
+func opaqueTranscriptStreamID(snapshot *worker.HistorySnapshot) string {
+ if snapshot == nil {
+ return ""
+ }
+ identity := snapshot.TranscriptStreamID + "\x00" + snapshot.ProviderSessionID + "\x00" + snapshot.LogicalConversationID
+ return sha256Hex([]byte(identity))
+}
+
+// opaqueGenerationID hashes the raw generation token (the worker records it as
+// ":" file-observation evidence) so the wire keeps a per-generation
+// change discriminator without disclosing the transcript file's modification
+// time or size. Generation is deliberately excluded from the projection hash
+// (it is not transcript identity), so the wire has no need for the raw values.
+// An empty token stays empty.
+func opaqueGenerationID(raw string) string {
+ if raw == "" {
+ return ""
+ }
+ return sha256Hex([]byte("generation\x00" + raw))
+}
+
+func structuredFallbackHistory(sessionID, providerSessionID, activity string) *SessionStructuredHistory {
+ if sessionID == "" {
+ sessionID = "unknown"
+ }
+ if providerSessionID == "" {
+ providerSessionID = sessionID
+ }
+ if activity == "" {
+ activity = string(worker.TailActivityUnknown)
+ }
+ streamID := "fallback:" + sessionID
+ return &SessionStructuredHistory{
+ GCSessionID: sessionID,
+ LogicalConversationID: sessionID,
+ ProviderSessionID: providerSessionID,
+ TranscriptStreamID: streamID,
+ Generation: SessionStructuredGeneration{
+ ID: streamID,
+ },
+ Continuity: SessionStructuredContinuity{
+ Status: string(worker.ContinuityStatusDegraded),
+ Note: structuredTranscriptUnavailableMessage,
+ },
+ TailState: SessionStructuredTailState{
+ Activity: activity,
+ Degraded: true,
+ DegradedReason: structuredTranscriptUnavailableMessage,
+ },
+ Diagnostics: []SessionStructuredDiagnostic{{
+ Code: structuredTranscriptUnavailableCode,
+ Message: structuredTranscriptUnavailableMessage,
+ Count: 1,
+ }},
+ }
+}
+
+func structuredFallbackMessages(sessionID, provider, text string) []SessionStructuredMessage {
+ if strings.TrimSpace(text) == "" {
+ return []SessionStructuredMessage{}
+ }
+ if sessionID == "" {
+ sessionID = "unknown"
+ }
+ return []SessionStructuredMessage{{
+ ID: "fallback:" + sessionID + ":1",
+ Role: "assistant",
+ Provider: provider,
+ Status: string(worker.ResultStatusPartial),
+ Blocks: []SessionStructuredBlock{{
+ Type: string(worker.BlockKindText),
+ Text: text,
+ }},
+ }}
+}
+
+func historySnapshotStructuredMessages(snapshot *worker.HistorySnapshot, includeThinking bool) ([]SessionStructuredMessage, []string) {
+ if snapshot == nil {
+ return []SessionStructuredMessage{}, []string{}
+ }
+ messages := make([]SessionStructuredMessage, 0, len(snapshot.Entries))
+ ids := make([]string, 0, len(snapshot.Entries))
+ for _, entry := range snapshot.Entries {
+ msg := historyEntryToStructuredMessage(entry, includeThinking)
+ if len(msg.Blocks) == 0 && msg.Role == "" {
+ continue
+ }
+ messages = append(messages, msg)
+ ids = append(ids, entry.ID)
+ }
+ return messages, ids
+}
+
+func historyEntryToStructuredMessage(entry worker.HistoryEntry, includeThinking bool) SessionStructuredMessage {
+ role := sessionStructuredMessageRole(entry.Actor)
+ msg := SessionStructuredMessage{
+ ID: entry.ID,
+ Role: role,
+ Provider: entry.Provenance.Provider,
+ Status: sessionStructuredMessageStatus(entry.Status),
+ Blocks: make([]SessionStructuredBlock, 0, len(entry.Blocks)),
+ }
+ switch role {
+ case string(worker.ActorAssistant):
+ msg.Model = entry.Model
+ msg.StopReason = entry.StopReason
+ msg.Usage = sessionStructuredUsageFromWorker(entry.Usage)
+ case string(worker.ActorUser):
+ msg.UserPrompt = sessionStructuredUserPromptFromWorker(entry.UserPrompt)
+ case string(worker.ActorSystem):
+ msg.SystemEvent = sessionStructuredSystemEventFromWorker(entry.SystemEvent)
+ case string(worker.ActorUnknown):
+ msg.Model = entry.Model
+ msg.StopReason = entry.StopReason
+ msg.Usage = sessionStructuredUsageFromWorker(entry.Usage)
+ msg.UserPrompt = sessionStructuredUserPromptFromWorker(entry.UserPrompt)
+ msg.SystemEvent = sessionStructuredSystemEventFromWorker(entry.SystemEvent)
+ }
+ if entry.Timestamp != nil {
+ msg.Timestamp = entry.Timestamp.Format(time.RFC3339Nano)
+ }
+ for _, block := range entry.Blocks {
+ if structured := historyBlockToStructuredBlock(block, includeThinking); structured != nil {
+ msg.Blocks = append(msg.Blocks, *structured)
+ }
+ }
+ return msg
+}
+
+func sessionStructuredSystemEventFromWorker(event *worker.HistorySystemEvent) *SessionStructuredSystemEvent {
+ if event == nil {
+ return nil
+ }
+ return &SessionStructuredSystemEvent{
+ Kind: event.Kind,
+ Category: event.Category,
+ Code: event.Code,
+ Message: event.Message,
+ }
+}
+
+func sessionStructuredUserPromptFromWorker(prompt *worker.HistoryUserPrompt) *SessionStructuredUserPrompt {
+ if prompt == nil {
+ return nil
+ }
+ return &SessionStructuredUserPrompt{
+ Text: prompt.Text,
+ OpenedFiles: append([]string(nil), prompt.OpenedFiles...),
+ UploadedFiles: sessionStructuredUploadedFilesFromWorker(prompt.UploadedFiles),
+ Selections: sessionStructuredIDESelectionsFromWorker(prompt.Selections),
+ }
+}
+
+func sessionStructuredUploadedFilesFromWorker(files []worker.HistoryUploadedFile) []SessionStructuredUploadedFile {
+ if len(files) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredUploadedFile, 0, len(files))
+ for _, file := range files {
+ out = append(out, SessionStructuredUploadedFile{
+ OriginalName: file.OriginalName,
+ Size: file.Size,
+ MIMEType: file.MIMEType,
+ FilePath: file.FilePath,
+ PreviewURL: file.PreviewURL,
+ })
+ }
+ return out
+}
+
+func sessionStructuredIDESelectionsFromWorker(selections []worker.HistoryUserSelection) []SessionStructuredIDESelection {
+ if len(selections) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredIDESelection, 0, len(selections))
+ for _, selection := range selections {
+ out = append(out, SessionStructuredIDESelection{Text: selection.Text})
+ }
+ return out
+}
+
+func sessionStructuredUsageFromWorker(usage *worker.HistoryUsage) *SessionStructuredUsage {
+ if usage == nil {
+ return nil
+ }
+ return &SessionStructuredUsage{
+ InputTokens: usage.InputTokens,
+ OutputTokens: usage.OutputTokens,
+ ReasoningTokens: usage.ReasoningTokens,
+ CacheReadTokens: usage.CacheReadTokens,
+ CacheCreationTokens: usage.CacheCreationTokens,
+ ContextWindowTokens: usage.ContextWindowTokens,
+ ContextUsedTokens: usage.ContextUsedTokens,
+ ContextPercent: usage.ContextPercent,
+ }
+}
+
+func historyBlockToStructuredBlock(block worker.HistoryBlock, includeThinking bool) *SessionStructuredBlock {
+ out := &SessionStructuredBlock{Type: sessionStructuredBlockType(block.Kind)}
+ switch block.Kind {
+ case worker.BlockKindText:
+ out.Text = block.Text
+ case worker.BlockKindThinking:
+ if includeThinking {
+ out.Thinking = block.Text
+ out.Signature = block.Signature
+ }
+ case worker.BlockKindToolUse:
+ out.ID = block.ToolUseID
+ out.Name = block.Name
+ out.FilePath = block.FilePath
+ out.Input = sessionStructuredToolInputFromWorker(block.StructuredInput)
+ case worker.BlockKindToolResult:
+ out.ToolCallID = block.ToolUseID
+ out.Name = block.Name
+ out.FilePath = block.FilePath
+ out.Content = block.ContentText
+ if out.Content == "" {
+ out.Content = block.Text
+ }
+ out.IsError = block.IsError
+ out.Structured = sessionStructuredToolResultFromWorker(block.StructuredResult)
+ case worker.BlockKindInteraction:
+ out.Interaction = structuredInteraction(block.Interaction)
+ case worker.BlockKindImage:
+ out.Text = block.Text
+ out.FilePath = block.FilePath
+ out.ImageURL = block.ImageURL
+ out.MIMEType = block.MIMEType
+ default:
+ out.Text = block.Text
+ if includeThinking {
+ out.Signature = block.Signature
+ }
+ out.ToolCallID = block.ToolUseID
+ out.Name = block.Name
+ out.FilePath = block.FilePath
+ out.ImageURL = block.ImageURL
+ out.MIMEType = block.MIMEType
+ out.Input = sessionStructuredToolInputFromWorker(block.StructuredInput)
+ out.Content = block.ContentText
+ out.IsError = block.IsError
+ out.Interaction = structuredInteraction(block.Interaction)
+ }
+ return out
+}
+
+func sessionStructuredToolInputFromWorker(input *worker.StructuredToolInput) *SessionStructuredToolInput {
+ if input == nil {
+ return nil
+ }
+ out := &SessionStructuredToolInput{
+ Kind: sessionStructuredToolInputKind(input.Kind),
+ Text: input.Text,
+ Command: input.Command,
+ LinkedCommand: input.LinkedCommand,
+ Code: input.Code,
+ Patch: input.Patch,
+ FilePath: input.FilePath,
+ Language: input.Language,
+ URL: input.URL,
+ Prompt: input.Prompt,
+ TaskID: input.TaskID,
+ TaskType: input.TaskType,
+ TaskStatus: input.TaskStatus,
+ Description: input.Description,
+ Question: input.Question,
+ Options: append([]string(nil), input.Options...),
+ Query: input.Query,
+ Pattern: input.Pattern,
+ Plan: input.Plan,
+ Explanation: input.Explanation,
+ Steps: sessionStructuredPlanStepsFromWorker(input.Steps),
+ Todos: sessionStructuredTodosFromWorker(input.Todos),
+ }
+ if len(input.Arguments) > 0 {
+ out.Arguments = sessionStructuredArgumentsFromWorker(input.Arguments)
+ }
+ return narrowSessionStructuredToolInput(out)
+}
+
+func narrowSessionStructuredToolInput(input *SessionStructuredToolInput) *SessionStructuredToolInput {
+ if input == nil || input.Kind == "unknown" {
+ return input
+ }
+ out := &SessionStructuredToolInput{Kind: input.Kind}
+ switch input.Kind {
+ case "command":
+ out.Command, out.Arguments = input.Command, input.Arguments
+ case "stdin":
+ out.TaskID, out.Text, out.LinkedCommand = input.TaskID, input.Text, input.LinkedCommand
+ case "code":
+ out.Code, out.Language = input.Code, input.Language
+ case "patch":
+ out.Patch, out.FilePath, out.Language = input.Patch, input.FilePath, input.Language
+ case "write":
+ out.FilePath, out.Language, out.Text = input.FilePath, input.Language, input.Text
+ case "glob":
+ out.Pattern, out.Query, out.FilePath, out.Arguments = input.Pattern, input.Query, input.FilePath, input.Arguments
+ case "fetch":
+ out.URL, out.Prompt = input.URL, input.Prompt
+ case "search":
+ out.Query, out.Pattern, out.FilePath, out.Command = input.Query, input.Pattern, input.FilePath, input.Command
+ out.Arguments = input.Arguments
+ case "file":
+ out.FilePath, out.Language, out.Command = input.FilePath, input.Language, input.Command
+ case "todo":
+ out.Todos = input.Todos
+ case "plan":
+ out.Plan, out.Explanation, out.Steps = input.Plan, input.Explanation, input.Steps
+ case "question":
+ out.Question, out.Options = input.Question, input.Options
+ case "task":
+ out.TaskID, out.TaskType, out.TaskStatus = input.TaskID, input.TaskType, input.TaskStatus
+ out.Description, out.Prompt = input.Description, input.Prompt
+ case "text":
+ out.Text = input.Text
+ case "arguments":
+ out.Arguments = input.Arguments
+ default:
+ return &SessionStructuredToolInput{Kind: "unknown"}
+ }
+ return out
+}
+
+func sessionStructuredArgumentsFromWorker(args []worker.StructuredArgument) []SessionStructuredArgument {
+ if len(args) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredArgument, 0, len(args))
+ for _, arg := range args {
+ out = append(out, SessionStructuredArgument{
+ Name: arg.Name,
+ Value: arg.Value,
+ })
+ }
+ return out
+}
+
+func sessionStructuredToolResultFromWorker(result *worker.StructuredToolResult) *SessionStructuredToolResult {
+ if result == nil {
+ return nil
+ }
+ out := &SessionStructuredToolResult{
+ Kind: sessionStructuredToolResultKind(result.Kind),
+ Text: result.Text,
+ Command: result.Command,
+ Stdout: result.Stdout,
+ Stderr: result.Stderr,
+ ExitCode: result.ExitCode,
+ Interrupted: result.Interrupted,
+ Truncated: result.Truncated,
+ IsImage: result.IsImage,
+ Mode: result.Mode,
+ Query: result.Query,
+ URL: result.URL,
+ TaskID: result.TaskID,
+ TaskType: result.TaskType,
+ TaskStatus: result.TaskStatus,
+ Description: result.Description,
+ TotalDurationMs: result.TotalDurationMs,
+ TotalTokens: result.TotalTokens,
+ TotalToolUseCount: result.TotalToolUseCount,
+ Output: result.Output,
+ Question: result.Question,
+ Questions: sessionStructuredQuestionsFromWorker(result.Questions),
+ Answer: result.Answer,
+ Options: append([]string(nil), result.Options...),
+ Answers: sessionStructuredArgumentsFromWorker(result.Answers),
+ Counts: sessionStructuredArgumentsFromWorker(result.Counts),
+ StatusCode: result.StatusCode,
+ StatusText: result.StatusText,
+ Bytes: result.Bytes,
+ Filenames: append([]string(nil), result.Filenames...),
+ NumFiles: result.NumFiles,
+ NumResults: result.NumResults,
+ DurationMs: result.DurationMs,
+ AppliedLimit: result.AppliedLimit,
+ StdoutLines: result.StdoutLines,
+ StderrLines: result.StderrLines,
+ Timestamp: result.Timestamp,
+ ResultItems: sessionStructuredSearchResultItemsFromWorker(result.ResultItems),
+ Content: result.Content,
+ NumLines: result.NumLines,
+ FilePath: result.FilePath,
+ FilePaths: append([]string(nil), result.FilePaths...),
+ Language: result.Language,
+ Code: result.Code,
+ Plan: result.Plan,
+ Explanation: result.Explanation,
+ Steps: sessionStructuredPlanStepsFromWorker(result.Steps),
+ Patch: result.Patch,
+ PatchHunks: sessionStructuredPatchHunksFromWorker(result.PatchHunks),
+ OldString: result.OldString,
+ NewString: result.NewString,
+ OriginalFile: result.OriginalFile,
+ ReplaceAll: result.ReplaceAll,
+ UserModified: result.UserModified,
+ OldTodos: sessionStructuredTodosFromWorker(result.OldTodos),
+ NewTodos: sessionStructuredTodosFromWorker(result.NewTodos),
+ StartLine: result.StartLine,
+ TotalLines: result.TotalLines,
+ Error: sessionStructuredToolErrorFromWorker(result.Error),
+ }
+ return narrowSessionStructuredToolResult(out)
+}
+
+func narrowSessionStructuredToolResult(result *SessionStructuredToolResult) *SessionStructuredToolResult {
+ if result == nil || result.Kind == "unknown" {
+ return result
+ }
+ out := &SessionStructuredToolResult{Kind: result.Kind}
+ switch result.Kind {
+ case "bash":
+ out.Text, out.Command, out.Stdout, out.Stderr = result.Text, result.Command, result.Stdout, result.Stderr
+ out.ExitCode, out.Interrupted, out.Truncated, out.IsImage = result.ExitCode, result.Interrupted, result.Truncated, result.IsImage
+ out.TaskID, out.TaskStatus = result.TaskID, result.TaskStatus
+ out.StdoutLines, out.StderrLines, out.Timestamp = result.StdoutLines, result.StderrLines, result.Timestamp
+ out.Content, out.NumLines, out.Error = result.Content, result.NumLines, result.Error
+ case "python":
+ out.Text, out.Code, out.Stdout, out.Stderr = result.Text, result.Code, result.Stdout, result.Stderr
+ out.ExitCode, out.Interrupted, out.Truncated, out.IsImage = result.ExitCode, result.Interrupted, result.Truncated, result.IsImage
+ out.Error = result.Error
+ case "read":
+ out.FilePath, out.Language, out.Content = result.FilePath, result.Language, result.Content
+ out.NumLines, out.StartLine, out.TotalLines, out.Error = result.NumLines, result.StartLine, result.TotalLines, result.Error
+ case "glob":
+ out.Filenames, out.NumFiles, out.DurationMs = result.Filenames, result.NumFiles, result.DurationMs
+ out.Truncated, out.Content, out.NumLines, out.Error = result.Truncated, result.Content, result.NumLines, result.Error
+ case "grep", "search":
+ out.Mode, out.Query, out.Filenames = result.Mode, result.Query, result.Filenames
+ out.NumFiles, out.NumResults, out.Counts = result.NumFiles, result.NumResults, result.Counts
+ out.DurationMs, out.AppliedLimit, out.ResultItems = result.DurationMs, result.AppliedLimit, result.ResultItems
+ out.Content, out.NumLines, out.Error = result.Content, result.NumLines, result.Error
+ case "fetch":
+ out.Text, out.URL, out.StatusCode, out.StatusText = result.Text, result.URL, result.StatusCode, result.StatusText
+ out.Bytes, out.DurationMs, out.Content, out.NumLines = result.Bytes, result.DurationMs, result.Content, result.NumLines
+ out.Error = result.Error
+ case "todo":
+ out.Text, out.Content, out.OldTodos, out.NewTodos = result.Text, result.Content, result.OldTodos, result.NewTodos
+ out.Error = result.Error
+ case "plan":
+ out.Text, out.Content, out.Plan, out.Explanation = result.Text, result.Content, result.Plan, result.Explanation
+ out.Steps, out.Error = result.Steps, result.Error
+ case "question":
+ out.Text, out.Content, out.Question = result.Text, result.Content, result.Question
+ out.Questions, out.Answer, out.Options, out.Answers = result.Questions, result.Answer, result.Options, result.Answers
+ out.Error = result.Error
+ case "stdin":
+ out.Text, out.TaskID, out.Content, out.NumLines = result.Text, result.TaskID, result.Content, result.NumLines
+ out.Error = result.Error
+ case "task":
+ out.Text, out.TaskID, out.TaskType, out.TaskStatus = result.Text, result.TaskID, result.TaskType, result.TaskStatus
+ out.Description, out.TotalDurationMs, out.TotalTokens = result.Description, result.TotalDurationMs, result.TotalTokens
+ out.TotalToolUseCount, out.Output = result.TotalToolUseCount, result.Output
+ out.Stdout, out.Stderr, out.ExitCode, out.Content = result.Stdout, result.Stderr, result.ExitCode, result.Content
+ out.Error = result.Error
+ case "write":
+ out.Text, out.FilePath, out.FilePaths, out.Language = result.Text, result.FilePath, result.FilePaths, result.Language
+ out.Content, out.NumLines, out.Patch, out.PatchHunks = result.Content, result.NumLines, result.Patch, result.PatchHunks
+ out.StartLine, out.TotalLines, out.Error = result.StartLine, result.TotalLines, result.Error
+ case "edit":
+ out.FilePath, out.FilePaths, out.Patch, out.PatchHunks = result.FilePath, result.FilePaths, result.Patch, result.PatchHunks
+ out.OldString, out.NewString, out.OriginalFile = result.OldString, result.NewString, result.OriginalFile
+ out.ReplaceAll, out.UserModified, out.Content, out.Error = result.ReplaceAll, result.UserModified, result.Content, result.Error
+ case "text":
+ out.Text, out.Content, out.Error = result.Text, result.Content, result.Error
+ default:
+ return &SessionStructuredToolResult{Kind: "unknown"}
+ }
+ return out
+}
+
+func sessionStructuredToolErrorFromWorker(err *worker.StructuredToolError) *SessionStructuredToolError {
+ if err == nil {
+ return nil
+ }
+ return &SessionStructuredToolError{
+ Category: err.Category,
+ Message: err.Message,
+ UserReason: err.UserReason,
+ }
+}
+
+func sessionStructuredQuestionsFromWorker(questions []worker.StructuredQuestion) []SessionStructuredQuestion {
+ if len(questions) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredQuestion, 0, len(questions))
+ for _, question := range questions {
+ out = append(out, SessionStructuredQuestion{
+ Question: question.Question,
+ Header: question.Header,
+ Options: sessionStructuredQuestionOptionsFromWorker(question.Options),
+ MultiSelect: question.MultiSelect,
+ })
+ }
+ return out
+}
+
+func sessionStructuredQuestionOptionsFromWorker(options []worker.StructuredQuestionOption) []SessionStructuredQuestionOption {
+ if len(options) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredQuestionOption, 0, len(options))
+ for _, option := range options {
+ out = append(out, SessionStructuredQuestionOption{
+ Label: option.Label,
+ Description: option.Description,
+ })
+ }
+ return out
+}
+
+func sessionStructuredSearchResultItemsFromWorker(items []worker.StructuredSearchResultItem) []SessionStructuredSearchResultItem {
+ if len(items) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredSearchResultItem, 0, len(items))
+ for _, item := range items {
+ out = append(out, SessionStructuredSearchResultItem{
+ Title: item.Title,
+ URL: item.URL,
+ Snippet: item.Snippet,
+ })
+ }
+ return out
+}
+
+func sessionStructuredPlanStepsFromWorker(steps []worker.StructuredPlanStep) []SessionStructuredPlanStep {
+ if len(steps) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredPlanStep, 0, len(steps))
+ for _, step := range steps {
+ out = append(out, SessionStructuredPlanStep{
+ Step: step.Step,
+ Status: step.Status,
+ })
+ }
+ return out
+}
+
+func sessionStructuredPatchHunksFromWorker(hunks []worker.StructuredPatchHunk) []SessionStructuredPatchHunk {
+ if len(hunks) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredPatchHunk, 0, len(hunks))
+ for _, hunk := range hunks {
+ out = append(out, SessionStructuredPatchHunk{
+ FilePath: hunk.FilePath,
+ OldStart: hunk.OldStart,
+ OldLines: hunk.OldLines,
+ NewStart: hunk.NewStart,
+ NewLines: hunk.NewLines,
+ Lines: append([]string(nil), hunk.Lines...),
+ })
+ }
+ return out
+}
+
+func sessionStructuredTodosFromWorker(todos []worker.StructuredTodoItem) []SessionStructuredTodoItem {
+ if len(todos) == 0 {
+ return nil
+ }
+ out := make([]SessionStructuredTodoItem, 0, len(todos))
+ for _, todo := range todos {
+ out = append(out, SessionStructuredTodoItem{
+ ID: todo.ID,
+ Content: todo.Content,
+ Status: todo.Status,
+ ActiveForm: todo.ActiveForm,
+ Priority: todo.Priority,
+ })
+ }
+ return out
+}
+
+func structuredInteraction(in *worker.HistoryInteraction) *SessionStructuredInteraction {
+ if in == nil {
+ return nil
+ }
+ return &SessionStructuredInteraction{
+ RequestID: in.RequestID,
+ Kind: in.Kind,
+ State: string(in.State),
+ Prompt: in.Prompt,
+ Options: append([]string(nil), in.Options...),
+ Action: in.Action,
+ }
+}
diff --git a/internal/api/session_structured_types_test.go b/internal/api/session_structured_types_test.go
new file mode 100644
index 0000000000..e9acf1c07b
--- /dev/null
+++ b/internal/api/session_structured_types_test.go
@@ -0,0 +1,420 @@
+package api
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/worker"
+)
+
+func TestHistorySnapshotStructuredMessagesPreferWorkerCarriedStructuredData(t *testing.T) {
+ exitCode := 7
+ replaceAll := false
+ userModified := false
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "assistant-1",
+ Kind: "assistant",
+ Actor: worker.ActorAssistant,
+ Status: worker.ResultStatusFinal,
+ Model: "claude-sonnet",
+ StopReason: "tool_use",
+ Usage: &worker.HistoryUsage{
+ InputTokens: 100,
+ OutputTokens: 20,
+ ReasoningTokens: 7,
+ CacheReadTokens: 5,
+ CacheCreationTokens: 3,
+ ContextWindowTokens: 200000,
+ ContextUsedTokens: 108,
+ ContextPercent: 1,
+ },
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindToolUse,
+ ToolUseID: "call-1",
+ Name: "exec_command",
+ Input: mustMarshalForStructuredTest(t, struct {
+ Command string `json:"cmd"`
+ }{Command: "cat wrong.txt"}),
+ StructuredInput: &worker.StructuredToolInput{
+ Kind: "command",
+ Command: "go test ./internal/api",
+ FilePath: "typed-input.txt",
+ Language: "text",
+ Arguments: []worker.StructuredArgument{{
+ Name: "cwd",
+ Value: "/tmp/project",
+ }},
+ },
+ }},
+ }, {
+ ID: "tool-1",
+ Kind: "tool",
+ Actor: worker.ActorTool,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindToolResult,
+ ToolUseID: "call-1",
+ Name: "exec_command",
+ Content: mustMarshalForStructuredTest(t, "fallback output"),
+ StructuredResult: &worker.StructuredToolResult{
+ Kind: "bash",
+ Command: "npm test",
+ TaskID: "shell-123",
+ TaskStatus: "completed",
+ Stdout: "typed stdout",
+ Stderr: "typed stderr",
+ ExitCode: &exitCode,
+ StdoutLines: 2,
+ StderrLines: 1,
+ Timestamp: "2026-06-01T00:00:02Z",
+ Language: "text",
+ FilePaths: []string{"typed-output.txt"},
+ Error: &worker.StructuredToolError{
+ Category: "command_failure",
+ Message: "npm ERR! test failed",
+ UserReason: "asked to stop",
+ },
+ OldString: "old typed text",
+ NewString: "new typed text",
+ OriginalFile: "old typed text\n",
+ ReplaceAll: &replaceAll,
+ UserModified: &userModified,
+ Counts: []worker.StructuredArgument{{
+ Name: "typed-output.txt",
+ Value: "2",
+ }},
+ ResultItems: []worker.StructuredSearchResultItem{{
+ Title: "Typed result item",
+ URL: "https://example.com/typed",
+ Snippet: "Provider-neutral item.",
+ }},
+ AppliedLimit: 100,
+ TotalDurationMs: 1234,
+ TotalTokens: 321,
+ TotalToolUseCount: 4,
+ Questions: []worker.StructuredQuestion{{
+ Question: "Select rollout scope",
+ Header: "Scope",
+ MultiSelect: true,
+ Options: []worker.StructuredQuestionOption{{
+ Label: "All providers",
+ Description: "Validate first-class and graceful providers",
+ }},
+ }},
+ },
+ }},
+ }},
+ }
+
+ messages, ids := historySnapshotStructuredMessages(snapshot, false)
+ if len(ids) != 2 || ids[0] != "assistant-1" || ids[1] != "tool-1" {
+ t.Fatalf("ids = %#v, want assistant/tool IDs", ids)
+ }
+ if len(messages) != 2 {
+ t.Fatalf("messages = %d, want 2", len(messages))
+ }
+ if messages[0].Model != "claude-sonnet" || messages[0].StopReason != "tool_use" {
+ t.Fatalf("message metadata = model %q stop %q, want claude-sonnet/tool_use", messages[0].Model, messages[0].StopReason)
+ }
+ if messages[0].Usage == nil || messages[0].Usage.InputTokens != 100 || messages[0].Usage.OutputTokens != 20 || messages[0].Usage.ReasoningTokens != 7 || messages[0].Usage.CacheReadTokens != 5 || messages[0].Usage.CacheCreationTokens != 3 {
+ t.Fatalf("message usage = %+v, want typed token usage", messages[0].Usage)
+ }
+ if messages[0].Usage.ContextWindowTokens != 200000 || messages[0].Usage.ContextUsedTokens != 108 || messages[0].Usage.ContextPercent != 1 {
+ t.Fatalf("message context usage = %+v, want context fields", messages[0].Usage)
+ }
+ input := messages[0].Blocks[0].Input
+ if input == nil {
+ t.Fatal("tool-use input = nil")
+ }
+ if input.Command != "go test ./internal/api" {
+ t.Fatalf("tool-use command = %q, want worker-carried command", input.Command)
+ }
+ if input.FilePath != "" || input.Language != "" {
+ t.Fatalf("command input leaked cross-variant fields: %+v", input)
+ }
+ if len(input.Arguments) != 1 || input.Arguments[0].Name != "cwd" || input.Arguments[0].Value != "/tmp/project" {
+ t.Fatalf("tool-use arguments = %#v, want converted worker arguments", input.Arguments)
+ }
+
+ result := messages[1].Blocks[0].Structured
+ if result == nil {
+ t.Fatal("tool result structured = nil")
+ }
+ if result.Stdout != "typed stdout" || result.Stderr != "typed stderr" {
+ t.Fatalf("tool result = %+v, want worker-carried stdout/stderr", result)
+ }
+ if result.Command != "npm test" || result.TaskID != "shell-123" || result.TaskStatus != "completed" {
+ t.Fatalf("tool result bash metadata = command %q task %q status %q, want npm test/shell-123/completed", result.Command, result.TaskID, result.TaskStatus)
+ }
+ if result.StdoutLines != 2 || result.StderrLines != 1 || result.Timestamp != "2026-06-01T00:00:02Z" {
+ t.Fatalf("tool result bash lines/timestamp = stdout %d stderr %d timestamp %q, want 2/1/2026-06-01T00:00:02Z", result.StdoutLines, result.StderrLines, result.Timestamp)
+ }
+ if result.ExitCode == nil || *result.ExitCode != 7 {
+ t.Fatalf("tool result exit = %v, want 7", result.ExitCode)
+ }
+ if result.Error == nil {
+ t.Fatal("tool result error = nil, want worker-carried structured error")
+ }
+ if result.Error.Category != "command_failure" || result.Error.Message != "npm ERR! test failed" || result.Error.UserReason != "asked to stop" {
+ t.Fatalf("tool result error = %+v, want worker-carried error classification", result.Error)
+ }
+ if len(result.FilePaths) != 0 || result.Language != "" || result.OldString != "" || result.NewString != "" || result.OriginalFile != "" {
+ t.Fatalf("bash result leaked file/edit fields: %+v", result)
+ }
+ if result.ReplaceAll != nil || result.UserModified != nil || len(result.Counts) != 0 || len(result.ResultItems) != 0 || len(result.Questions) != 0 {
+ t.Fatalf("bash result leaked cross-variant collections or flags: %+v", result)
+ }
+ if result.AppliedLimit != 0 || result.TotalDurationMs != 0 || result.TotalTokens != 0 || result.TotalToolUseCount != 0 {
+ t.Fatalf("bash result leaked cross-variant metrics: %+v", result)
+ }
+}
+
+func TestHistorySnapshotStructuredMessagesCarriesUserPromptMetadata(t *testing.T) {
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "user-1",
+ Kind: "user",
+ Actor: worker.ActorUser,
+ Status: worker.ResultStatusFinal,
+ UserPrompt: &worker.HistoryUserPrompt{
+ Text: "Please inspect this.",
+ OpenedFiles: []string{"/tmp/project/src/app.ts"},
+ UploadedFiles: []worker.HistoryUploadedFile{{
+ OriginalName: "diagram.png",
+ Size: "12 KB",
+ MIMEType: "image/png",
+ FilePath: "/tmp/uploads/diagram.png",
+ }},
+ Selections: []worker.HistoryUserSelection{{
+ Text: "const answer = 42;",
+ }},
+ },
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindText,
+ Text: "raw prompt text with metadata",
+ }},
+ }},
+ }
+
+ messages, _ := historySnapshotStructuredMessages(snapshot, false)
+ if len(messages) != 1 {
+ t.Fatalf("messages = %+v, want one message", messages)
+ }
+ got := messages[0].UserPrompt
+ if got == nil {
+ t.Fatal("UserPrompt = nil, want projected prompt metadata")
+ }
+ if got.Text != "Please inspect this." {
+ t.Fatalf("prompt text = %q, want cleaned text", got.Text)
+ }
+ if len(got.OpenedFiles) != 1 || got.OpenedFiles[0] != "/tmp/project/src/app.ts" {
+ t.Fatalf("opened files = %#v, want projected file path", got.OpenedFiles)
+ }
+ if len(got.UploadedFiles) != 1 || got.UploadedFiles[0].OriginalName != "diagram.png" || got.UploadedFiles[0].MIMEType != "image/png" || got.UploadedFiles[0].FilePath != "/tmp/uploads/diagram.png" {
+ t.Fatalf("uploaded files = %#v, want projected upload metadata", got.UploadedFiles)
+ }
+ if len(got.Selections) != 1 || got.Selections[0].Text != "const answer = 42;" {
+ t.Fatalf("selections = %#v, want projected IDE selection", got.Selections)
+ }
+}
+
+func TestHistorySnapshotStructuredMessagesRedactsThinkingSignatureUnlessIncluded(t *testing.T) {
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "assistant-thinking",
+ Kind: "assistant",
+ Actor: worker.ActorAssistant,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindThinking,
+ Text: "private reasoning",
+ Signature: "encrypted",
+ }},
+ }},
+ }
+
+ redacted, _ := historySnapshotStructuredMessages(snapshot, false)
+ if len(redacted) != 1 || len(redacted[0].Blocks) != 1 {
+ t.Fatalf("redacted messages = %+v, want one thinking block", redacted)
+ }
+ if redacted[0].Blocks[0].Thinking != "" || redacted[0].Blocks[0].Text != "" {
+ t.Fatalf("redacted block leaked thinking text: %+v", redacted[0].Blocks[0])
+ }
+ if redacted[0].Blocks[0].Signature != "" {
+ t.Fatalf("redacted signature = %q, want empty", redacted[0].Blocks[0].Signature)
+ }
+
+ included, _ := historySnapshotStructuredMessages(snapshot, true)
+ if included[0].Blocks[0].Thinking != "private reasoning" {
+ t.Fatalf("included thinking = %q, want private reasoning", included[0].Blocks[0].Thinking)
+ }
+ if included[0].Blocks[0].Signature != "encrypted" {
+ t.Fatalf("included signature = %q, want encrypted", included[0].Blocks[0].Signature)
+ }
+}
+
+func TestHistorySnapshotStructuredMessagesRedactsUnknownBlockSignatureUnlessIncluded(t *testing.T) {
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "assistant-unknown",
+ Kind: "assistant",
+ Actor: worker.ActorAssistant,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindUnknown,
+ Text: "opaque block",
+ Signature: "encrypted",
+ }},
+ }},
+ }
+
+ redacted, _ := historySnapshotStructuredMessages(snapshot, false)
+ if len(redacted) != 1 || len(redacted[0].Blocks) != 1 {
+ t.Fatalf("redacted messages = %+v, want one unknown block", redacted)
+ }
+ if redacted[0].Blocks[0].Signature != "" {
+ t.Fatalf("redacted unknown signature = %q, want empty", redacted[0].Blocks[0].Signature)
+ }
+
+ included, _ := historySnapshotStructuredMessages(snapshot, true)
+ if included[0].Blocks[0].Signature != "encrypted" {
+ t.Fatalf("included unknown signature = %q, want encrypted", included[0].Blocks[0].Signature)
+ }
+}
+
+func TestHistoryEntryToStructuredMessageUsesActorAsRole(t *testing.T) {
+ message := historyEntryToStructuredMessage(worker.HistoryEntry{
+ ID: "tool-result",
+ Kind: "tool_result",
+ Actor: worker.ActorTool,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{Kind: worker.BlockKindToolResult, ContentText: "done"}},
+ }, false)
+
+ if message.Role != string(worker.ActorTool) {
+ t.Fatalf("role = %q, want actor role %q", message.Role, worker.ActorTool)
+ }
+}
+
+func TestHistorySnapshotStructuredMessagesCarriesImageBlockMetadata(t *testing.T) {
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "user-image",
+ Kind: "user",
+ Actor: worker.ActorUser,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindImage,
+ FilePath: "screens/shot.png",
+ ImageURL: "https://example.com/shot.png",
+ MIMEType: "image/png",
+ }},
+ }},
+ }
+
+ messages, ids := historySnapshotStructuredMessages(snapshot, false)
+ if len(ids) != 1 || ids[0] != "user-image" {
+ t.Fatalf("ids = %#v, want user-image", ids)
+ }
+ if len(messages) != 1 || len(messages[0].Blocks) != 1 {
+ t.Fatalf("messages = %+v, want one image block", messages)
+ }
+ block := messages[0].Blocks[0]
+ if block.Type != "image" || block.FilePath != "screens/shot.png" || block.ImageURL != "https://example.com/shot.png" || block.MIMEType != "image/png" {
+ t.Fatalf("image block = %+v, want provider-neutral image metadata", block)
+ }
+}
+
+func TestHistorySnapshotStructuredMessagesDoNotInferProviderNativeFallbacks(t *testing.T) {
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "assistant-1",
+ Kind: "assistant",
+ Actor: worker.ActorAssistant,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindToolUse,
+ ToolUseID: "call-1",
+ Name: "exec_command",
+ Input: mustMarshalForStructuredTest(t, struct {
+ Command string `json:"cmd"`
+ }{Command: "cat provider-native.txt"}),
+ }},
+ }, {
+ ID: "tool-1",
+ Kind: "tool",
+ Actor: worker.ActorTool,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindToolResult,
+ ToolUseID: "call-1",
+ Name: "exec_command",
+ Content: mustMarshalForStructuredTest(t, struct {
+ ToolUseResult struct {
+ Stdout string `json:"stdout"`
+ } `json:"toolUseResult"`
+ }{
+ ToolUseResult: struct {
+ Stdout string `json:"stdout"`
+ }{Stdout: "native stdout"},
+ }),
+ }},
+ }},
+ }
+
+ messages, _ := historySnapshotStructuredMessages(snapshot, false)
+ if got := messages[0].Blocks[0].Input; got != nil {
+ t.Fatalf("tool input = %+v, want nil without worker-carried structured input", got)
+ }
+ resultBlock := messages[1].Blocks[0]
+ if resultBlock.Structured != nil {
+ t.Fatalf("structured result = %+v, want nil without worker-carried structured result", resultBlock.Structured)
+ }
+ if resultBlock.Content != "" {
+ t.Fatalf("content = %q, want empty string for provider-native object without generic content/text", resultBlock.Content)
+ }
+}
+
+func TestHistorySnapshotStructuredMessagesUseWorkerCarriedContentText(t *testing.T) {
+ snapshot := &worker.HistorySnapshot{
+ Entries: []worker.HistoryEntry{{
+ ID: "tool-1",
+ Kind: "tool",
+ Actor: worker.ActorTool,
+ Status: worker.ResultStatusFinal,
+ Blocks: []worker.HistoryBlock{{
+ Kind: worker.BlockKindToolResult,
+ ToolUseID: "call-1",
+ Name: "exec_command",
+ Content: mustMarshalForStructuredTest(t, struct {
+ ToolUseResult struct {
+ Stdout string `json:"stdout"`
+ } `json:"toolUseResult"`
+ }{
+ ToolUseResult: struct {
+ Stdout string `json:"stdout"`
+ }{Stdout: "provider-native stdout"},
+ }),
+ ContentText: "provider-neutral content text",
+ }},
+ }},
+ }
+
+ messages, _ := historySnapshotStructuredMessages(snapshot, false)
+ if len(messages) != 1 || len(messages[0].Blocks) != 1 {
+ t.Fatalf("messages = %+v, want one tool-result block", messages)
+ }
+ if got := messages[0].Blocks[0].Content; got != "provider-neutral content text" {
+ t.Fatalf("content = %q, want worker-carried content text", got)
+ }
+}
+
+func mustMarshalForStructuredTest(t *testing.T, value any) json.RawMessage {
+ t.Helper()
+ out, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("marshal structured fixture: %v", err)
+ }
+ return out
+}
diff --git a/internal/api/sse.go b/internal/api/sse.go
index 8c5d0f6de3..b481fa463b 100644
--- a/internal/api/sse.go
+++ b/internal/api/sse.go
@@ -40,6 +40,31 @@ func cancelOnSendError(send sse.Sender, cancel context.CancelFunc) sse.Sender {
}
}
+func cancelOnStringIDSendError(send StringIDSender, cancel context.CancelFunc) StringIDSender {
+ var firstErr error
+ return func(msg StringIDMessage) error {
+ if firstErr != nil {
+ return firstErr
+ }
+ if err := send(msg); err != nil {
+ firstErr = err
+ cancel()
+ return err
+ }
+ return nil
+ }
+}
+
+func integerSSESender(send StringIDSender) sse.Sender {
+ return func(msg sse.Message) error {
+ id := ""
+ if msg.ID > 0 {
+ id = fmt.Sprintf("%d", msg.ID)
+ }
+ return send(StringIDMessage{ID: id, Data: msg.Data})
+ }
+}
+
// StreamFunc is the callback signature for SSE streaming handlers
// registered via registerSSE. It receives the huma context (for setting
// custom response headers before streaming starts), the parsed input,
@@ -130,6 +155,13 @@ func writeSSE(w http.ResponseWriter, eventType string, id any, data []byte) {
}
}
+func writeSSEWithoutID(w http.ResponseWriter, eventType string, data []byte) {
+ fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, data) //nolint:errcheck
+ if err := http.NewResponseController(w).Flush(); err != nil {
+ _ = err
+ }
+}
+
// writeSSEComment emits a keepalive comment frame and flushes.
func writeSSEComment(w http.ResponseWriter) {
fmt.Fprintf(w, ": keepalive\n\n") //nolint:errcheck
@@ -155,7 +187,7 @@ func registerSSEStringID[I any](
stream StringIDStreamFunc[I],
) {
normalizeSSEResponseHeaders(&op)
- typeToEvent := attachSSEResponseSchema(api, &op, eventTypeMap, huma.TypeString, "The event ID (composite cursor).")
+ typeToEvent := attachSSEResponseSchema(api, &op, eventTypeMap, huma.TypeString, "The event resume cursor.")
huma.Register(api, op, func(ctx context.Context, input *I) (*huma.StreamResponse, error) {
if precheck != nil {
diff --git a/internal/api/state.go b/internal/api/state.go
index adbba1b9b3..c01c5ffbd4 100644
--- a/internal/api/state.go
+++ b/internal/api/state.go
@@ -330,10 +330,15 @@ type StateMutator interface {
// when non-nil, is called record-then-create at each resource-creation
// checkpoint (before the clone with CreatedDir set; after init with any
// minted DoltDB) so the caller can persist the G14 rollback manifest and
- // capture it for teardown. It returns the provisioned rig so the caller can
- // report its resolved prefix/branch. This is the async server-side rig-add
- // path (C4b/C4c); the sync CreateRig stays git-blind.
- ProvisionRigFromGit(ctx context.Context, r config.Rig, gitURL string, onStep func(step, detail string, warn bool), onManifest func(RigProvisionManifest)) (config.Rig, error)
+ // capture it for teardown. onManifest's error is load-bearing at the
+ // pre-clone checkpoint: if durable persistence of CreatedDir fails there,
+ // ProvisionRigFromGit MUST abort before cloning (fail closed) rather than
+ // create an unmanifested directory the boot sweep and re-clone pre-drop
+ // cannot discover — leaving it would wedge the request_id/name. It returns
+ // the provisioned rig so the caller can report its resolved prefix/branch.
+ // This is the async server-side rig-add path (C4b/C4c); the sync CreateRig
+ // stays git-blind.
+ ProvisionRigFromGit(ctx context.Context, r config.Rig, gitURL string, onStep func(step, detail string, warn bool), onManifest func(RigProvisionManifest) error) (config.Rig, error)
// TeardownPartialRig removes the created rig working tree and drops the
// managed Dolt database named in the manifest (best-effort), then repairs
diff --git a/internal/api/status_warm.go b/internal/api/status_warm.go
index 7164cd2756..eb1552e68a 100644
--- a/internal/api/status_warm.go
+++ b/internal/api/status_warm.go
@@ -109,6 +109,15 @@ func (s *Server) buildAndStoreStatus(lite bool) StatusBody {
// leaks until it returns; making the reads ctx-cancellable is the
// separate root fix (vp-e0hv plan, fix 2).
s.statusBuildSF.Forget(key)
+ // MERGE INTENT (v1.4.0 resync): forget the INNER store-health flight too.
+ // The fork's wedged-leader escape predates upstream's storeHealthFlight
+ // coalescing, so it only forgot this outer key. A build wedged inside
+ // cachedStoreHealth leaves a live "refresh" leader behind, and the next
+ // attempt — started precisely because we forgot the outer key — joins
+ // that wedged inner leader and hangs again. Forgetting only one of the
+ // two nested flights makes the escape hatch a no-op for exactly the case
+ // it exists to handle.
+ s.storeHealthFlight.Forget(storeHealthFlightKey)
if entry, ok := s.warmStatusBody(lite); ok {
return entry.body
}
diff --git a/internal/api/status_warm_test.go b/internal/api/status_warm_test.go
index 04cdc4ba9b..e8c8e99753 100644
--- a/internal/api/status_warm_test.go
+++ b/internal/api/status_warm_test.go
@@ -123,7 +123,7 @@ func TestHandleStatusRefreshesAgedWarmBody(t *testing.T) {
func TestBuildAndStoreStatusRecoversFromBuildPanic(t *testing.T) {
state := newFakeState(t)
s := &Server{state: state}
- s.storeHealthComputer = func(context.Context) *StatusStoreHealth {
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
panic("simulated build panic")
}
@@ -178,9 +178,9 @@ func TestBuildAndStoreStatusEscapesWedgedBuild(t *testing.T) {
unblock := make(chan struct{})
t.Cleanup(func() { close(unblock) })
s.storeHealthMu.Lock()
- s.storeHealthComputer = func(context.Context) *StatusStoreHealth {
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
<-unblock
- return &StatusStoreHealth{SizeBytes: 1}
+ return &StatusStoreHealth{SizeBytes: 1}, nil
}
s.storeHealthMu.Unlock()
@@ -195,8 +195,8 @@ func TestBuildAndStoreStatusEscapesWedgedBuild(t *testing.T) {
// Swap in a non-blocking computer under the same lock the wedged
// goroutine already read past, so this reassignment cannot race it.
s.storeHealthMu.Lock()
- s.storeHealthComputer = func(context.Context) *StatusStoreHealth {
- return &StatusStoreHealth{SizeBytes: 2}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ return &StatusStoreHealth{SizeBytes: 2}, nil
}
s.storeHealthMu.Unlock()
diff --git a/internal/api/store_health.go b/internal/api/store_health.go
index b636eed7d6..7018ab0769 100644
--- a/internal/api/store_health.go
+++ b/internal/api/store_health.go
@@ -2,6 +2,8 @@ package api
import (
"context"
+ "errors"
+ "fmt"
"time"
"github.com/gastownhall/gascity/internal/beads"
@@ -9,55 +11,91 @@ import (
)
// storeHealthCacheTTL is the refresh interval for the /v0/status
-// StoreHealth block. The underlying inputs (directory size walk,
-// maintenance-log read) are cheap enough to run every minute but
-// running them on every dashboard poll is wasteful.
-const storeHealthCacheTTL = 30 * time.Second
+// StoreHealth block. Its inputs include a full closed-history row scan
+// whose cost grows with store history and can exceed a minute on a
+// long-lived city, so keep the interval above the worst observed scan.
+const storeHealthCacheTTL = 3 * time.Minute
// cachedStoreHealth returns the memoized StoreHealth block, refreshing
-// when the TTL has elapsed. Safe for concurrent callers.
-func (s *Server) cachedStoreHealth(ctx context.Context, now time.Time) *StatusStoreHealth {
- s.storeHealthMu.Lock()
- if s.storeHealthEntry != nil && now.Before(s.storeHealthExpires) {
- entry := s.storeHealthEntry
- s.storeHealthMu.Unlock()
- return entry
- }
- compute := s.storeHealthComputer
- if compute == nil {
- compute = s.computeStoreHealth
+// when the TTL has elapsed. Concurrent refreshes are coalesced through a
+// singleflight group so a single scan serves every waiting caller. Failed
+// refreshes are returned to the caller and are not cached. Safe for
+// concurrent callers.
+// storeHealthFlightKey is the singleflight key coalescing concurrent
+// store-health refreshes. Named so the wedged-build escape in
+// buildAndStoreStatus can Forget the same key this function registers —
+// see the MERGE INTENT note there.
+const storeHealthFlightKey = "refresh"
+
+func (s *Server) cachedStoreHealth(ctx context.Context, now time.Time) (*StatusStoreHealth, error) {
+ if entry := s.cachedStoreHealthEntry(now); entry != nil {
+ return entry, nil
}
- s.storeHealthMu.Unlock()
- h := compute(ctx)
+ value, err, _ := s.storeHealthFlight.Do(storeHealthFlightKey, func() (any, error) {
+ // Another refresh may have completed between this caller's initial
+ // miss and its election into the singleflight group.
+ if entry := s.cachedStoreHealthEntry(time.Now()); entry != nil {
+ return entry, nil
+ }
+
+ s.storeHealthMu.Lock()
+ compute := s.storeHealthComputer
+ if compute == nil {
+ compute = s.computeStoreHealth
+ }
+ s.storeHealthMu.Unlock()
+
+ // The refresh is shared by every concurrent status request, so its
+ // lifetime must not depend on whichever request won the flight. The
+ // store read applies its own bounded timeout downstream.
+ health, err := compute(context.WithoutCancel(ctx))
+ if err != nil {
+ return nil, err
+ }
+ completedAt := time.Now()
+ s.storeHealthMu.Lock()
+ s.storeHealthEntry = health
+ s.storeHealthExpires = completedAt.Add(storeHealthCacheTTL)
+ s.storeHealthMu.Unlock()
+ return health, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return value.(*StatusStoreHealth), nil
+}
+
+func (s *Server) cachedStoreHealthEntry(now time.Time) *StatusStoreHealth {
s.storeHealthMu.Lock()
defer s.storeHealthMu.Unlock()
if s.storeHealthEntry != nil && now.Before(s.storeHealthExpires) {
return s.storeHealthEntry
}
- s.storeHealthEntry = h
- s.storeHealthExpires = now.Add(storeHealthCacheTTL)
- return h
+ return nil
}
// computeStoreHealth measures the Dolt store on disk and the latest
// gc.store.maintenance event via the server's State. Returns nil when
// the city path is empty (no state to measure against).
-func (s *Server) computeStoreHealth(ctx context.Context) *StatusStoreHealth {
+func (s *Server) computeStoreHealth(ctx context.Context) (*StatusStoreHealth, error) {
cityPath := s.state.CityPath()
if cityPath == "" {
- return nil
+ return nil, nil
}
// WalkSize is a synchronous, uncancellable disk walk; the
// storeHealthCacheTTL cache bounds how often it runs. Plumbing
// context/timeout through WalkSize is deferred until it shows up
// in profiles.
size := storehealth.WalkSize(storehealth.StorePath(cityPath))
- rows := countBeadStoreRows(ctx, s.state, s.state.CityBeadStore())
+ rows, err := countBeadStoreRows(ctx, s.state, s.state.CityBeadStore())
+ if err != nil {
+ return nil, err
+ }
lastAt, lastStatus := storehealth.LastMaintenance(s.state.EventProvider())
h := storehealth.Compute(cityPath, size, rows, lastAt, lastStatus)
- return statusStoreHealthFromDomain(h)
+ return statusStoreHealthFromDomain(h), nil
}
// statusStoreHealthFromDomain adapts storehealth.Health to the wire
@@ -78,21 +116,39 @@ func statusStoreHealthFromDomain(h storehealth.Health) *StatusStoreHealth {
return out
}
-// countBeadStoreRows returns the number of beads in store. Zero when
-// store is nil or the scan fails — the ratio is best-effort. The
-// closed-inclusive query is never answerable from the in-memory cache,
-// so this path always hydrates; counting closed history without
-// hydration needs backend support (#1896 follow-up). Because it always
-// hydrates, this is the store-health block's exposure to ga-cdmx6x's
-// bd-child leak; statusListStoreWithTimeout's state.ScopedStoreLike wiring
-// covers it the same way as the work-count fallback.
-func countBeadStoreRows(ctx context.Context, state State, store beads.Store) int {
+// countBeadStoreRows returns the number of retained beads in store, including
+// open and closed beads. A nil store and measurement failures are returned as
+// errors so callers do not mistake an unavailable denominator for zero.
+// The closed-inclusive query is never answerable from the in-memory cache, so
+// the count prefers the hydration-free beads.Counter path (the #1896
+// follow-up): hydrating tens of thousands of closed rows cannot finish inside
+// statusStoreReadTimeout on a long-lived city, which left store_health
+// permanently absent. The hydrating List fallback remains for stores without
+// a Counter and for shapes Count reports as unsupported; that path is the
+// store-health block's exposure to ga-cdmx6x's bd-child leak, covered by
+// statusListStoreWithTimeout's state.ScopedStoreLike wiring the same way as
+// the work-count fallback.
+func countBeadStoreRows(ctx context.Context, state State, store beads.Store) (int, error) {
if store == nil {
- return 0
+ return 0, errors.New("counting retained bead rows: store unavailable")
+ }
+ query := beads.ListQuery{AllowScan: true, IncludeClosed: true}
+ if counter, ok := store.(beads.Counter); ok {
+ // cachedStoreHealth strips the request deadline, so bound the count
+ // here the same way the status handler bounds its store reads.
+ reqCtx, cancel := context.WithTimeout(ctx, statusStoreReadTimeout)
+ n, err := counter.Count(reqCtx, query)
+ cancel()
+ if err == nil {
+ return n, nil
+ }
+ if !errors.Is(err, beads.ErrCountUnsupported) {
+ return 0, fmt.Errorf("counting retained bead rows: %w", err)
+ }
}
- list, err := statusListStoreWithTimeout(ctx, state, store, beads.ListQuery{AllowScan: true, IncludeClosed: true})
+ list, err := statusListStoreWithTimeout(ctx, state, store, query)
if err != nil {
- return 0
+ return 0, fmt.Errorf("counting retained bead rows: %w", err)
}
- return len(list)
+ return len(list), nil
}
diff --git a/internal/api/store_health_test.go b/internal/api/store_health_test.go
index 9ecb64c11e..118c6f3af2 100644
--- a/internal/api/store_health_test.go
+++ b/internal/api/store_health_test.go
@@ -3,10 +3,14 @@ package api
import (
"context"
"encoding/json"
+ "errors"
+ "fmt"
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
+ "testing/synctest"
"time"
"github.com/gastownhall/gascity/internal/beads"
@@ -14,77 +18,294 @@ import (
"github.com/gastownhall/gascity/internal/storehealth"
)
-func TestCachedStoreHealthServesMemoized(t *testing.T) {
- var calls int
- want := &StatusStoreHealth{Path: "/c/.beads/dolt", SizeBytes: 123}
- s := &Server{}
- s.storeHealthComputer = func(context.Context) *StatusStoreHealth {
- calls++
- return want
- }
+type storeHealthListErrorStore struct {
+ beads.Store
+ err error
+}
- now := time.Unix(1_000_000, 0)
- got := s.cachedStoreHealth(context.Background(), now)
- if got != want {
- t.Fatalf("cachedStoreHealth = %+v, want %+v", got, want)
- }
- if calls != 1 {
- t.Fatalf("computer called %d times, want 1", calls)
+func (s *storeHealthListErrorStore) List(query beads.ListQuery) ([]beads.Bead, error) {
+ if query.AllowScan && query.IncludeClosed {
+ return nil, s.err
}
+ return s.Store.List(query)
+}
- // Within TTL: no recomputation.
- got2 := s.cachedStoreHealth(context.Background(), now.Add(storeHealthCacheTTL-time.Second))
- if got2 != want {
- t.Fatalf("second cachedStoreHealth = %+v, want %+v", got2, want)
- }
- if calls != 1 {
- t.Fatalf("computer called %d times within TTL, want 1", calls)
- }
+func TestCachedStoreHealthServesMemoized(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ var calls int
+ want := &StatusStoreHealth{Path: "/c/.beads/dolt", SizeBytes: 123}
+ s := &Server{}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ calls++
+ return want, nil
+ }
+
+ got, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Fatalf("cachedStoreHealth: %v", err)
+ }
+ if got != want {
+ t.Fatalf("cachedStoreHealth = %+v, want %+v", got, want)
+ }
+ if calls != 1 {
+ t.Fatalf("computer called %d times, want 1", calls)
+ }
+
+ // Within TTL: no recomputation.
+ <-time.After(storeHealthCacheTTL - time.Second)
+ got2, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Fatalf("second cachedStoreHealth: %v", err)
+ }
+ if got2 != want {
+ t.Fatalf("second cachedStoreHealth = %+v, want %+v", got2, want)
+ }
+ if calls != 1 {
+ t.Fatalf("computer called %d times within TTL, want 1", calls)
+ }
+ })
}
func TestCachedStoreHealthRefreshesAfterTTL(t *testing.T) {
- var calls int
- s := &Server{}
- s.storeHealthComputer = func(context.Context) *StatusStoreHealth {
- calls++
- return &StatusStoreHealth{SizeBytes: int64(calls)}
- }
+ synctest.Test(t, func(t *testing.T) {
+ var calls int
+ s := &Server{}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ calls++
+ return &StatusStoreHealth{SizeBytes: int64(calls)}, nil
+ }
- now := time.Unix(1_000_000, 0)
- _ = s.cachedStoreHealth(context.Background(), now)
- later := now.Add(storeHealthCacheTTL + time.Second)
- got := s.cachedStoreHealth(context.Background(), later)
- if calls != 2 {
- t.Fatalf("computer calls = %d, want 2", calls)
- }
- if got.SizeBytes != 2 {
- t.Fatalf("refreshed entry SizeBytes = %d, want 2", got.SizeBytes)
- }
+ if _, err := s.cachedStoreHealth(context.Background(), time.Now()); err != nil {
+ t.Fatalf("initial cachedStoreHealth: %v", err)
+ }
+ <-time.After(storeHealthCacheTTL + time.Second)
+ got, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Fatalf("refreshed cachedStoreHealth: %v", err)
+ }
+ if calls != 2 {
+ t.Fatalf("computer calls = %d, want 2", calls)
+ }
+ if got.SizeBytes != 2 {
+ t.Fatalf("refreshed entry SizeBytes = %d, want 2", got.SizeBytes)
+ }
+ })
}
-func TestCachedStoreHealthDoesNotHoldMutexDuringRefreshCompute(t *testing.T) {
- s := &Server{}
- canLockDuringCompute := make(chan bool, 1)
- s.storeHealthComputer = func(context.Context) *StatusStoreHealth {
- locked := make(chan struct{})
+func TestCachedStoreHealthConcurrentColdMissesCoalesce(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ const callers = 8
+
+ want := &StatusStoreHealth{Path: "/c/.beads/dolt", SizeBytes: 123}
+ releaseCompute := make(chan struct{})
+ results := make(chan *StatusStoreHealth, callers)
+ var calls atomic.Int32
+
+ s := &Server{}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ calls.Add(1)
+ <-releaseCompute
+ return want, nil
+ }
+
+ for range callers {
+ go func() {
+ got, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Errorf("cachedStoreHealth: %v", err)
+ }
+ results <- got
+ }()
+ }
+
+ // Every caller is now either the elected computer or waiting for that
+ // same in-flight result. No wall-clock sleep is needed to prove overlap.
+ synctest.Wait()
+ computeCalls := calls.Load()
+
+ close(releaseCompute)
+ synctest.Wait()
+
+ for i := range callers {
+ if got := <-results; got != want {
+ t.Errorf("caller %d got cachedStoreHealth = %p, want shared result %p", i, got, want)
+ }
+ }
+ if computeCalls != 1 {
+ t.Errorf("computer calls while %d cold misses overlapped = %d, want 1", callers, computeCalls)
+ }
+ if got := calls.Load(); got != 1 {
+ t.Errorf("final computer calls after %d cold misses completed = %d, want 1", callers, got)
+ }
+ })
+}
+
+func TestCachedStoreHealthConcurrentExpiredMissesCoalesce(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ const callers = 8
+
+ stale := &StatusStoreHealth{SizeBytes: 1}
+ fresh := &StatusStoreHealth{SizeBytes: 2}
+ releaseRefresh := make(chan struct{})
+ results := make(chan *StatusStoreHealth, callers)
+ var calls atomic.Int32
+
+ s := &Server{}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ if calls.Add(1) == 1 {
+ return stale, nil
+ }
+ <-releaseRefresh
+ return fresh, nil
+ }
+
+ primed, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Fatalf("primed cachedStoreHealth: %v", err)
+ }
+ if primed != stale {
+ t.Fatalf("primed cachedStoreHealth = %p, want stale entry %p", primed, stale)
+ }
+ <-time.After(storeHealthCacheTTL)
+
+ for range callers {
+ go func() {
+ got, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Errorf("cachedStoreHealth: %v", err)
+ }
+ results <- got
+ }()
+ }
+
+ synctest.Wait()
+ computeCalls := calls.Load()
+
+ close(releaseRefresh)
+ synctest.Wait()
+
+ for i := range callers {
+ if got := <-results; got != fresh {
+ t.Errorf("caller %d got cachedStoreHealth = %p, want refreshed result %p", i, got, fresh)
+ }
+ }
+ if computeCalls != 2 {
+ t.Errorf("computer calls across prime plus %d expired misses = %d, want 2", callers, computeCalls)
+ }
+ if got := calls.Load(); got != 2 {
+ t.Errorf("final computer calls after %d expired misses completed = %d, want 2", callers, got)
+ }
+ })
+}
+
+func TestCachedStoreHealthRefreshSurvivesLeaderCancellation(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ want := &StatusStoreHealth{SizeBytes: 123}
+ canceledResult := &StatusStoreHealth{SizeBytes: -1}
+ computeStarted := make(chan struct{})
+ releaseCompute := make(chan struct{})
+ results := make(chan *StatusStoreHealth, 2)
+ var calls atomic.Int32
+
+ s := &Server{}
+ s.storeHealthComputer = func(ctx context.Context) (*StatusStoreHealth, error) {
+ calls.Add(1)
+ close(computeStarted)
+ <-releaseCompute
+ if ctx.Err() != nil {
+ return canceledResult, nil
+ }
+ return want, nil
+ }
+
+ leaderCtx, cancelLeader := context.WithCancel(context.Background())
go func() {
- s.storeHealthMu.Lock()
- defer s.storeHealthMu.Unlock()
- close(locked)
+ got, err := s.cachedStoreHealth(leaderCtx, time.Now())
+ if err != nil {
+ t.Errorf("cachedStoreHealth: %v", err)
+ }
+ results <- got
}()
- select {
- case <-locked:
- canLockDuringCompute <- true
- case <-time.After(100 * time.Millisecond):
- canLockDuringCompute <- false
+ <-computeStarted
+ cancelLeader()
+
+ go func() {
+ got, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Errorf("cachedStoreHealth: %v", err)
+ }
+ results <- got
+ }()
+ synctest.Wait()
+
+ close(releaseCompute)
+ synctest.Wait()
+
+ for i := range 2 {
+ if got := <-results; got != want {
+ t.Errorf("caller %d got cachedStoreHealth = %p, want request-independent result %p", i, got, want)
+ }
}
- return &StatusStoreHealth{SizeBytes: 1}
- }
+ if got := calls.Load(); got != 1 {
+ t.Errorf("computer calls with canceled leader and live waiter = %d, want 1", got)
+ }
+ })
+}
- _ = s.cachedStoreHealth(context.Background(), time.Unix(1_000_000, 0))
- if !<-canLockDuringCompute {
- t.Fatal("cachedStoreHealth held storeHealthMu while running the refresh computer")
- }
+func TestCachedStoreHealthTTLStartsAfterComputeCompletes(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ want := &StatusStoreHealth{Path: "/c/.beads/dolt", SizeBytes: 123}
+ var calls atomic.Int32
+
+ s := &Server{}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ calls.Add(1)
+ // Advance virtual time past the TTL while the refresh is running.
+ <-time.After(storeHealthCacheTTL + time.Second)
+ return want, nil
+ }
+
+ first, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Fatalf("first cachedStoreHealth: %v", err)
+ }
+ second, err := s.cachedStoreHealth(context.Background(), time.Now())
+ if err != nil {
+ t.Fatalf("second cachedStoreHealth: %v", err)
+ }
+
+ if first != want || second != want {
+ t.Fatalf("cached results = (%p, %p), want (%p, %p)", first, second, want, want)
+ }
+ if got := calls.Load(); got != 1 {
+ t.Fatalf("computer calls across immediate post-compute read = %d, want 1", got)
+ }
+ })
+}
+
+func TestCachedStoreHealthDoesNotHoldMutexDuringRefreshCompute(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ s := &Server{}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ locked := make(chan struct{})
+ go func() {
+ s.storeHealthMu.Lock()
+ defer s.storeHealthMu.Unlock()
+ close(locked)
+ }()
+ synctest.Wait()
+ select {
+ case <-locked:
+ default:
+ t.Error("cachedStoreHealth held storeHealthMu while running the refresh computer")
+ }
+ return &StatusStoreHealth{SizeBytes: 1}, nil
+ }
+
+ if _, err := s.cachedStoreHealth(context.Background(), time.Now()); err != nil {
+ t.Fatalf("cachedStoreHealth: %v", err)
+ }
+ })
}
func TestStatusStoreHealthFromDomainOmitsEmptyLastGC(t *testing.T) {
@@ -137,7 +358,10 @@ func TestComputeStoreHealthServerIntegration(t *testing.T) {
cityBeadStore: store,
}
s := &Server{state: state}
- got := s.computeStoreHealth(context.Background())
+ got, err := s.computeStoreHealth(context.Background())
+ if err != nil {
+ t.Fatalf("computeStoreHealth: %v", err)
+ }
if got == nil {
t.Fatal("computeStoreHealth returned nil")
}
@@ -168,7 +392,10 @@ func TestComputeStoreHealthUsesDoltlitePathFromMetadata(t *testing.T) {
cityBeadStore: beads.NewMemStore(),
}
s := &Server{state: state}
- got := s.computeStoreHealth(context.Background())
+ got, err := s.computeStoreHealth(context.Background())
+ if err != nil {
+ t.Fatalf("computeStoreHealth: %v", err)
+ }
if got == nil {
t.Fatal("computeStoreHealth returned nil")
}
@@ -180,14 +407,35 @@ func TestComputeStoreHealthUsesDoltlitePathFromMetadata(t *testing.T) {
func TestComputeStoreHealthEmptyCityPath(t *testing.T) {
state := &fakeState{cityPath: ""}
s := &Server{state: state}
- if got := s.computeStoreHealth(context.Background()); got != nil {
+ got, err := s.computeStoreHealth(context.Background())
+ if err != nil {
+ t.Fatalf("computeStoreHealth: %v", err)
+ }
+ if got != nil {
t.Fatalf("computeStoreHealth = %+v, want nil for empty city path", got)
}
}
-func TestCountBeadStoreRowsNil(t *testing.T) {
- if got := countBeadStoreRows(context.Background(), newFakeState(t), nil); got != 0 {
- t.Fatalf("countBeadStoreRows(nil) = %d, want 0", got)
+func TestCountBeadStoreRowsReturnsUnavailableForNilStore(t *testing.T) {
+ got, err := countBeadStoreRows(context.Background(), newFakeState(t), nil)
+ if got != 0 {
+ t.Errorf("countBeadStoreRows(nil) = %d, want zero value when unavailable", got)
+ }
+ if err == nil || !strings.Contains(err.Error(), "unavailable") {
+ t.Fatalf("countBeadStoreRows(nil) error = %v, want unavailable error", err)
+ }
+}
+
+func TestCountBeadStoreRowsReturnsScanError(t *testing.T) {
+ wantErr := errors.New("store health row scan failed")
+ store := &storeHealthListErrorStore{Store: beads.NewMemStore(), err: wantErr}
+
+ got, err := countBeadStoreRows(context.Background(), newFakeState(t), store)
+ if got != 0 {
+ t.Errorf("countBeadStoreRows rows = %d, want zero value when unavailable", got)
+ }
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("countBeadStoreRows error = %v, want %v", err, wantErr)
}
}
@@ -204,13 +452,106 @@ func TestCountBeadStoreRowsIncludesClosedBeads(t *testing.T) {
if err := store.Close(closed.ID); err != nil {
t.Fatalf("Close: %v", err)
}
- if got := countBeadStoreRows(context.Background(), newFakeState(t), store); got != 2 {
+ got, err := countBeadStoreRows(context.Background(), newFakeState(t), store)
+ if err != nil {
+ t.Fatalf("countBeadStoreRows: %v", err)
+ }
+ if got != 2 {
t.Fatalf("countBeadStoreRows = %d, want 2 including closed bead %s and open bead %s", got, closed.ID, open.ID)
}
}
+type storeHealthCounterStore struct {
+ beads.Store
+ count int
+ countErr error
+ gotQuery *beads.ListQuery
+ listCalls int
+}
+
+func (s *storeHealthCounterStore) Count(_ context.Context, query beads.ListQuery, _ ...string) (int, error) {
+ s.gotQuery = &query
+ return s.count, s.countErr
+}
+
+func (s *storeHealthCounterStore) List(query beads.ListQuery) ([]beads.Bead, error) {
+ s.listCalls++
+ return s.Store.List(query)
+}
+
+func TestCountBeadStoreRowsPrefersCounterWithoutHydration(t *testing.T) {
+ store := &storeHealthCounterStore{Store: beads.NewMemStore(), count: 41252}
+
+ got, err := countBeadStoreRows(context.Background(), newFakeState(t), store)
+ if err != nil {
+ t.Fatalf("countBeadStoreRows: %v", err)
+ }
+ if got != 41252 {
+ t.Fatalf("countBeadStoreRows = %d, want Counter result 41252", got)
+ }
+ if store.listCalls != 0 {
+ t.Fatalf("List called %d times, want 0 (Counter path must not hydrate)", store.listCalls)
+ }
+ if store.gotQuery == nil || !store.gotQuery.IncludeClosed || !store.gotQuery.AllowScan {
+ t.Fatalf("Count query = %+v, want AllowScan and IncludeClosed set", store.gotQuery)
+ }
+}
+
+func TestCountBeadStoreRowsFallsBackWhenCountUnsupported(t *testing.T) {
+ store := &storeHealthCounterStore{
+ Store: beads.NewMemStore(),
+ countErr: fmt.Errorf("counting beads: %w", beads.ErrCountUnsupported),
+ }
+ if _, err := store.Create(beads.Bead{Title: "x"}); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ got, err := countBeadStoreRows(context.Background(), newFakeState(t), store)
+ if err != nil {
+ t.Fatalf("countBeadStoreRows: %v", err)
+ }
+ if got != 1 {
+ t.Fatalf("countBeadStoreRows = %d, want 1 from List fallback", got)
+ }
+ if store.listCalls == 0 {
+ t.Fatal("List never called, want hydrating fallback on ErrCountUnsupported")
+ }
+}
+
+func TestCountBeadStoreRowsReturnsCounterError(t *testing.T) {
+ wantErr := errors.New("store health count failed")
+ store := &storeHealthCounterStore{Store: beads.NewMemStore(), countErr: wantErr}
+
+ got, err := countBeadStoreRows(context.Background(), newFakeState(t), store)
+ if got != 0 {
+ t.Errorf("countBeadStoreRows = %d, want zero value on Counter error", got)
+ }
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("countBeadStoreRows error = %v, want %v", err, wantErr)
+ }
+ if store.listCalls != 0 {
+ t.Fatalf("List called %d times after non-unsupported Counter error, want 0", store.listCalls)
+ }
+}
+
+func TestComputeStoreHealthReturnsRowCountError(t *testing.T) {
+ wantErr := errors.New("store health row scan failed")
+ state := newFakeState(t)
+ state.cityBeadStore = &storeHealthListErrorStore{Store: beads.NewMemStore(), err: wantErr}
+ s := &Server{state: state}
+
+ got, err := s.computeStoreHealth(context.Background())
+ if got != nil {
+ t.Errorf("computeStoreHealth = %+v, want nil when row count is unavailable", got)
+ }
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("computeStoreHealth error = %v, want %v", err, wantErr)
+ }
+}
+
func TestBuildStatusBodyIncludesStoreHealth(t *testing.T) {
state := newFakeState(t)
+ state.cityBeadStore = beads.NewMemStore()
s := &Server{state: state}
body := s.buildStatusBody(context.Background(), false)
@@ -225,6 +566,27 @@ func TestBuildStatusBodyIncludesStoreHealth(t *testing.T) {
}
}
+func TestBuildStatusBodyOmitsUnavailableStoreHealthAndReportsPartialError(t *testing.T) {
+ wantErr := errors.New("store health row scan failed")
+ state := newFakeState(t)
+ s := &Server{state: state}
+ s.storeHealthComputer = func(context.Context) (*StatusStoreHealth, error) {
+ return nil, wantErr
+ }
+
+ body := s.buildStatusBody(context.Background(), false)
+ if body.StoreHealth != nil {
+ t.Errorf("StoreHealth = %+v, want omitted when unavailable", body.StoreHealth)
+ }
+ if !body.Partial {
+ t.Error("Partial = false, want true when store health is unavailable")
+ }
+ wantPartialError := "store health: " + wantErr.Error()
+ if len(body.PartialErrors) != 1 || body.PartialErrors[0] != wantPartialError {
+ t.Fatalf("PartialErrors = %q, want [%q]", body.PartialErrors, wantPartialError)
+ }
+}
+
func TestBuildStatusBodyIncludesBeadsDiagnostic(t *testing.T) {
state := newFakeState(t)
state.cityBeadsDiag = &beads.BeadsDiagnostic{
diff --git a/internal/api/structured_leakage_test.go b/internal/api/structured_leakage_test.go
new file mode 100644
index 0000000000..d0d175ccd2
--- /dev/null
+++ b/internal/api/structured_leakage_test.go
@@ -0,0 +1,642 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/runtime"
+ "github.com/gastownhall/gascity/internal/session"
+ "github.com/gastownhall/gascity/internal/worker"
+)
+
+// structuredTranscriptWireAllowedKeys is the allowlist of JSON keys the typed
+// structured transcript response can legitimately serialize. Any key outside it
+// on the wire is a leaked provider-native key.
+func structuredTranscriptWireAllowedKeys() map[string]struct{} {
+ return worker.NeutralWireKeys(reflect.TypeOf(sessionTranscriptGetResponse{}))
+}
+
+// assertNoStructuredWireLeak fails the test if the serialized structured wire
+// carries any provider-native shape. It applies both leakage gates: the
+// canonical provider-native token denylist (plus any case-specific extras) and
+// the schema allowlist, which catches future native keys the denylist does not
+// yet name.
+func assertNoStructuredWireLeak(t *testing.T, wire []byte, extraForbidden ...string) {
+ t.Helper()
+ leaked, err := structuredWireLeakage(wire, extraForbidden...)
+ if err != nil {
+ t.Fatalf("scan structured wire keys: %v", err)
+ }
+ if len(leaked) > 0 {
+ t.Fatalf("structured response leaked provider-native data %v: %s", leaked, wire)
+ }
+}
+
+func structuredWireLeakage(wire []byte, extraForbidden ...string) ([]string, error) {
+ leaked := make(map[string]struct{})
+ for _, token := range extraForbidden {
+ if token != "" && bytes.Contains(wire, []byte(token)) {
+ leaked["forbidden:"+token] = struct{}{}
+ }
+ }
+ unexpected, err := worker.UnexpectedWireKeys(wire, structuredTranscriptWireAllowedKeys())
+ if err != nil {
+ return nil, err
+ }
+ for _, key := range unexpected {
+ leaked["key:"+key] = struct{}{}
+ }
+ var decoded any
+ if err := json.Unmarshal(wire, &decoded); err != nil {
+ return nil, err
+ }
+ collectStructuredArgumentLeakage(decoded, "", leaked)
+ collectStructuredHistoryEnvelopeLeakage(decoded, leaked)
+ if len(leaked) == 0 {
+ return nil, nil
+ }
+ out := make([]string, 0, len(leaked))
+ for item := range leaked {
+ out = append(out, item)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+var neutralStructuredInputArgumentNames = map[string]struct{}{
+ "path": {},
+}
+
+func collectStructuredArgumentLeakage(value any, path string, leaked map[string]struct{}) {
+ switch typed := value.(type) {
+ case map[string]any:
+ for key, child := range typed {
+ childPath := key
+ if path != "" {
+ childPath = path + "." + key
+ }
+ switch key {
+ case "arguments":
+ scanStructuredArguments(child, childPath, true, leaked)
+ case "answers", "counts":
+ scanStructuredArguments(child, childPath, false, leaked)
+ }
+ collectStructuredArgumentLeakage(child, childPath, leaked)
+ }
+ case []any:
+ for i, child := range typed {
+ collectStructuredArgumentLeakage(child, path+"["+strconv.Itoa(i)+"]", leaked)
+ }
+ }
+}
+
+// rawGenerationTokenPattern matches the worker's raw ":" generation
+// token — file-observation evidence that must not reach the structured wire.
+var rawGenerationTokenPattern = regexp.MustCompile(`^\d+:\d+$`)
+
+// collectStructuredHistoryEnvelopeLeakage flags server-only filesystem evidence
+// that legitimate envelope KEYS can smuggle as VALUES. The key-allowlist gate
+// accepts transcript_stream_id and generation because they are real fields; it
+// cannot see that transcript_stream_id must never be an absolute server path, or
+// that generation must not carry the raw file mtime:size. A path separator in
+// the stream identity, a bare ":" generation id, or a populated
+// observed_at is such a leak. This is envelope-scoped, so it never
+// false-positives on the legitimate file paths that appear inside tool
+// inputs/results, which are real transcript content rather than server metadata.
+func collectStructuredHistoryEnvelopeLeakage(value any, leaked map[string]struct{}) {
+ root, ok := value.(map[string]any)
+ if !ok {
+ return
+ }
+ history, ok := root["history"].(map[string]any)
+ if !ok {
+ return
+ }
+ if streamID, ok := history["transcript_stream_id"].(string); ok && strings.ContainsAny(streamID, `/\`) {
+ leaked["history.transcript_stream_id:server_path"] = struct{}{}
+ }
+ generation, ok := history["generation"].(map[string]any)
+ if !ok {
+ return
+ }
+ if id, ok := generation["id"].(string); ok && rawGenerationTokenPattern.MatchString(id) {
+ leaked["history.generation.id:raw_mtime_size"] = struct{}{}
+ }
+ if observed, ok := generation["observed_at"].(string); ok && observed != "" {
+ leaked["history.generation.observed_at:file_mtime"] = struct{}{}
+ }
+}
+
+func scanStructuredArguments(value any, path string, restrictNames bool, leaked map[string]struct{}) {
+ items, ok := value.([]any)
+ if !ok {
+ leaked[path+":not_array"] = struct{}{}
+ return
+ }
+ for i, item := range items {
+ itemPath := path + "[" + strconv.Itoa(i) + "]"
+ argument, ok := item.(map[string]any)
+ if !ok {
+ leaked[itemPath+":not_object"] = struct{}{}
+ continue
+ }
+ name, nameOK := argument["name"].(string)
+ if !nameOK || strings.TrimSpace(name) == "" {
+ leaked[itemPath+".name:missing"] = struct{}{}
+ } else {
+ if restrictNames {
+ if _, allowed := neutralStructuredInputArgumentNames[name]; !allowed {
+ leaked[itemPath+".name:"+name] = struct{}{}
+ }
+ }
+ scanStructuredArgumentTokens(name, itemPath+".name", leaked)
+ }
+
+ argumentValue, exists := argument["value"]
+ if !exists {
+ leaked[itemPath+".value:missing"] = struct{}{}
+ continue
+ }
+ valueText, valueOK := argumentValue.(string)
+ if !valueOK {
+ switch argumentValue.(type) {
+ case map[string]any:
+ leaked[itemPath+".value:json_object"] = struct{}{}
+ case []any:
+ leaked[itemPath+".value:json_array"] = struct{}{}
+ default:
+ leaked[itemPath+".value:not_string"] = struct{}{}
+ }
+ continue
+ }
+ scanStructuredArgumentTokens(valueText, itemPath+".value", leaked)
+ if kind := jsonStringContainerKind(valueText); kind != "" {
+ leaked[itemPath+".value:"+kind] = struct{}{}
+ }
+ }
+}
+
+func scanStructuredArgumentTokens(value, path string, leaked map[string]struct{}) {
+ for _, token := range worker.ProviderNativeForbiddenTokens() {
+ if token != "" && strings.Contains(value, token) {
+ leaked[path+":provider_token="+token] = struct{}{}
+ }
+ }
+}
+
+func jsonStringContainerKind(value string) string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" || (trimmed[0] != '{' && trimmed[0] != '[') {
+ return ""
+ }
+ var decoded any
+ if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
+ return ""
+ }
+ switch decoded.(type) {
+ case map[string]any:
+ return "json_object"
+ case []any:
+ return "json_array"
+ default:
+ return ""
+ }
+}
+
+// TestStructuredWireTypesHaveNoMapFields enforces the load-bearing assumption
+// behind the allowlist leakage gate: the structured wire payload must contain no
+// map fields. NeutralWireKeys cannot enumerate a map's dynamic keys, so if one
+// is added the allowlist would silently miss provider-native keys nested inside
+// it. If this fails, exclude the new map subtree before calling
+// UnexpectedWireKeys (and update assertNoStructuredWireLeak accordingly).
+func TestStructuredWireTypesHaveNoMapFields(t *testing.T) {
+ roots := []reflect.Type{
+ reflect.TypeOf(SessionStructuredHistory{}),
+ reflect.TypeOf(SessionStructuredMessage{}),
+ reflect.TypeOf(SessionStreamStructuredMessageEvent{}),
+ }
+ for _, root := range roots {
+ if path := firstMapField(root, map[reflect.Type]struct{}{}, root.Name()); path != "" {
+ t.Fatalf("structured wire type carries a map field at %s; the allowlist leakage gate cannot enumerate its dynamic keys", path)
+ }
+ }
+}
+
+// firstMapField returns the dotted path to the first map-typed field reachable
+// from t, or "" if none exists.
+func firstMapField(t reflect.Type, seen map[reflect.Type]struct{}, path string) string {
+ for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
+ t = t.Elem()
+ }
+ if t.Kind() == reflect.Map {
+ return path
+ }
+ if t.Kind() != reflect.Struct {
+ return ""
+ }
+ if _, ok := seen[t]; ok {
+ return ""
+ }
+ seen[t] = struct{}{}
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if field.PkgPath != "" {
+ continue
+ }
+ if hit := firstMapField(field.Type, seen, path+"."+field.Name); hit != "" {
+ return hit
+ }
+ }
+ return ""
+}
+
+// Inline-subagent lineage is intentionally outside session.structured.v1.
+// Keep the reserved fields off the v1 wire until the versioned follow-up has
+// real provider evidence and end-to-end coverage (ga-mb46n3).
+func TestStructuredV1ExcludesInlineSubagentLineage(t *testing.T) {
+ typ := reflect.TypeOf(SessionStructuredMessage{})
+ for _, field := range []string{"IsSubagent", "ParentToolCallID"} {
+ if _, ok := typ.FieldByName(field); ok {
+ t.Fatalf("SessionStructuredMessage still exposes v1 lineage field %s", field)
+ }
+ }
+}
+
+func TestStructuredLeakageGateCatchesInjectedNativeKey(t *testing.T) {
+ clean := sessionTranscriptGetResponse{
+ ID: "s1",
+ Template: "Chat",
+ Provider: "claude",
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ StructuredMessages: structuredMessagesField([]SessionStructuredMessage{{
+ ID: "m1",
+ Role: "assistant",
+ Status: "final",
+ Blocks: []SessionStructuredBlock{{
+ Type: "tool_result",
+ ToolCallID: "call-1",
+ Structured: &SessionStructuredToolResult{Kind: "edit", FilePath: "a.go", Patch: "@@ -1 +1 @@"},
+ }},
+ }}),
+ }
+ wire, err := json.Marshal(clean)
+ if err != nil {
+ t.Fatalf("marshal clean response: %v", err)
+ }
+
+ // Baseline: a real typed response passes both gates.
+ assertNoStructuredWireLeak(t, wire)
+
+ // A known provider-native key must be caught by BOTH gates.
+ if leaked := worker.ScanForbiddenTokens(injectWireKey(t, wire, "toolUseResult")); len(leaked) == 0 {
+ t.Fatal("denylist gate failed to catch injected toolUseResult")
+ }
+ if unexpected, _ := worker.UnexpectedWireKeys(injectWireKey(t, wire, "toolUseResult"), structuredTranscriptWireAllowedKeys()); len(unexpected) == 0 {
+ t.Fatal("allowlist gate failed to catch injected toolUseResult")
+ }
+
+ // A novel native key the denylist has never seen must still be caught by
+ // the allowlist gate — this is the future-proofing the denylist cannot give.
+ novel := injectWireKey(t, wire, "someBrandNewProviderKey")
+ if leaked := worker.ScanForbiddenTokens(novel); len(leaked) > 0 {
+ t.Fatalf("denylist unexpectedly matched a novel key: %v", leaked)
+ }
+ unexpected, err := worker.UnexpectedWireKeys(novel, structuredTranscriptWireAllowedKeys())
+ if err != nil {
+ t.Fatalf("scan novel wire: %v", err)
+ }
+ if len(unexpected) != 1 || unexpected[0] != "someBrandNewProviderKey" {
+ t.Fatalf("allowlist gate must catch a novel non-schema key, got %v", unexpected)
+ }
+}
+
+// TestStructuredHistoryWireHidesServerPathAndGeneration pins the value-level
+// contract Finding 3 raised: the structured history envelope must never emit the
+// absolute server transcript path or the raw mtime:size generation data. The
+// key-level allowlist gate cannot catch this because transcript_stream_id and
+// generation are legitimate keys — only their VALUES leak.
+func TestStructuredHistoryWireHidesServerPathAndGeneration(t *testing.T) {
+ rawPath := "/home/ubuntu/.claude/projects/-data-projects-secret/9f1c2d3e-uuid.jsonl"
+ snapshot := &worker.HistorySnapshot{
+ GCSessionID: "gc-session-1",
+ LogicalConversationID: "logical-1",
+ ProviderSessionID: "provider-uuid-1",
+ TranscriptStreamID: rawPath,
+ Generation: worker.Generation{ID: "1749123456789012345:20481", ObservedAt: time.Date(2026, 6, 1, 2, 3, 4, 0, time.UTC)},
+ Cursor: worker.Cursor{AfterEntryID: "entry-1"},
+ Continuity: worker.Continuity{Status: worker.ContinuityStatusContinuous},
+ TailState: worker.TailState{Activity: worker.TailActivityIdle, LastEntryID: "entry-1"},
+ }
+ history := structuredHistoryFromSnapshot(snapshot)
+ if history == nil {
+ t.Fatal("structuredHistoryFromSnapshot returned nil")
+ }
+ wire, err := json.Marshal(history)
+ if err != nil {
+ t.Fatalf("marshal history: %v", err)
+ }
+
+ // The absolute path, its directory segments, and the raw mtime:size must not
+ // appear anywhere on the wire.
+ for _, secret := range []string{rawPath, "/home/ubuntu", ".claude/projects", "-data-projects-secret", "20481", "1749123456789012345"} {
+ if bytes.Contains(wire, []byte(secret)) {
+ t.Fatalf("structured history wire leaked server data %q: %s", secret, wire)
+ }
+ }
+ // transcript_stream_id is an opaque, path-free identity: non-empty, not the
+ // raw path, and carrying no filesystem separator.
+ if history.TranscriptStreamID == "" || history.TranscriptStreamID == rawPath || strings.ContainsAny(history.TranscriptStreamID, `/\`) {
+ t.Fatalf("transcript_stream_id is not an opaque identity: %q", history.TranscriptStreamID)
+ }
+ // generation carries no raw mtime:size and no observed_at timestamp.
+ if history.Generation.ObservedAt != "" {
+ t.Fatalf("generation.observed_at leaked file mtime: %q", history.Generation.ObservedAt)
+ }
+ if history.Generation.ID == snapshot.Generation.ID || rawGenerationTokenPattern.MatchString(history.Generation.ID) {
+ t.Fatalf("generation.id still carries raw mtime:size: %q", history.Generation.ID)
+ }
+ // The reusable leak gate now also bites on an enveloped path/mtime value.
+ if leaked, _ := structuredWireLeakage(wire); len(leaked) != 0 {
+ t.Fatalf("sanitized history still flagged by leak gate: %v", leaked)
+ }
+
+ // Opaque identity is deterministic for a given stream and rotation-sensitive.
+ if again := structuredHistoryFromSnapshot(snapshot); again.TranscriptStreamID != history.TranscriptStreamID || again.Generation.ID != history.Generation.ID {
+ t.Fatal("opaque identity is not deterministic for the same stream")
+ }
+ rotated := *snapshot
+ rotated.TranscriptStreamID = rawPath + ".rotated"
+ if structuredHistoryFromSnapshot(&rotated).TranscriptStreamID == history.TranscriptStreamID {
+ t.Fatal("transcript_stream_id did not change across transcript rotation")
+ }
+}
+
+// TestStructuredHistoryEnvelopeLeakGateCatchesRawPathAndGeneration proves the
+// envelope value-leak gate actually bites, so the sanitization above cannot
+// silently regress without a test failing.
+func TestStructuredHistoryEnvelopeLeakGateCatchesRawPathAndGeneration(t *testing.T) {
+ leakyWire := []byte(`{"history":{"transcript_stream_id":"/home/ubuntu/.claude/x.jsonl","generation":{"id":"1749123456789012345:20481","observed_at":"2026-06-01T02:03:04Z"}}}`)
+ leaked, err := structuredWireLeakage(leakyWire)
+ if err != nil {
+ t.Fatalf("scan leaky wire: %v", err)
+ }
+ for _, want := range []string{"history.transcript_stream_id:server_path", "history.generation.id:raw_mtime_size", "history.generation.observed_at:file_mtime"} {
+ if !stringSliceContainsSubstring(leaked, want) {
+ t.Fatalf("envelope leak gate missed %q, got %v", want, leaked)
+ }
+ }
+}
+
+func TestStructuredLeakageScanRejectsGenericArgumentCarriers(t *testing.T) {
+ tests := []struct {
+ name string
+ argument SessionStructuredArgument
+ wantLeak string
+ }{
+ {
+ name: "unknown input argument name",
+ argument: SessionStructuredArgument{Name: "scope", Value: "web"},
+ wantLeak: "arguments[0].name",
+ },
+ {
+ name: "encoded object value",
+ argument: SessionStructuredArgument{Name: "path", Value: `{"query":"provider-owned"}`},
+ wantLeak: "arguments[0].value:json_object",
+ },
+ {
+ name: "encoded array value",
+ argument: SessionStructuredArgument{Name: "path", Value: `["provider-owned"]`},
+ wantLeak: "arguments[0].value:json_array",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ response := structuredLeakageTestResponse(SessionStructuredBlock{
+ Type: "tool_use",
+ ID: "call-1",
+ Input: &SessionStructuredToolInput{
+ Kind: "arguments",
+ Arguments: []SessionStructuredArgument{tt.argument},
+ },
+ })
+ wire, err := json.Marshal(response)
+ if err != nil {
+ t.Fatalf("marshal structured response: %v", err)
+ }
+ leaked, err := structuredWireLeakage(wire)
+ if err != nil {
+ t.Fatalf("scan structured response: %v", err)
+ }
+ if !stringSliceContainsSubstring(leaked, tt.wantLeak) {
+ t.Fatalf("structuredWireLeakage() = %v, want leak containing %q", leaked, tt.wantLeak)
+ }
+ })
+ }
+}
+
+func TestStructuredLeakageScanAllowsTypedJSONText(t *testing.T) {
+ providerLookingText := `{"toolUseResult":{"source":"user-authored","type":"example"}}`
+ response := structuredLeakageTestResponse(
+ SessionStructuredBlock{Type: "text", Text: providerLookingText},
+ SessionStructuredBlock{
+ Type: "tool_use",
+ ID: "call-1",
+ Input: &SessionStructuredToolInput{
+ Kind: "code",
+ Code: providerLookingText,
+ Command: providerLookingText,
+ Text: providerLookingText,
+ },
+ },
+ SessionStructuredBlock{
+ Type: "tool_result",
+ ToolCallID: "call-1",
+ Content: providerLookingText,
+ Structured: &SessionStructuredToolResult{
+ Kind: "bash",
+ Text: providerLookingText,
+ Stdout: providerLookingText,
+ },
+ },
+ )
+ wire, err := json.Marshal(response)
+ if err != nil {
+ t.Fatalf("marshal structured response: %v", err)
+ }
+ leaked, err := structuredWireLeakage(wire)
+ if err != nil {
+ t.Fatalf("scan structured response: %v", err)
+ }
+ if leaked != nil {
+ t.Fatalf("legitimate typed JSON text flagged as provider leakage: %v", leaked)
+ }
+}
+
+func TestStructuredRawResponsePreservesProviderNativeFrame(t *testing.T) {
+ raw := json.RawMessage(`{"timestamp":9007199254740993,"type":"response_item","payload":{"action":{"type":"search","source":"web"},"scope":"web"}}`)
+ response := sessionTranscriptGetResponse{
+ ID: "s1",
+ Template: "Chat",
+ Provider: "codex",
+ Format: "raw",
+ Messages: rawMessagesField([]SessionRawMessageFrame{{Raw: raw}}),
+ }
+ wire, err := json.Marshal(response)
+ if err != nil {
+ t.Fatalf("marshal raw response: %v", err)
+ }
+ var envelope struct {
+ Messages []json.RawMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(wire, &envelope); err != nil {
+ t.Fatalf("decode raw response envelope: %v", err)
+ }
+ if len(envelope.Messages) != 1 || !bytes.Equal(envelope.Messages[0], raw) {
+ t.Fatalf("raw frame = %s, want exact provider frame %s", envelope.Messages, raw)
+ }
+}
+
+func TestStructuredCodexWebSearchOmitsNativeInputAndRawPreservesIt(t *testing.T) {
+ isolateProviderDiscovery(t)
+ fs := newSessionFakeState(t)
+ searchBase := t.TempDir()
+ srv := New(fs)
+ h := newTestCityHandlerWith(t, fs, srv)
+ srv.sessionLogSearchPaths = []string{searchBase}
+
+ mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp)
+ workDir := t.TempDir()
+ info, err := mgr.CreateSession(context.Background(), session.CreateOptions{
+ Template: "myrig/worker",
+ Title: "Chat",
+ Command: "codex",
+ WorkDir: workDir,
+ Provider: "codex",
+ Resume: session.ProviderResume{
+ ResumeFlag: "--resume",
+ ResumeStyle: "flag",
+ SessionIDFlag: "--session-id",
+ },
+ Hints: runtime.Config{},
+ ExtraMeta: map[string]string{"session_origin": "manual"},
+ })
+ if err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ writeStructuredCodexWebSearchFixture(t, searchBase, info.WorkDir, info.SessionKey)
+
+ structuredRecorder := httptest.NewRecorder()
+ structuredRequest := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=structured&tail=0", nil)
+ h.ServeHTTP(structuredRecorder, structuredRequest)
+ if structuredRecorder.Code != http.StatusOK {
+ t.Fatalf("structured status = %d, want %d; body: %s", structuredRecorder.Code, http.StatusOK, structuredRecorder.Body.String())
+ }
+ var structured sessionTranscriptGetResponse
+ if err := json.Unmarshal(structuredRecorder.Body.Bytes(), &structured); err != nil {
+ t.Fatalf("decode structured response: %v", err)
+ }
+ toolUse, _ := findStructuredToolPair(structuredTranscriptMessages(structured), "call-codex-web-search")
+ if toolUse == nil || toolUse.Input == nil {
+ t.Fatalf("structured response missing web-search input: %+v", structuredTranscriptMessages(structured))
+ }
+ if toolUse.Input.Kind != "search" || toolUse.Input.Query != "structured tool result formats" {
+ t.Fatalf("structured input = %+v, want neutral search query", toolUse.Input)
+ }
+ if toolUse.Input.Text != "" || len(toolUse.Input.Arguments) != 0 {
+ t.Fatalf("structured input leaked fallback carriers: %+v", toolUse.Input)
+ }
+ assertNoStructuredWireLeak(t, structuredRecorder.Body.Bytes())
+ for _, native := range []string{`"action"`, `"scope"`, `"source"`} {
+ if bytes.Contains(structuredRecorder.Body.Bytes(), []byte(native)) {
+ t.Fatalf("structured response leaked native field %s: %s", native, structuredRecorder.Body.Bytes())
+ }
+ }
+
+ rawRecorder := httptest.NewRecorder()
+ rawRequest := httptest.NewRequest("GET", cityURL(fs, "/session/")+info.ID+"/transcript?format=raw&tail=0", nil)
+ h.ServeHTTP(rawRecorder, rawRequest)
+ if rawRecorder.Code != http.StatusOK {
+ t.Fatalf("raw status = %d, want %d; body: %s", rawRecorder.Code, http.StatusOK, rawRecorder.Body.String())
+ }
+ var rawResponse sessionTranscriptGetResponse
+ if err := json.Unmarshal(rawRecorder.Body.Bytes(), &rawResponse); err != nil {
+ t.Fatalf("decode raw response: %v", err)
+ }
+ wantRaw := []byte(`{"timestamp":"2026-06-01T00:04:01Z","type":"response_item","payload":{"type":"web_search_call","id":"call-codex-web-search","query":"structured tool result formats","input":{"query":"ignored fallback","scope":"web"},"action":{"type":"search","source":"web"}}}`)
+ foundExact := false
+ for _, frame := range rawTranscriptMessages(rawResponse) {
+ if bytes.Equal(frame.Raw, wantRaw) {
+ foundExact = true
+ break
+ }
+ }
+ if !foundExact {
+ t.Fatalf("raw response did not preserve exact provider web-search frame: %s", rawRecorder.Body.Bytes())
+ }
+}
+
+func structuredLeakageTestResponse(blocks ...SessionStructuredBlock) sessionTranscriptGetResponse {
+ return sessionTranscriptGetResponse{
+ ID: "s1",
+ Template: "Chat",
+ Provider: "codex",
+ Format: "structured",
+ SchemaVersion: sessionStructuredSchemaVersion,
+ Operation: "snapshot",
+ StructuredMessages: structuredMessagesField([]SessionStructuredMessage{representativeStructuredMessage(blocks...)}),
+ }
+}
+
+func representativeStructuredMessage(blocks ...SessionStructuredBlock) SessionStructuredMessage {
+ return SessionStructuredMessage{
+ ID: "m1",
+ Role: "assistant",
+ Status: "final",
+ Blocks: blocks,
+ }
+}
+
+func stringSliceContainsSubstring(values []string, want string) bool {
+ for _, value := range values {
+ if strings.Contains(value, want) {
+ return true
+ }
+ }
+ return false
+}
+
+// injectWireKey decodes the wire, adds key (with a sentinel value) to the first
+// structured block, and re-encodes it — simulating a provider-native key
+// leaking into the structured projection.
+func injectWireKey(t *testing.T, wire []byte, key string) []byte {
+ t.Helper()
+ var doc map[string]any
+ if err := json.Unmarshal(wire, &doc); err != nil {
+ t.Fatalf("unmarshal wire: %v", err)
+ }
+ messages, ok := doc["structured_messages"].([]any)
+ if !ok || len(messages) == 0 {
+ t.Fatalf("wire has no structured_messages to inject into: %s", wire)
+ }
+ message := messages[0].(map[string]any)
+ blocks := message["blocks"].([]any)
+ block := blocks[0].(map[string]any)
+ block[key] = "leak"
+ out, err := json.Marshal(doc)
+ if err != nil {
+ t.Fatalf("re-marshal injected wire: %v", err)
+ }
+ return out
+}
diff --git a/internal/api/supervisor.go b/internal/api/supervisor.go
index 51efbf17d7..39f0311f24 100644
--- a/internal/api/supervisor.go
+++ b/internal/api/supervisor.go
@@ -302,8 +302,11 @@ func (sm *SupervisorMux) WithAPIPlane(h http.Handler) *SupervisorMux {
return sm
}
-// WithRunCensusSource supplies the incremental projection used by the typed
-// row-free run census endpoint. It must be called before Serve.
+// WithRunCensusSource supplies the incremental projection used by typed run
+// reads. The required contract serves the row-free census; a source that also
+// implements RunProjectionSource and RunProjectionGraceSource supplies the
+// warm list/detail/steps snapshots and point-read warming grace. It must be
+// called before Serve.
func (sm *SupervisorMux) WithRunCensusSource(source RunCensusSource) *SupervisorMux {
sm.runCensusSource = source
return sm
@@ -477,9 +480,14 @@ func (sm *SupervisorMux) getCityServer(name string, state State) *Server {
srv.runCensusSource = sm.runCensusSource
sm.cacheMu.Lock()
+ defer sm.cacheMu.Unlock()
+ // A concurrent miss may have installed a Server for this State while this
+ // candidate was being built. Return the published instance so per-city
+ // caches and refresh coalescing remain process-unique.
+ if cached, ok := sm.cache[name]; ok && cached.state == state {
+ return cached.srv
+ }
sm.cache[name] = cachedCityServer{state: state, srv: srv}
- sm.cacheMu.Unlock()
-
return srv
}
diff --git a/internal/api/supervisor_city_routes.go b/internal/api/supervisor_city_routes.go
index 7f0146903b..4a6d80e92a 100644
--- a/internal/api/supervisor_city_routes.go
+++ b/internal/api/supervisor_city_routes.go
@@ -14,11 +14,13 @@ import (
// without re-defining the shape.
func sessionStreamEventMap() map[string]any {
return map[string]any{
- "turn": SessionStreamMessageEvent{},
- "message": SessionStreamRawMessageEvent{},
- "activity": SessionActivityEvent{},
- "pending": runtime.PendingInteraction{},
- "heartbeat": HeartbeatEvent{},
+ "turn": SessionStreamMessageEvent{},
+ "message": SessionStreamRawMessageEvent{},
+ "structured": SessionStreamStructuredMessageEvent{},
+ "activity": SessionActivityEvent{},
+ "pending": runtime.PendingInteraction{},
+ "pending_cleared": SessionPendingClearedEvent{},
+ "heartbeat": HeartbeatEvent{},
}
}
@@ -194,7 +196,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
// a mutation with a 403 before the handler runs; reads never emit it.
// GET /beads also declares 400: an invalid pagination cursor is a typed
// invalid-cursor problem response, never a silent page-1 restart.
- cityGet(sm, "/beads", (*Server).humaHandleBeadList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable))
+ cityGet(sm, "/beads", (*Server).humaHandleBeadList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable), listOrder("(created_at DESC, id DESC) — newest beads first"))
cityGet(sm, "/beads/graph/{rootID}", (*Server).humaHandleBeadGraph, errorStatuses(http.StatusNotFound))
cityGet(sm, "/beads/ready", (*Server).humaHandleBeadReady, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable))
cityRegister(sm, huma.Operation{
@@ -217,7 +219,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
// Mail. Part of the P12 error-contract slice (see Beads above): each op
// enumerates the error statuses it can return (Huma adds auto 422/500);
// mutations declare 403 for the CSRF/read-only middleware.
- cityGet(sm, "/mail", (*Server).humaHandleMailList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable))
+ cityGet(sm, "/mail", (*Server).humaHandleMailList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable), listOrder("(created_at DESC, id DESC) — newest messages first"))
cityRegister(sm, huma.Operation{
OperationID: "send-mail",
Method: http.MethodPost,
@@ -245,7 +247,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
// Convoys.
// 400: invalid pagination cursor (invalid-cursor problem type).
- cityGet(sm, "/convoys", (*Server).humaHandleConvoyList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable))
+ cityGet(sm, "/convoys", (*Server).humaHandleConvoyList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable), listOrder("(created_at DESC, id DESC) — newest convoys first"))
cityRegister(sm, huma.Operation{
OperationID: "create-convoy",
Method: http.MethodPost,
@@ -263,7 +265,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
cityDelete(sm, "/convoy/{id}", (*Server).humaHandleConvoyDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound))
// Events (list/emit/rotate — stream is a separate SSE registration below).
- cityGet(sm, "/events", (*Server).humaHandleEventList, errorStatuses(http.StatusBadRequest, http.StatusNotFound))
+ cityGet(sm, "/events", (*Server).humaHandleEventList, errorStatuses(http.StatusBadRequest, http.StatusNotFound), listOrder("seq DESC — newest events first"))
cityRegister(sm, huma.Operation{
OperationID: "emit-event",
Method: http.MethodPost,
@@ -366,7 +368,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable},
}, (*Server).humaHandleSessionCreate)
// 400: invalid pagination cursor (invalid-cursor problem type).
- cityGet(sm, "/sessions", (*Server).humaHandleSessionList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable))
+ cityGet(sm, "/sessions", (*Server).humaHandleSessionList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable), listOrder("(created_at DESC, id DESC) — newest sessions first"))
cityGet(sm, "/session/{id}", (*Server).humaHandleSessionGet, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable))
cityGet(sm, "/session/{id}/transcript", (*Server).humaHandleSessionTranscript, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable))
cityGet(sm, "/session/{id}/pending", (*Server).humaHandleSessionPending, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable))
@@ -379,7 +381,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
Path: "/session/{id}/submit",
Summary: "Submit a message to a session",
DefaultStatus: http.StatusAccepted,
- Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable},
+ Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable},
}, (*Server).humaHandleSessionSubmit)
cityRegister(sm, huma.Operation{
OperationID: "send-session-message",
@@ -387,7 +389,7 @@ func (sm *SupervisorMux) registerCityRoutes() {
Path: "/session/{id}/messages",
Summary: "Send a message to a session",
DefaultStatus: http.StatusAccepted,
- Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable},
+ Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable},
}, (*Server).humaHandleSessionMessage)
cityPost(sm, "/session/{id}/stop", (*Server).humaHandleSessionStop, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable))
cityPost(sm, "/session/{id}/kill", (*Server).humaHandleSessionKill, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable))
@@ -411,19 +413,19 @@ func (sm *SupervisorMux) registerCityRoutes() {
cityGet(sm, "/wait/{id}", (*Server).humaHandleWaitGet, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable))
// Session SSE stream.
- registerSSE(sm.humaAPI, huma.Operation{
+ registerSSEStringID(sm.humaAPI, huma.Operation{
OperationID: "stream-session",
Method: http.MethodGet,
Path: cityScopePrefix + "/session/{id}/stream",
Summary: "Stream session output in real time",
Description: "Server-Sent Events stream of session transcript updates. " +
- "Streams turns (conversation format) or raw messages (JSONL format) " +
+ "Streams turns (conversation format), raw messages (JSONL format), or structured messages " +
"based on the format query parameter. Emits activity and pending events " +
"for tool approval prompts.",
Responses: sseResponseHeaders("GC-Session-State", "GC-Session-Status"),
}, sessionStreamEventMap(),
sseCityPrecheck(sm, (*Server).checkSessionStream),
- sseCityStream(sm, (*Server).streamSession))
+ sseCityStringIDStream(sm, (*Server).streamSession))
// Event SSE stream (per-city).
registerSSE(sm.humaAPI, huma.Operation{
diff --git a/internal/api/supervisor_test.go b/internal/api/supervisor_test.go
index 5ca5aae5f4..5ebf10080d 100644
--- a/internal/api/supervisor_test.go
+++ b/internal/api/supervisor_test.go
@@ -9,10 +9,12 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
"github.com/gastownhall/gascity/internal/beads"
+ "github.com/gastownhall/gascity/internal/config"
"github.com/gastownhall/gascity/internal/events"
"github.com/gastownhall/gascity/internal/workspacesvc"
)
@@ -66,6 +68,23 @@ func (f *fakeCityResolver) SupervisorEventRecorder() events.Recorder {
return f.supervisorRecorder
}
+// blockingConfigState holds every concurrent Server construction at its first
+// State access until all callers have missed the supervisor cache.
+type blockingConfigState struct {
+ *fakeState
+ constructors int32
+ configCalls atomic.Int32
+ allStarted chan struct{}
+}
+
+func (s *blockingConfigState) Config() *config.City {
+ if s.configCalls.Add(1) == s.constructors {
+ close(s.allStarted)
+ }
+ <-s.allStarted
+ return s.fakeState.Config()
+}
+
func newTestSupervisorMux(t *testing.T, cities map[string]*fakeState) *SupervisorMux {
t.Helper()
return newTestSupervisorMuxWithBuildID(t, cities, "")
@@ -244,6 +263,42 @@ func TestSupervisorCityNamespacedRoute(t *testing.T) {
}
}
+func TestSupervisorGetCityServerConcurrentCallsReturnCanonicalServer(t *testing.T) {
+ const callers = 8
+
+ state := &blockingConfigState{
+ fakeState: newFakeState(t),
+ constructors: callers,
+ allStarted: make(chan struct{}),
+ }
+ state.cityName = "bright-lights"
+ sm := newTestSupervisorMux(t, nil)
+
+ start := make(chan struct{})
+ servers := make(chan *Server, callers)
+ for range callers {
+ go func() {
+ <-start
+ servers <- sm.getCityServer(state.CityName(), state)
+ }()
+ }
+ close(start)
+
+ want := <-servers
+ for i := 1; i < callers; i++ {
+ if got := <-servers; got != want {
+ t.Fatalf("concurrent caller %d got Server %p, want canonical Server %p", i, got, want)
+ }
+ }
+
+ sm.cacheMu.RLock()
+ cached := sm.cache[state.CityName()].srv
+ sm.cacheMu.RUnlock()
+ if cached != want {
+ t.Fatalf("cached Server = %p, want returned canonical Server %p", cached, want)
+ }
+}
+
func TestSupervisorCityScopedRoute404sUntilCityRunning(t *testing.T) {
resolver := &fakeCityResolver{
cities: map[string]*fakeState{},
diff --git a/internal/api/types_read.go b/internal/api/types_read.go
index 0093f022a0..91fe24f4b9 100644
--- a/internal/api/types_read.go
+++ b/internal/api/types_read.go
@@ -68,6 +68,8 @@ type StatusView struct {
// the wire (the view reuses the wire struct — it is already CLI-shaped).
ConditionalWrites *StatusConditionalWrites
Summary StatusSummaryView
+ Partial bool
+ PartialErrors []string
}
// StatusAgentView is the CLI-facing per-agent row.
diff --git a/internal/api/worker_factory_test.go b/internal/api/worker_factory_test.go
index 7a1d7ad4f1..e6bd740d50 100644
--- a/internal/api/worker_factory_test.go
+++ b/internal/api/worker_factory_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/gastownhall/gascity/internal/config"
+ "github.com/gastownhall/gascity/internal/convergence"
"github.com/gastownhall/gascity/internal/events"
"github.com/gastownhall/gascity/internal/runtime"
"github.com/gastownhall/gascity/internal/session"
@@ -18,10 +19,20 @@ func TestResolveWorkerSessionRuntimePreservesStoredResolvedCommandAndBackfillsCu
t.Setenv("ANTHROPIC_AUTH_TOKEN", "api-resume-anthropic-token")
t.Setenv("ANTHROPIC_BASE_URL", "https://process.example.test")
t.Setenv("OLLAMA_API_KEY", "api-resume-ollama-token")
+ t.Setenv("API_SESSION_WORKSPACE_VALUE", "expanded-workspace-value")
t.Setenv("GC_RIG", "caller-rig")
t.Setenv("GC_SESSION_NAME", "caller-session")
fs := newSessionFakeState(t)
+ fs.cfg.Workspace.Env = map[string]string{
+ "WORKSPACE_ONLY": "$API_SESSION_WORKSPACE_VALUE",
+ "SESSION_ENV_PRECEDENCE": "workspace",
+ "GC_BIN": "/workspace/bin/gc",
+ "GC_CITY": "/workspace/city",
+ // PR #4577 review (security major): a controller token configured via
+ // workspace env must be scrubbed from the resumed session env.
+ convergence.TokenEnvVar: "workspace-controller-token",
+ }
fs.cfg.Agents[0].Provider = "resolved-worker"
fs.cfg.Providers["resolved-worker"] = config.ProviderSpec{
DisplayName: "Resolved Worker",
@@ -33,7 +44,13 @@ func TestResolveWorkerSessionRuntimePreservesStoredResolvedCommandAndBackfillsCu
ResumeCommand: "resolved resume {{.SessionKey}}",
SessionIDFlag: "--session-id-resolved",
Env: map[string]string{
- "ANTHROPIC_BASE_URL": "https://resolved.example.test",
+ "ANTHROPIC_BASE_URL": "https://resolved.example.test",
+ "SESSION_ENV_PRECEDENCE": "provider",
+ "GC_BIN": "/provider/bin/gc",
+ "GC_CITY": "/provider/city",
+ // PR #4577 review (security major): a controller token configured via
+ // provider env must also be scrubbed from the resumed session env.
+ convergence.TokenEnvVar: "provider-controller-token",
},
}
@@ -96,6 +113,39 @@ func TestResolveWorkerSessionRuntimePreservesStoredResolvedCommandAndBackfillsCu
if runtimeCfg.SessionEnv["GC_CITY_RUNTIME_DIR"] == "" {
t.Error("SessionEnv[GC_CITY_RUNTIME_DIR] = empty, want set")
}
+ gcBin, err := os.Executable()
+ if err != nil {
+ t.Fatalf("os.Executable: %v", err)
+ }
+ for key, want := range map[string]string{
+ "WORKSPACE_ONLY": "expanded-workspace-value",
+ "SESSION_ENV_PRECEDENCE": "provider",
+ "GC_BIN": gcBin,
+ } {
+ if got := runtimeCfg.SessionEnv[key]; got != want {
+ t.Errorf("SessionEnv[%s] = %q, want %q", key, got, want)
+ }
+ if got := runtimeCfg.Hints.Env[key]; got != want {
+ t.Errorf("Hints.Env[%s] = %q, want %q", key, got, want)
+ }
+ }
+ // PR #4577 review: the API resume path must (a) prepend the gc binary's dir
+ // to PATH so a bare `gc` in the resumed session resolves to this binary
+ // (behavioral-correctness major), and (b) scrub the controller token from
+ // both workspace and provider env layers (security major).
+ wantPATHPrefix := filepath.Dir(gcBin)
+ for name, env := range map[string]map[string]string{
+ "SessionEnv": runtimeCfg.SessionEnv,
+ "Hints.Env": runtimeCfg.Hints.Env,
+ } {
+ parts := strings.Split(env["PATH"], string(os.PathListSeparator))
+ if len(parts) == 0 || parts[0] != wantPATHPrefix {
+ t.Errorf("%s[PATH] = %q, want first entry %q (dir of GC_BIN)", name, env["PATH"], wantPATHPrefix)
+ }
+ if got, present := env[convergence.TokenEnvVar]; present {
+ t.Errorf("%s[%s] = %q present, want scrubbed", name, convergence.TokenEnvVar, got)
+ }
+ }
// Identity-only contract (per Copilot review): no dispatcher trace
// default — that must stay per-dispatcher-qualified, not reseeded
// to the city-uniform value here.
diff --git a/internal/beadmeta/guard_test.go b/internal/beadmeta/guard_test.go
index 7aa5e9fa73..bfc0f3d570 100644
--- a/internal/beadmeta/guard_test.go
+++ b/internal/beadmeta/guard_test.go
@@ -5,10 +5,11 @@ import (
"go/ast"
"go/parser"
"go/token"
- "io/fs"
"os"
+ "os/exec"
"path/filepath"
"regexp"
+ "slices"
"strconv"
"strings"
"testing"
@@ -95,59 +96,39 @@ func TestNoUndeclaredMetadataKeys(t *testing.T) {
}
var violations []string
- for _, top := range []string{"internal", "cmd"} {
- base := filepath.Join(root, top)
- err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
+ for _, rel := range trackedGoFiles(t, root, []string{"internal", "cmd"}) {
+ relSlash := filepath.ToSlash(rel)
+ fset := token.NewFileSet()
+ f, perr := parser.ParseFile(fset, filepath.Join(root, rel), nil, 0)
+ if perr != nil {
+ continue // unparseable file is not this guard's concern
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ lit, ok := n.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return true
}
- rel, _ := filepath.Rel(root, path)
- rel = filepath.ToSlash(rel)
- if d.IsDir() {
- if d.Name() == "testdata" || isExcludedDir(rel) {
- return filepath.SkipDir
- }
- return nil
+ val, uerr := strconv.Unquote(lit.Value)
+ if uerr != nil {
+ return true
}
- if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
- return nil
+ if !keyShape.MatchString(val) {
+ return true // not a whole bead-metadata key (bare "gc.", message, filter, ...)
}
- fset := token.NewFileSet()
- f, perr := parser.ParseFile(fset, path, nil, 0)
- if perr != nil {
- return nil // unparseable file is not this guard's concern
+ if hasKnownPrefix(val) {
+ return true
}
- ast.Inspect(f, func(n ast.Node) bool {
- lit, ok := n.(*ast.BasicLit)
- if !ok || lit.Kind != token.STRING {
- return true
- }
- val, uerr := strconv.Unquote(lit.Value)
- if uerr != nil {
- return true
- }
- if !keyShape.MatchString(val) {
- return true // not a whole bead-metadata key (bare "gc.", message, filter, ...)
- }
- if hasKnownPrefix(val) {
- return true
- }
- if _, ok := allowedNonMetadata[val]; ok {
- return true
- }
- line := fset.Position(lit.Pos()).Line
- if _, ok := declared[val]; ok {
- violations = append(violations, fmt.Sprintf(" %s:%d %q is declared — reference the beadmeta constant instead of the raw literal", rel, line, val))
- } else {
- violations = append(violations, fmt.Sprintf(" %s:%d %q is undeclared — declare it in internal/beadmeta/keys.go", rel, line, val))
- }
+ if _, ok := allowedNonMetadata[val]; ok {
return true
- })
- return nil
+ }
+ line := fset.Position(lit.Pos()).Line
+ if _, ok := declared[val]; ok {
+ violations = append(violations, fmt.Sprintf(" %s:%d %q is declared — reference the beadmeta constant instead of the raw literal", relSlash, line, val))
+ } else {
+ violations = append(violations, fmt.Sprintf(" %s:%d %q is undeclared — declare it in internal/beadmeta/keys.go", relSlash, line, val))
+ }
+ return true
})
- if err != nil {
- t.Fatalf("walking %s: %v", base, err)
- }
}
if len(violations) > 0 {
@@ -196,3 +177,112 @@ func repoRoot(t *testing.T) string {
dir = parent
}
}
+
+// TestTrackedGoFilesExcludesUntrackedScaffoldNoise locks in defense against
+// the same scaffold-noise class that tripped internal/api/apierr_guard_test.go
+// before PR#4118: a raw filepath.WalkDir over the repo tree descends into
+// whatever happens to be sitting on disk, including stray ga-* bead-worktree
+// checkouts and .gascity-worktree-stage.* staging dirs that a concurrent
+// fleet agent may have left under repo root. trackedGoFiles must scan
+// git-tracked files only, so untracked scaffold noise can never be walked,
+// regardless of its name. See ga-5vzfgb.
+func TestTrackedGoFilesExcludesUntrackedScaffoldNoise(t *testing.T) {
+ root := t.TempDir()
+ runGit := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = root
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.invalid",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.invalid",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ writeFile := func(rel, content string) {
+ t.Helper()
+ full := filepath.Join(root, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ runGit("init", "-q")
+
+ // A real, git-tracked file under internal/ — must always be scanned.
+ writeFile("internal/example/tracked.go", "package example\n\nconst x = \"tracked-marker\"\n")
+ runGit("add", "internal/example/tracked.go")
+ runGit("commit", "-q", "-m", "tracked")
+
+ // Untracked nested ga-*-named worktree-shaped scaffold dir — must never
+ // be scanned, regardless of the fact that its name matches a bead id.
+ writeFile("internal/example/ga-9zzzzz-stray/scaffold.go", "package stray\n\nconst y = \"scaffold-marker\"\n")
+
+ // Untracked .gascity-worktree-stage.* staging dir — must never be scanned.
+ writeFile("internal/example/.gascity-worktree-stage.abc/scaffold.go", "package stage\n\nconst z = \"stage-marker\"\n")
+
+ got := trackedGoFiles(t, root, []string{"internal", "cmd"})
+ for i, f := range got {
+ got[i] = filepath.ToSlash(f)
+ }
+
+ if !slices.Contains(got, "internal/example/tracked.go") {
+ t.Fatalf("trackedGoFiles = %v, want to contain the tracked file", got)
+ }
+ for _, unwanted := range []string{
+ "internal/example/ga-9zzzzz-stray/scaffold.go",
+ "internal/example/.gascity-worktree-stage.abc/scaffold.go",
+ } {
+ if slices.Contains(got, unwanted) {
+ t.Fatalf("trackedGoFiles = %v must NOT contain untracked scaffold file %q", got, unwanted)
+ }
+ }
+}
+
+// trackedGoFiles returns the repo-relative paths of every git-tracked,
+// non-test .go file under any of the given top-level directories (each
+// checked against excludedDirs and a testdata skip, matching the semantics
+// filepath.WalkDir previously enforced during the walk itself), using
+// `git ls-files` instead of a filesystem walk. This is immune by
+// construction to untracked scaffold noise landing under root — a stray
+// ga-* bead-worktree checkout or .gascity-worktree-stage.* staging dir is
+// never git-tracked, so it can never appear in the result, regardless of
+// its name. Mirrors internal/api/apierr_guard_test.go (PR#4118).
+func trackedGoFiles(t *testing.T, root string, tops []string) []string {
+ t.Helper()
+ out, err := exec.Command("git", "-C", root, "ls-files", "-z", "--", "*.go").Output()
+ if err != nil {
+ t.Fatalf("git ls-files in %s: %v", root, err)
+ }
+
+ var files []string
+ for _, rel := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
+ if rel == "" || strings.HasSuffix(rel, "_test.go") {
+ continue
+ }
+ relSlash := filepath.ToSlash(rel)
+ inScope := false
+ for _, top := range tops {
+ if relSlash == top || strings.HasPrefix(relSlash, top+"/") {
+ inScope = true
+ break
+ }
+ }
+ if !inScope {
+ continue
+ }
+ if strings.HasPrefix(relSlash, "testdata/") || strings.Contains(relSlash, "/testdata/") {
+ continue
+ }
+ if isExcludedDir(filepath.ToSlash(filepath.Dir(relSlash))) {
+ continue
+ }
+ files = append(files, rel)
+ }
+ return files
+}
diff --git a/internal/beadmeta/keys.go b/internal/beadmeta/keys.go
index 2523176bcf..cfcdcef5c3 100644
--- a/internal/beadmeta/keys.go
+++ b/internal/beadmeta/keys.go
@@ -63,12 +63,6 @@ const (
ControllerRetryableMetadataKey = "gc.controller_retryable"
CurrentRunIDMetadataKey = "gc.current_run_id"
CwdMetadataKey = "gc.cwd"
- // ActiveWorkBeadMetadataKey is the session bead's current-pointer to the STEP it
- // is executing — the work bead's bare gc.step_id (NOT its namespaced bead id),
- // stamped at the claim hook and read at the usage record site to populate
- // usage.Fact.StepID. Empty when the current work has no formula step (ad-hoc /
- // manual), matching the events plane. See engdocs/design/active-work-bead-v0.md.
- ActiveWorkBeadMetadataKey = "gc.active_work_bead"
// AttachFencePendingMetadataKey marks a fenced attach's sub-DAG root
// between speculative (deferred, non-runnable) creation and the CAS-last
// epoch fence committing. Cleared on activation; a root still carrying it
@@ -320,7 +314,6 @@ var KnownMetadataKeys = []string{
ControllerErrorMetadataKey,
ControllerRetryableMetadataKey,
CurrentRunIDMetadataKey,
- ActiveWorkBeadMetadataKey,
CwdMetadataKey,
AttachFencePendingMetadataKey,
DeferredAssigneeMetadataKey,
diff --git a/internal/beads/bdstore.go b/internal/beads/bdstore.go
index 2d3a31e2a0..98d1193a58 100644
--- a/internal/beads/bdstore.go
+++ b/internal/beads/bdstore.go
@@ -320,6 +320,8 @@ type BdStore struct {
// mode plus the once-per-store degrade latch, under its own mutex
// (disjoint from condWriteMu's capability state; no nesting).
condWritesStamp
+
+ localStrings *localSidecar // clone-local data; see Store.SetLocalString
}
const (
@@ -348,7 +350,12 @@ func NewBdStore(dir string, runner CommandRunner, opts ...BdStoreOption) *BdStor
// NewBdStoreWithPrefix creates a BdStore with an explicit owned bead ID prefix.
func NewBdStoreWithPrefix(dir string, runner CommandRunner, idPrefix string, opts ...BdStoreOption) *BdStore {
- s := &BdStore{dir: dir, runner: runner, idPrefix: normalizeIDPrefix(idPrefix)}
+ s := &BdStore{
+ dir: dir,
+ runner: runner,
+ idPrefix: normalizeIDPrefix(idPrefix),
+ localStrings: newLocalSidecar(bdLocalSidecarPath(dir)),
+ }
for _, opt := range opts {
if opt != nil {
opt(s)
@@ -357,6 +364,15 @@ func NewBdStoreWithPrefix(dir string, runner CommandRunner, idPrefix string, opt
return s
}
+// bdLocalSidecarPath returns the path of the clone-local sidecar file for a
+// BdStore rooted at dir, or "" (in-memory-only) if dir is unset.
+func bdLocalSidecarPath(dir string) string {
+ if dir == "" {
+ return ""
+ }
+ return filepath.Join(dir, ".beads", "local-strings.json")
+}
+
// IDPrefix returns the bead ID prefix owned by this store, without trailing "-".
func (s *BdStore) IDPrefix() string {
if s == nil {
@@ -1551,6 +1567,29 @@ func (s *BdStore) SetMetadataBatch(id string, kvs map[string]string) error {
return nil
}
+// SetLocalString sets a clone-local string value for a bead. See
+// Store.SetLocalString. Persisted to a sidecar JSON file under this store's
+// .beads/ directory rather than routed through the bd subprocess: unlike
+// SetMetadata, this never invokes bd and so never touches Dolt sync or bd's
+// on_update hook. Does not validate that id refers to an existing bead — see
+// the interface doc comment for why.
+func (s *BdStore) SetLocalString(id, key, value string) error {
+ if err := s.localStrings.Set(id, key, value); err != nil {
+ return fmt.Errorf("setting local string on %q: %w", id, err)
+ }
+ return nil
+}
+
+// GetLocalString returns the clone-local string value for a bead. See
+// Store.GetLocalString.
+func (s *BdStore) GetLocalString(id, key string) (string, error) {
+ value, err := s.localStrings.Get(id, key)
+ if err != nil {
+ return "", fmt.Errorf("getting local string on %q: %w", id, err)
+ }
+ return value, nil
+}
+
// Tx executes fn against a staged BdStore transaction. BdStore reads each bead
// on first touch, applies callback writes to that snapshot, and reasserts the
// staged fields when fn returns; concurrent edits to the same bead fields made
@@ -2190,6 +2229,9 @@ func (s *BdStore) Delete(id string) error {
}
return fmt.Errorf("deleting bead %q: %w", id, err)
}
+ if sidecarErr := s.localStrings.DeleteBead(id); sidecarErr != nil {
+ return fmt.Errorf("deleting bead %q: cleaning up local strings: %w", id, sidecarErr)
+ }
return nil
}
diff --git a/internal/beads/bdstore_ready_projection.go b/internal/beads/bdstore_ready_projection.go
index d4eadddd6a..87c86a35ec 100644
--- a/internal/beads/bdstore_ready_projection.go
+++ b/internal/beads/bdstore_ready_projection.go
@@ -21,13 +21,13 @@ func (s *BdStore) enrichReadyProjectionForCache(items []Bead) ([]Bead, error) {
ids := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
- // Message (mail) beads are never dependency-blocked ready work, and
- // bd's denormalized is_blocked column flaps NULL<->false for ephemeral
- // mail wisps. Enriching them makes the CachingStore reconciler re-emit
- // bead.updated for every open mail bead on every cycle (an event flood
- // that starves gc-hook work queries). Leave their IsBlocked at bd's nil
- // fallback so the reconcile diff converges.
- if item.ID == "" || item.Status == "closed" || item.IsBlocked != nil || item.Type == "message" {
+ // Message and nudge beads are notifications, not dependency-blocked ready
+ // work, and bd's denormalized is_blocked column can flap NULL<->false for
+ // them. Enriching those rows makes the CachingStore reconciler re-emit
+ // bead.updated on every cycle (an event flood that starves gc-hook work
+ // queries). Leave their IsBlocked at bd's nil fallback so the reconcile
+ // diff converges.
+ if skipBDReadyProjectionEnrichment(item) {
continue
}
if _, ok := seen[item.ID]; ok {
@@ -54,7 +54,7 @@ func (s *BdStore) enrichReadyProjectionForCache(items []Bead) ([]Bead, error) {
enriched := make([]Bead, len(items))
copy(enriched, items)
for i := range enriched {
- if enriched[i].ID == "" || enriched[i].Status == "closed" || enriched[i].IsBlocked != nil || enriched[i].Type == "message" {
+ if skipBDReadyProjectionEnrichment(enriched[i]) {
continue
}
blocked, ok := projection[enriched[i].ID]
@@ -66,6 +66,14 @@ func (s *BdStore) enrichReadyProjectionForCache(items []Bead) ([]Bead, error) {
return enriched, nil
}
+func skipBDReadyProjectionEnrichment(item Bead) bool {
+ return item.ID == "" ||
+ item.Status == "closed" ||
+ item.IsBlocked != nil ||
+ item.Type == "message" ||
+ beadHasLabel(item, "gc:nudge")
+}
+
func (s *BdStore) bdReadyProjectionEnabled() (bool, error) {
s.readyProjectionMu.Lock()
defer s.readyProjectionMu.Unlock()
diff --git a/internal/beads/bdstore_ready_projection_internal_test.go b/internal/beads/bdstore_ready_projection_internal_test.go
index 50f9695ca0..8422e1de07 100644
--- a/internal/beads/bdstore_ready_projection_internal_test.go
+++ b/internal/beads/bdstore_ready_projection_internal_test.go
@@ -48,3 +48,40 @@ func TestEnrichReadyProjectionForCacheSkipsMessageBeads(t *testing.T) {
t.Errorf("task bead IsBlocked = %v, want &false (real work must still be enriched)", got)
}
}
+
+// TestEnrichReadyProjectionForCacheSkipsNudgeBeads guards the same cache
+// convergence invariant for durable nudge queue beads. They are transient
+// notifications represented as chore beads, not dependency-blocked work.
+func TestEnrichReadyProjectionForCacheSkipsNudgeBeads(t *testing.T) {
+ runner := func(_, name string, args ...string) ([]byte, error) {
+ joined := name + " " + strings.Join(args, " ")
+ switch {
+ case joined == "bd version":
+ return []byte("bd version 1.1.0\n"), nil
+ case len(args) > 0 && args[0] == "sql":
+ return []byte(`[{"id":"gc-wisp-nudge","is_blocked":false},{"id":"gcg-task","is_blocked":false}]`), nil
+ }
+ return nil, fmt.Errorf("unexpected command: %s", joined)
+ }
+ s := NewBdStore("/city", runner)
+
+ items := []Bead{
+ {ID: "gc-wisp-nudge", Type: "chore", Status: "open", Labels: []string{"gc:nudge"}},
+ {ID: "gcg-task", Type: "task", Status: "open"},
+ }
+ out, err := s.enrichReadyProjectionForCache(items)
+ if err != nil {
+ t.Fatalf("enrichReadyProjectionForCache: %v", err)
+ }
+
+ byID := make(map[string]Bead, len(out))
+ for _, b := range out {
+ byID[b.ID] = b
+ }
+ if got := byID["gc-wisp-nudge"].IsBlocked; got != nil {
+ t.Errorf("nudge bead IsBlocked = &%v, want nil (must be skipped so the reconcile diff converges)", *got)
+ }
+ if got := byID["gcg-task"].IsBlocked; got == nil || *got {
+ t.Errorf("task bead IsBlocked = %v, want &false (real work must still be enriched)", got)
+ }
+}
diff --git a/internal/beads/bdstore_test.go b/internal/beads/bdstore_test.go
index 2be33ece3e..d479804ce4 100644
--- a/internal/beads/bdstore_test.go
+++ b/internal/beads/bdstore_test.go
@@ -3168,6 +3168,127 @@ func TestBdStoreSetMetadataError(t *testing.T) {
}
}
+// --- SetLocalString / GetLocalString ---
+
+func TestBdStoreSetLocalStringRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ runner := func(_, name string, args ...string) ([]byte, error) {
+ t.Fatalf("unexpected bd invocation: %s %s", name, strings.Join(args, " "))
+ return nil, nil
+ }
+ s := beads.NewBdStore(dir, runner)
+
+ if err := s.SetLocalString("bd-1", "last_woke_at", "2026-07-14T00:00:00Z"); err != nil {
+ t.Fatalf("SetLocalString: %v", err)
+ }
+ got, err := s.GetLocalString("bd-1", "last_woke_at")
+ if err != nil {
+ t.Fatalf("GetLocalString: %v", err)
+ }
+ if got != "2026-07-14T00:00:00Z" {
+ t.Errorf("GetLocalString = %q, want persisted value", got)
+ }
+}
+
+func TestBdStoreGetLocalStringUnsetReturnsEmpty(t *testing.T) {
+ dir := t.TempDir()
+ runner := func(_, name string, args ...string) ([]byte, error) {
+ t.Fatalf("unexpected bd invocation: %s %s", name, strings.Join(args, " "))
+ return nil, nil
+ }
+ s := beads.NewBdStore(dir, runner)
+
+ got, err := s.GetLocalString("bd-1", "never_set")
+ if err != nil {
+ t.Fatalf("GetLocalString: %v", err)
+ }
+ if got != "" {
+ t.Errorf("GetLocalString unset = %q, want empty", got)
+ }
+}
+
+// TestBdStoreLocalStringNeverInvokesCommandRunner asserts the property
+// documented on BdStore.SetLocalString: clone-local writes are persisted to
+// a sidecar JSON file and never shell out to bd, so they never touch Dolt
+// sync or bd's on_update hook. The runner fails the test immediately if
+// invoked at all.
+func TestBdStoreLocalStringNeverInvokesCommandRunner(t *testing.T) {
+ dir := t.TempDir()
+ runner := func(_, name string, args ...string) ([]byte, error) {
+ t.Fatalf("SetLocalString/GetLocalString must never invoke bd, got: %s %s", name, strings.Join(args, " "))
+ return nil, nil
+ }
+ s := beads.NewBdStore(dir, runner)
+
+ if err := s.SetLocalString("bd-1", "k1", "v1"); err != nil {
+ t.Fatalf("SetLocalString k1: %v", err)
+ }
+ if err := s.SetLocalString("bd-1", "k2", "v2"); err != nil {
+ t.Fatalf("SetLocalString k2: %v", err)
+ }
+ if _, err := s.GetLocalString("bd-1", "k1"); err != nil {
+ t.Fatalf("GetLocalString k1: %v", err)
+ }
+ if _, err := s.GetLocalString("bd-1", "k2"); err != nil {
+ t.Fatalf("GetLocalString k2: %v", err)
+ }
+ if err := s.SetLocalString("bd-1", "k1", ""); err != nil {
+ t.Fatalf("SetLocalString clear k1: %v", err)
+ }
+}
+
+func TestBdStoreSetLocalStringPersistsAcrossNewInstanceSameDir(t *testing.T) {
+ dir := t.TempDir()
+ failRunner := func(_, name string, args ...string) ([]byte, error) {
+ t.Fatalf("unexpected bd invocation: %s %s", name, strings.Join(args, " "))
+ return nil, nil
+ }
+
+ first := beads.NewBdStore(dir, failRunner)
+ if err := first.SetLocalString("bd-1", "last_woke_at", "2026-07-14T00:00:00Z"); err != nil {
+ t.Fatalf("SetLocalString: %v", err)
+ }
+
+ // A fresh *BdStore at the same dir simulates a new process/session
+ // opening the same clone; clone-local data must survive that restart.
+ second := beads.NewBdStore(dir, failRunner)
+ got, err := second.GetLocalString("bd-1", "last_woke_at")
+ if err != nil {
+ t.Fatalf("GetLocalString from fresh instance: %v", err)
+ }
+ if got != "2026-07-14T00:00:00Z" {
+ t.Errorf("GetLocalString from fresh instance at same dir = %q, want persisted value", got)
+ }
+}
+
+func TestBdStoreDeleteRemovesLocalStrings(t *testing.T) {
+ dir := t.TempDir()
+ runner := func(_, name string, args ...string) ([]byte, error) {
+ if name != "bd" {
+ return nil, fmt.Errorf("unexpected command name %q", name)
+ }
+ if len(args) == 0 || args[0] != "delete" {
+ return nil, fmt.Errorf("unexpected command: bd %s", strings.Join(args, " "))
+ }
+ return nil, nil
+ }
+ s := beads.NewBdStore(dir, runner)
+
+ if err := s.SetLocalString("bd-1", "k", "v"); err != nil {
+ t.Fatalf("SetLocalString: %v", err)
+ }
+ if err := s.Delete("bd-1"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ got, err := s.GetLocalString("bd-1", "k")
+ if err != nil {
+ t.Fatalf("GetLocalString after Delete: %v", err)
+ }
+ if got != "" {
+ t.Errorf("GetLocalString after Delete = %q, want empty (sidecar entry removed)", got)
+ }
+}
+
func TestBdStoreSetMetadataBatchRetriesDoltSerializationFailure(t *testing.T) {
calls := 0
runner := func(_, _ string, _ ...string) ([]byte, error) {
diff --git a/internal/beads/beads.go b/internal/beads/beads.go
index 554afca7a7..0593359405 100644
--- a/internal/beads/beads.go
+++ b/internal/beads/beads.go
@@ -126,6 +126,18 @@ type Bead struct {
// backing store until reconcile or CAS-failure eviction; callers read it only
// through ConditionalWriter (equality-only; see the revision contract).
Revision int64 `json:"-"`
+ // ClaimFence is the store-internal ownership fence: a monotonic counter
+ // bumped ONLY on ownership transitions — a claim/unclaim/release, an
+ // assignee change, or a reopen (closed→open) — never by content mutations
+ // (title, notes, metadata) or a close. It mirrors beads' claim_fence column
+ // (migration 0055) so GC-side guarded-release paths and their unit tests are
+ // non-vacuous: a guarded release compares it (bd --if-fence) and a stale
+ // incarnation holding an old fence gets a typed conflict instead of
+ // unclaiming a bead a fresh owner already re-claimed. Like Revision it is
+ // json:"-" (off every HTTP/SSE wire path); the native Mem/File stores
+ // maintain it per bead and FileStore persists it out of band. A bd-backed
+ // store leaves it 0 until the pinned bd emits claim_fence.
+ ClaimFence int64 `json:"-"`
}
// UpdateOpts specifies which fields to change. Nil pointers are skipped.
@@ -623,6 +635,38 @@ type Store interface {
// Returns ErrNotFound if the bead does not exist.
SetMetadataBatch(id string, kvs map[string]string) error
+ // SetLocalString sets a clone-local string value for a bead, keyed by an
+ // arbitrary string key. Unlike SetMetadata, values written here are never
+ // synced through Dolt/git and are never visible in Bead.Metadata — they
+ // live only in this store's local clone (in-memory, or a local sidecar
+ // file for on-disk stores). Use this for ephemeral, high-churn, or
+ // clone-specific data where cross-clone durability is unnecessary or
+ // actively undesirable (e.g. synced_at, last_woke_at,
+ // pending_create_claim — illustrative examples, not an exhaustive list).
+ // Setting value to "" clears the key. Writing here never touches the
+ // bead's UpdatedAt, since that field is Dolt-synced and a clone-local
+ // write must not appear as a durable change to other clones.
+ //
+ // Implementations that already hold the bead set in process (MemStore,
+ // FileStore) return ErrNotFound for an unknown id, matching SetMetadata.
+ // Implementations backed by an external process (BdStore, NativeDoltStore)
+ // do not perform that check here: doing so would require exactly the
+ // synchronous round-trip this method exists to avoid for high-churn
+ // writes. Callers must not rely on this method to validate bead
+ // existence; validate via Get first if that matters.
+ SetLocalString(id, key, value string) error
+
+ // GetLocalString returns the clone-local string value previously set by
+ // SetLocalString for the given bead and key, scoped to this store's
+ // local clone. Returns "" with a nil error if the key was never set, was
+ // cleared, or was set by a different clone — not ErrNotFound — mirroring
+ // the empty-string-means-absent convention SetLocalString uses for
+ // clearing. As with SetLocalString, only in-process implementations
+ // additionally return ErrNotFound for an unknown bead id; external-store
+ // implementations return "", nil instead. Callers must not rely on this
+ // method to validate bead existence.
+ GetLocalString(id, key string) (string, error)
+
// Tx executes fn inside a single logical transaction identified by
// commitMsg. Implementations without native transaction support may execute
// writes sequentially or stage them until fn returns; outside observers
diff --git a/internal/beads/beadstest/conformance.go b/internal/beads/beadstest/conformance.go
index 63d88997bc..a3881650e7 100644
--- a/internal/beads/beadstest/conformance.go
+++ b/internal/beads/beadstest/conformance.go
@@ -842,6 +842,116 @@ func RunStoreTestsWithOptions(t *testing.T, newStore func() beads.Store, opts Op
t.Errorf("both tier titles = %v, want [tier-ephemeral tier-history tier-no-history]", got)
}
})
+
+ // SetLocalString/GetLocalString cover only behavior common to every Store
+ // implementation. Unknown-bead-id handling is deliberately excluded here:
+ // in-process stores validate and return ErrNotFound while external-process
+ // stores (BdStore, NativeDoltStore, exec.Store) do not, by design (see the
+ // Store interface doc comment) — that asymmetry, if tested at all, belongs
+ // in each implementation's own test file, not this shared suite.
+ t.Run("SetLocalStringRoundTrip", func(t *testing.T) {
+ s := newStore()
+ b, err := s.Create(beads.Bead{Title: "local-string"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLocalString(b.ID, "last_woke_at", "2026-07-14T00:00:00Z"); err != nil {
+ t.Fatalf("SetLocalString: %v", err)
+ }
+ got, err := s.GetLocalString(b.ID, "last_woke_at")
+ if err != nil {
+ t.Fatalf("GetLocalString: %v", err)
+ }
+ if got != "2026-07-14T00:00:00Z" {
+ t.Errorf("GetLocalString = %q, want 2026-07-14T00:00:00Z", got)
+ }
+ })
+
+ t.Run("GetLocalStringUnsetReturnsEmpty", func(t *testing.T) {
+ s := newStore()
+ b, err := s.Create(beads.Bead{Title: "local-string-unset"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.GetLocalString(b.ID, "never_set")
+ if err != nil {
+ t.Fatalf("GetLocalString: %v", err)
+ }
+ if got != "" {
+ t.Errorf("GetLocalString unset = %q, want empty", got)
+ }
+ })
+
+ t.Run("SetLocalStringEmptyClears", func(t *testing.T) {
+ s := newStore()
+ b, err := s.Create(beads.Bead{Title: "local-string-clear"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLocalString(b.ID, "k", "v"); err != nil {
+ t.Fatalf("SetLocalString: %v", err)
+ }
+ if err := s.SetLocalString(b.ID, "k", ""); err != nil {
+ t.Fatalf("SetLocalString empty: %v", err)
+ }
+ got, err := s.GetLocalString(b.ID, "k")
+ if err != nil {
+ t.Fatalf("GetLocalString: %v", err)
+ }
+ if got != "" {
+ t.Errorf("GetLocalString after clear = %q, want empty", got)
+ }
+ })
+
+ t.Run("SetLocalStringNotInDurableMetadata", func(t *testing.T) {
+ s := newStore()
+ b, err := s.Create(beads.Bead{Title: "local-string-not-durable"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLocalString(b.ID, "clone_local_key", "v"); err != nil {
+ t.Fatalf("SetLocalString: %v", err)
+ }
+ got, err := s.Get(b.ID)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if _, ok := got.Metadata["clone_local_key"]; ok {
+ t.Error("SetLocalString leaked into durable Metadata, want clone-local key absent from Metadata")
+ }
+ })
+
+ t.Run("SetLocalStringPerBeadIsolation", func(t *testing.T) {
+ s := newStore()
+ a, err := s.Create(beads.Bead{Title: "local-string-bead-a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := s.Create(beads.Bead{Title: "local-string-bead-b"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLocalString(a.ID, "k", "a-value"); err != nil {
+ t.Fatalf("SetLocalString a: %v", err)
+ }
+ if err := s.SetLocalString(b.ID, "k", "b-value"); err != nil {
+ t.Fatalf("SetLocalString b: %v", err)
+ }
+ gotA, err := s.GetLocalString(a.ID, "k")
+ if err != nil {
+ t.Fatalf("GetLocalString a: %v", err)
+ }
+ if gotA != "a-value" {
+ t.Errorf("GetLocalString a = %q, want a-value", gotA)
+ }
+ gotB, err := s.GetLocalString(b.ID, "k")
+ if err != nil {
+ t.Fatalf("GetLocalString b: %v", err)
+ }
+ if gotB != "b-value" {
+ t.Errorf("GetLocalString b = %q, want b-value", gotB)
+ }
+ })
}
// RunMetadataTests runs conformance tests for metadata absent-vs-empty
diff --git a/internal/beads/beadstest/fence_conformance.go b/internal/beads/beadstest/fence_conformance.go
new file mode 100644
index 0000000000..6e8e7e8b7f
--- /dev/null
+++ b/internal/beads/beadstest/fence_conformance.go
@@ -0,0 +1,243 @@
+package beadstest
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beads"
+)
+
+// RunFenceConformance exercises the ownership-fence bump contract that GC's
+// native in-memory stores (MemStore, FileStore) must satisfy so guarded-release
+// unit tests are non-vacuous. It mirrors the beads-side behavioral fence tests
+// (internal/storage/dolt/fence_test.go): ClaimFence is a monotonic counter
+// bumped ONLY on ownership transitions — a claim/unclaim (assignee change) or a
+// reopen (closed→open) — and NEVER by content mutations or a close.
+//
+// It is exercised only against the native Mem/File stores. A bd-backed store
+// leaves ClaimFence at 0 until the pinned bd emits claim_fence, so running this
+// against BdStore/NativeDoltStore would be vacuous (every read returns 0).
+//
+// newStore must return a fresh, empty store for each call.
+func RunFenceConformance(t *testing.T, newStore func() beads.Store) {
+ t.Helper()
+
+ fenceOf := func(t *testing.T, s beads.Store, id string) int64 {
+ t.Helper()
+ b, err := s.Get(id)
+ if err != nil {
+ t.Fatalf("Get(%q): %v", id, err)
+ }
+ return b.ClaimFence
+ }
+ create := func(t *testing.T, s beads.Store) string {
+ t.Helper()
+ b, err := s.Create(beads.Bead{Title: "fence subject"})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ return b.ID
+ }
+ str := func(v string) *string { return &v }
+
+ t.Run("CreateStartsFenceAtZero", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if got := fenceOf(t, s, id); got != 0 {
+ t.Errorf("fresh bead ClaimFence = %d, want 0", got)
+ }
+ })
+
+ t.Run("ClaimBumpsFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != 1 {
+ t.Errorf("after claim ClaimFence = %d, want 1", got)
+ }
+ })
+
+ t.Run("SameOwnerReclaimDoesNotBumpFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ f1 := fenceOf(t, s, id)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f1 {
+ t.Errorf("re-claim by the same owner bumped ClaimFence %d→%d; a no-op ownership write must not bump", f1, got)
+ }
+ })
+
+ t.Run("UnclaimBumpsFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ f1 := fenceOf(t, s, id)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("")}); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f1+1 {
+ t.Errorf("after unclaim ClaimFence = %d, want %d", got, f1+1)
+ }
+ })
+
+ t.Run("AssigneeChangeBumpsFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-a")}); err != nil {
+ t.Fatal(err)
+ }
+ f1 := fenceOf(t, s, id)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-b")}); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f1+1 {
+ t.Errorf("after owner handoff ClaimFence = %d, want %d", got, f1+1)
+ }
+ })
+
+ t.Run("PlainUpdateDoesNotBumpFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ f1 := fenceOf(t, s, id)
+ if err := s.Update(id, beads.UpdateOpts{Title: str("renamed")}); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Update(id, beads.UpdateOpts{Metadata: map[string]string{"note": "x"}}); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f1 {
+ t.Errorf("content-only update bumped ClaimFence %d→%d; only ownership transitions bump", f1, got)
+ }
+ })
+
+ t.Run("CloseDoesNotBumpFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ f1 := fenceOf(t, s, id)
+ if err := s.Close(id); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f1 {
+ t.Errorf("close bumped ClaimFence %d→%d; close is not an ownership transition", f1, got)
+ }
+ })
+
+ t.Run("ReopenBumpsFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Close(id); err != nil {
+ t.Fatal(err)
+ }
+ f := fenceOf(t, s, id) // close did not bump
+ if err := s.Reopen(id); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f+1 {
+ t.Errorf("after reopen ClaimFence = %d, want %d (closed→open starts a new ownership generation)", got, f+1)
+ }
+ })
+
+ t.Run("InProgressToOpenKeepsFence", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1"), Status: str("in_progress")}); err != nil {
+ t.Fatal(err)
+ }
+ f1 := fenceOf(t, s, id)
+ // in_progress→open KEEPING the assignee is not a transition: the row stays
+ // claimable only by the same owner, and the eventual release bumps at the
+ // real ownership boundary.
+ if err := s.Update(id, beads.UpdateOpts{Status: str("open")}); err != nil {
+ t.Fatal(err)
+ }
+ if got := fenceOf(t, s, id); got != f1 {
+ t.Errorf("in_progress→open (same owner) bumped ClaimFence %d→%d; only closed→open is a transition", f1, got)
+ }
+ })
+
+ t.Run("FenceIsMonotonicAcrossTransitions", func(t *testing.T) {
+ s := newStore()
+ id := create(t, s)
+ prev := fenceOf(t, s, id)
+ ops := []beads.UpdateOpts{
+ {Assignee: str("a")}, // claim
+ {Assignee: str("b")}, // handoff
+ {Assignee: str("")}, // unclaim
+ {Assignee: str("c")}, // reclaim
+ }
+ for i, op := range ops {
+ if err := s.Update(id, op); err != nil {
+ t.Fatal(err)
+ }
+ cur := fenceOf(t, s, id)
+ if cur <= prev {
+ t.Errorf("op %d: ClaimFence did not advance (%d→%d); ownership transitions must be strictly monotonic", i, prev, cur)
+ }
+ prev = cur
+ }
+ })
+
+ // The guarded-write path (UpdateIfMatch on ConditionalWriter) is the exact
+ // entry point the fence exists to protect. It shares applyUpdateLocked with
+ // Update, but a direct assertion keeps a future refactor of the CAS path from
+ // silently dropping the bump.
+ t.Run("ConditionalWriteBumpsFenceOnOwnershipChange", func(t *testing.T) {
+ s := newStore()
+ w, ok := beads.ConditionalWriterFor(s)
+ if !ok {
+ t.Skip("store has no ConditionalWriter")
+ }
+ id := create(t, s)
+ b, err := s.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f0 := b.ClaimFence
+ if err := w.UpdateIfMatch(id, b.Revision, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatalf("UpdateIfMatch(claim): %v", err)
+ }
+ if got := fenceOf(t, s, id); got != f0+1 {
+ t.Errorf("guarded-write claim ClaimFence = %d, want %d", got, f0+1)
+ }
+ })
+
+ t.Run("ConditionalWriteDoesNotBumpFenceOnContentChange", func(t *testing.T) {
+ s := newStore()
+ w, ok := beads.ConditionalWriterFor(s)
+ if !ok {
+ t.Skip("store has no ConditionalWriter")
+ }
+ id := create(t, s)
+ if err := s.Update(id, beads.UpdateOpts{Assignee: str("worker-1")}); err != nil {
+ t.Fatal(err)
+ }
+ b, err := s.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f1 := b.ClaimFence
+ if err := w.UpdateIfMatch(id, b.Revision, beads.UpdateOpts{Title: str("renamed via CAS")}); err != nil {
+ t.Fatalf("UpdateIfMatch(content): %v", err)
+ }
+ if got := fenceOf(t, s, id); got != f1 {
+ t.Errorf("content-only guarded write bumped ClaimFence %d→%d", f1, got)
+ }
+ })
+}
diff --git a/internal/beads/beadstest/metadata_cas_conformance.go b/internal/beads/beadstest/metadata_cas_conformance.go
new file mode 100644
index 0000000000..f03f050edb
--- /dev/null
+++ b/internal/beads/beadstest/metadata_cas_conformance.go
@@ -0,0 +1,244 @@
+package beadstest
+
+import (
+ "strconv"
+ "sync"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beads"
+)
+
+// MetadataCASOptions controls legs of the narrow CAS suite that not every
+// TEST FIXTURE can evaluate. Note the distinction from
+// ConditionalWriterOptions, whose legs turn on what a STORE can express: the
+// contention leg here is a claim about the backend's isolation, so a fixture
+// that models a backend without modeling its isolation cannot judge it.
+type MetadataCASOptions struct {
+ // FixtureLacksIsolationReason, when non-empty, declares that this
+ // factory's store cannot evaluate the contention leg because the fixture
+ // behind it provides no isolation between concurrent transactions, and
+ // records why. The leg is then reported as an explicit, named absence
+ // rather than silently dropped — an unevaluatable gate must be visible in
+ // the test output, not missing from it.
+ //
+ // Set this ONLY for a fixture whose non-isolation is a property of the
+ // test double, never to quiet a store that genuinely admits two winners:
+ // a store that loses the contention leg cannot carry a lease, which is
+ // the whole reason callers want this capability. A fixture that opts out
+ // here owes the contention property an integration-level test against the
+ // real backend.
+ FixtureLacksIsolationReason string
+}
+
+// RunMetadataCASConformance runs the store-agnostic beads.MetadataCASWriter
+// contract suite against a capable store, with every leg enabled.
+func RunMetadataCASConformance(t *testing.T, name string, open func(t *testing.T) beads.Store) {
+ RunMetadataCASConformanceWithOptions(t, name, open, MetadataCASOptions{})
+}
+
+// RunMetadataCASConformanceWithOptions is RunMetadataCASConformance with the
+// fixture-dependent contention leg configurable. open must return a fresh,
+// empty store that implements beads.MetadataCASWriter (verified via
+// beads.MetadataCASWriterFor); name prefixes every subtest so multiple stores
+// can run in one package.
+//
+// The subtests mirror the metadata-CAS legs of
+// RunConditionalWriterConformance one-to-one, so a store that can only offer
+// the narrow capability is held to exactly the same value-CAS contract as a
+// full ConditionalWriter — the two suites can be diffed against each other.
+// The revision legs are deliberately absent rather than skipped: a narrow
+// store makes no revision claim at all, so there is nothing to assert (see
+// the MetadataCASWriter doc comment for why no sound revision token exists at
+// beads v1.1.0).
+//
+// Both contract traps that the in-tree implementations historically diverged
+// on ride this suite: empty-expected matching absent OR present-and-empty,
+// and a lost race reporting (false, nil) rather than an error.
+func RunMetadataCASConformanceWithOptions(t *testing.T, name string, open func(t *testing.T) beads.Store, opts MetadataCASOptions) {
+ t.Helper()
+
+ // writerFor resolves the narrow writer or fails loudly: the suite is only
+ // meaningful against a capable store.
+ writerFor := func(t *testing.T, s beads.Store) beads.MetadataCASWriter {
+ t.Helper()
+ w, ok := beads.MetadataCASWriterFor(s)
+ if !ok {
+ t.Fatalf("store does not implement beads.MetadataCASWriter; "+
+ "RunMetadataCASConformance requires a capable store (%T)", s)
+ }
+ return w
+ }
+
+ t.Run(name+"/cas_empty_expected_claims_absent_or_empty_only", func(t *testing.T) {
+ s := open(t)
+ w := writerFor(t, s)
+ b, err := s.Create(beads.Bead{Title: "cas-empty"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := b.ID
+
+ // Absent key: expected "" claims it.
+ if ok, err := w.CompareAndSetMetadataKey(id, "k", "", "one"); err != nil || !ok {
+ t.Fatalf("claim absent key: (%v, %v), want (true, nil)", ok, err)
+ }
+ // Empty-valued key: expected "" also claims it (the two states are
+ // indistinguishable to callers).
+ if err := s.SetMetadata(id, "k", ""); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := w.CompareAndSetMetadataKey(id, "k", "", "two"); err != nil || !ok {
+ t.Fatalf("claim empty-valued key: (%v, %v), want (true, nil)", ok, err)
+ }
+ // Non-empty key: expected "" must NOT claim it.
+ if ok, err := w.CompareAndSetMetadataKey(id, "k", "", "three"); err != nil || ok {
+ t.Fatalf("claim non-empty key with empty expected: (%v, %v), want (false, nil)", ok, err)
+ }
+ if got, _ := s.Get(id); got.Metadata["k"] != "two" {
+ t.Fatalf("value after rejected empty-expected CAS = %q, want %q", got.Metadata["k"], "two")
+ }
+ })
+
+ t.Run(name+"/cas_value_mismatch_is_false_nil_not_error", func(t *testing.T) {
+ s := open(t)
+ w := writerFor(t, s)
+ b, err := s.Create(beads.Bead{Title: "cas-mismatch"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := b.ID
+ if err := s.SetMetadata(id, "k", "A"); err != nil {
+ t.Fatal(err)
+ }
+ ok, err := w.CompareAndSetMetadataKey(id, "k", "B", "C")
+ if err != nil {
+ t.Fatalf("value-mismatch CAS returned error: %v (want nil)", err)
+ }
+ if ok {
+ t.Fatal("value-mismatch CAS returned true (want false)")
+ }
+ if got, _ := s.Get(id); got.Metadata["k"] != "A" {
+ t.Fatalf("value mutated on a lost CAS: %q, want %q", got.Metadata["k"], "A")
+ }
+ })
+
+ t.Run(name+"/cas_winner_value_visible_to_loser_reread", func(t *testing.T) {
+ s := open(t)
+ w := writerFor(t, s)
+ b, err := s.Create(beads.Bead{Title: "cas-visible"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := b.ID
+ if err := s.SetMetadata(id, "k", "start"); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := w.CompareAndSetMetadataKey(id, "k", "start", "winner"); err != nil || !ok {
+ t.Fatalf("winner CAS: (%v, %v), want (true, nil)", ok, err)
+ }
+ // A loser re-reads and must observe the winner's value.
+ if got, _ := s.Get(id); got.Metadata["k"] != "winner" {
+ t.Fatalf("loser re-read = %q, want %q (winner value not visible)", got.Metadata["k"], "winner")
+ }
+ // And a CAS from the old value now loses cleanly.
+ if ok, err := w.CompareAndSetMetadataKey(id, "k", "start", "late"); err != nil || ok {
+ t.Fatalf("stale-value CAS after a swap: (%v, %v), want (false, nil)", ok, err)
+ }
+ })
+
+ t.Run(name+"/cas_does_not_disturb_sibling_keys", func(t *testing.T) {
+ s := open(t)
+ w := writerFor(t, s)
+ b, err := s.Create(beads.Bead{Title: "cas-siblings"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := b.ID
+ if err := s.SetMetadata(id, "sibling", "preserved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetMetadata(id, "k", "A"); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := w.CompareAndSetMetadataKey(id, "k", "A", "B"); err != nil || !ok {
+ t.Fatalf("CAS: (%v, %v), want (true, nil)", ok, err)
+ }
+ got, err := s.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Metadata["sibling"] != "preserved" {
+ t.Fatalf("sibling metadata = %q, want %q (read-modify-write dropped it)", got.Metadata["sibling"], "preserved")
+ }
+ if got.Metadata["k"] != "B" {
+ t.Fatalf("target metadata = %q, want %q", got.Metadata["k"], "B")
+ }
+ })
+
+ // The exclusion property the lease/claim callers actually depend on:
+ // under concurrency exactly ONE racer may win a claim from a single
+ // starting value. A store that reports two winners has no mutual
+ // exclusion and cannot carry a lease, however well it passes the
+ // sequential legs above.
+ t.Run(name+"/cas_contention_admits_exactly_one_winner", func(t *testing.T) {
+ if reason := opts.FixtureLacksIsolationReason; reason != "" {
+ // Named, visible absence: the store is not being excused, the
+ // FIXTURE cannot evaluate the claim. The property is owed an
+ // integration test against the real backend.
+ t.Skipf("fixture cannot evaluate contention: %s", reason)
+ }
+ s := open(t)
+ w := writerFor(t, s)
+ b, err := s.Create(beads.Bead{Title: "cas-contention"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := b.ID
+ if err := s.SetMetadata(id, "lease", ""); err != nil {
+ t.Fatal(err)
+ }
+
+ const racers = 8
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ winners []string
+ errs []error
+ )
+ start := make(chan struct{})
+ for i := range racers {
+ wg.Add(1)
+ go func(racer int) {
+ defer wg.Done()
+ holder := "holder-" + strconv.Itoa(racer)
+ <-start
+ ok, err := w.CompareAndSetMetadataKey(id, "lease", "", holder)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ errs = append(errs, err)
+ return
+ }
+ if ok {
+ winners = append(winners, holder)
+ }
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+
+ for _, err := range errs {
+ t.Fatalf("racer returned an error (a lost race must be (false, nil)): %v", err)
+ }
+ if len(winners) != 1 {
+ t.Fatalf("winners = %d %v, want exactly 1 (no mutual exclusion)", len(winners), winners)
+ }
+ got, err := s.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Metadata["lease"] != winners[0] {
+ t.Fatalf("stored lease = %q, want the sole winner %q", got.Metadata["lease"], winners[0])
+ }
+ })
+}
diff --git a/internal/beads/beadstest/recording_store.go b/internal/beads/beadstest/recording_store.go
index af32c7f5e1..729864165c 100644
--- a/internal/beads/beadstest/recording_store.go
+++ b/internal/beads/beadstest/recording_store.go
@@ -14,8 +14,8 @@ import (
// writes to the raw bead op it replaces.
type RecordedCall struct {
// Op is the Store method that was invoked: "Create", "Update", "Close",
- // "Reopen", "CloseAll", "SetMetadata", "SetMetadataBatch", "Delete",
- // "DepAdd", or "DepRemove".
+ // "Reopen", "CloseAll", "SetMetadata", "SetMetadataBatch", "SetLocalString",
+ // "Delete", "DepAdd", or "DepRemove".
Op string
// ID is the target bead id for ops that address a single bead. For Create
@@ -188,6 +188,13 @@ func (r *RecordingStore) SetMetadataBatch(id string, kvs map[string]string) erro
return r.Store.SetMetadataBatch(id, kvs)
}
+// SetLocalString records the clone-local write then delegates. GetLocalString
+// is a read and, like Get, is passed straight through unrecorded.
+func (r *RecordingStore) SetLocalString(id, key, value string) error {
+ r.record(RecordedCall{Op: "SetLocalString", ID: id, Key: key, Value: value})
+ return r.Store.SetLocalString(id, key, value)
+}
+
// Delete records the delete then delegates.
func (r *RecordingStore) Delete(id string) error {
r.record(RecordedCall{Op: "Delete", ID: id})
diff --git a/internal/beads/boundary_test.go b/internal/beads/boundary_test.go
index 7229ca04e1..262143294f 100644
--- a/internal/beads/boundary_test.go
+++ b/internal/beads/boundary_test.go
@@ -16,43 +16,31 @@ func repoRoot() string {
return filepath.Join(filepath.Dir(filename), "..", "..")
}
-// TestNoBdExecOutsideBeads enforces the architectural invariant that all bd
-// subprocess calls must live in internal/beads/. This prevents coupling sprawl
-// and ensures all bd interactions go through the BdStore abstraction.
-//
-// Two categories of violations:
-// 1. exec.Command("bd"...) or exec.CommandContext(..."bd"...) — direct subprocess calls
-// 2. Variable assignments building bd command strings for shell execution
-// (e.g., cmd := "bd mol cook ...")
-//
-// Not violations (allowed):
-// - internal/beads/ — that's where bd calls belong
-// - test/integration/ — integration tests may use real bd for setup
-// - Config defaults returning bd command templates (WorkQuery, SlingQuery)
-// and command-name token consts (bdReadyOracleCommand = "bd ready")
-// - Test fixture data (map keys, runner output, assertions)
-// - Binary existence checks (LookPath)
-// - Provider comparisons (== "bd", != "bd")
-func TestNoBdExecOutsideBeads(t *testing.T) {
- root := repoRoot()
-
- // Directories where bd calls are allowed.
- allowedDirs := []string{
- filepath.Join("internal", "beads") + string(filepath.Separator),
- filepath.Join("internal", "deps") + string(filepath.Separator), // version checks only (bd version)
- filepath.Join("internal", "doctor") + string(filepath.Separator), // health checks query bd config directly
- filepath.Join("internal", "dolt") + string(filepath.Separator), // upstream-synced from gastown
- // env.ledger conformance probe execs `bd ready` INSIDE the provisioned
- // box (via the runtime exec op), to verify the session's bd can reach the
- // work ledger — a box-side capability probe, not a gc-side bd subprocess.
- filepath.Join("internal", "runtime", "runtimecapability") + string(filepath.Separator),
- filepath.Join("test", "integration") + string(filepath.Separator),
- // dashboard BFF runs read-only `bd doctor` health probes against
- // arbitrary per-rig .beads stores (supervisor-reported paths). This is
- // the same direct-bd usage the retired cmd/gc/dashboard server had.
- filepath.Join("internal", "api", "dashboardbff") + string(filepath.Separator),
- }
+// bdExecAllowedDirs lists directories where bd calls are allowed.
+var bdExecAllowedDirs = []string{
+ filepath.Join("internal", "beads") + string(filepath.Separator),
+ filepath.Join("internal", "deps") + string(filepath.Separator), // version checks only (bd version)
+ filepath.Join("internal", "doctor") + string(filepath.Separator), // health checks query bd config directly
+ filepath.Join("internal", "dolt") + string(filepath.Separator), // upstream-synced from gastown
+ // env.ledger conformance probe execs `bd ready` INSIDE the provisioned
+ // box (via the runtime exec op), to verify the session's bd can reach the
+ // work ledger — a box-side capability probe, not a gc-side bd subprocess.
+ filepath.Join("internal", "runtime", "runtimecapability") + string(filepath.Separator),
+ filepath.Join("test", "integration") + string(filepath.Separator),
+ // dashboard BFF runs read-only `bd doctor` health probes against
+ // arbitrary per-rig .beads stores (supervisor-reported paths). This is
+ // the same direct-bd usage the retired cmd/gc/dashboard server had.
+ filepath.Join("internal", "api", "dashboardbff") + string(filepath.Separator),
+}
+// findBdExecViolations walks root looking for bd subprocess calls outside
+// bdExecAllowedDirs. It skips vendored/cache directories, including any
+// nested Go module (a directory other than root that owns its own go.mod) —
+// an in-tree GOMODCACHE, the canonical layout on CI systems that require
+// caches to live inside the checkout, otherwise gets walked as if it were
+// first-party source and flags third-party (or bd's own vendored) sources
+// as violations (#4480).
+func findBdExecViolations(root string) ([]string, error) {
var violations []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
@@ -68,6 +56,14 @@ func TestNoBdExecOutsideBeads(t *testing.T) {
if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
return filepath.SkipDir
}
+ // Skip nested Go modules: any directory other than root that owns
+ // its own go.mod is a separate module's source tree (a module-cache
+ // entry), not part of this repo.
+ if path != root {
+ if _, serr := os.Stat(filepath.Join(path, "go.mod")); serr == nil {
+ return filepath.SkipDir
+ }
+ }
return nil
}
if !strings.HasSuffix(path, ".go") {
@@ -78,7 +74,7 @@ func TestNoBdExecOutsideBeads(t *testing.T) {
if err != nil {
return err
}
- for _, dir := range allowedDirs {
+ for _, dir := range bdExecAllowedDirs {
if strings.HasPrefix(rel, dir) {
return nil
}
@@ -127,6 +123,28 @@ func TestNoBdExecOutsideBeads(t *testing.T) {
}
return scanner.Err()
})
+ return violations, err
+}
+
+// TestNoBdExecOutsideBeads enforces the architectural invariant that all bd
+// subprocess calls must live in internal/beads/. This prevents coupling sprawl
+// and ensures all bd interactions go through the BdStore abstraction.
+//
+// Two categories of violations:
+// 1. exec.Command("bd"...) or exec.CommandContext(..."bd"...) — direct subprocess calls
+// 2. Variable assignments building bd command strings for shell execution
+// (e.g., cmd := "bd mol cook ...")
+//
+// Not violations (allowed):
+// - internal/beads/ — that's where bd calls belong
+// - test/integration/ — integration tests may use real bd for setup
+// - Config defaults returning bd command templates (WorkQuery, SlingQuery)
+// and command-name token consts (bdReadyOracleCommand = "bd ready")
+// - Test fixture data (map keys, runner output, assertions)
+// - Binary existence checks (LookPath)
+// - Provider comparisons (== "bd", != "bd")
+func TestNoBdExecOutsideBeads(t *testing.T) {
+ violations, err := findBdExecViolations(repoRoot())
if err != nil {
t.Fatalf("walking repo: %v", err)
}
@@ -141,6 +159,52 @@ func TestNoBdExecOutsideBeads(t *testing.T) {
}
}
+// TestFindBdExecViolationsSkipsNestedGoModules pins the fix for #4480: an
+// in-tree GOMODCACHE (the canonical CI layout when cache paths must live
+// inside the checkout) previously got walked like first-party source,
+// flagging third-party — or even bd's own vendored — sources as violations.
+// A directory that owns its own go.mod is a separate module's source tree
+// and must be skipped, while a real violation living directly in the
+// checkout must still be caught.
+func TestFindBdExecViolationsSkipsNestedGoModules(t *testing.T) {
+ root := t.TempDir()
+
+ mustWriteFile(t, filepath.Join(root, "go.mod"), "module example.com/fixture\n")
+
+ // A real violation, directly in the checkout, outside any allowed dir.
+ mustWriteFile(t, filepath.Join(root, "cmd", "gc", "example.go"),
+ "package main\n\nfunc run() { exec.Command(\"bd\", \"prime\") }\n")
+
+ // A nested module (module-cache-shaped): its own go.mod plus a source
+ // file containing the exact same call pattern. Must be skipped entirely.
+ nestedModule := filepath.Join(root, ".cache", "go-mod", "github.com", "steveyegge", "beads@v1.1.0")
+ mustWriteFile(t, filepath.Join(nestedModule, "go.mod"), "module github.com/steveyegge/beads\n")
+ mustWriteFile(t, filepath.Join(nestedModule, "cmd", "bd", "doctor", "claude.go"),
+ "package doctor\n\nfunc run() { exec.Command(\"bd\", \"prime\") }\n")
+
+ violations, err := findBdExecViolations(root)
+ if err != nil {
+ t.Fatalf("findBdExecViolations: %v", err)
+ }
+
+ if len(violations) != 1 {
+ t.Fatalf("violations = %v, want exactly 1 (the real violation, nested module skipped)", violations)
+ }
+ if !strings.Contains(violations[0], filepath.Join("cmd", "gc", "example.go")) {
+ t.Fatalf("violations[0] = %q, want the cmd/gc/example.go violation", violations[0])
+ }
+}
+
+func mustWriteFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("MkdirAll(%q): %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("WriteFile(%q): %v", path, err)
+ }
+}
+
// isBdCommandAssignment detects lines that build bd command strings for shell
// execution via variable assignment. Returns true for patterns like:
//
diff --git a/internal/beads/caching_store_conditional.go b/internal/beads/caching_store_conditional.go
index cda8b82009..9388166992 100644
--- a/internal/beads/caching_store_conditional.go
+++ b/internal/beads/caching_store_conditional.go
@@ -202,7 +202,14 @@ func (c *CachingStore) DeleteIfMatch(id string, expectedRevision int64) error {
// `expected`, and without the evict a cross-process loser re-reads the same
// stale value through the cache and re-loses until an unrelated reconcile.
func (c *CachingStore) CompareAndSetMetadataKey(id, key, expected, next string) (bool, error) {
- writer, ok := ConditionalWriterFor(c.conditionalBacking())
+ // Resolve the NARROW capability, not ConditionalWriter: a backing that can
+ // do value-CAS but cannot soundly fence on a revision (NativeDoltStore)
+ // declares MetadataCASWriter only, and every ConditionalWriter satisfies
+ // MetadataCASWriter anyway, so this widens the backings that forward
+ // without changing behavior for fully capable ones. The trio above keeps
+ // resolving through ConditionalWriterFor — a narrow backing must not
+ // unlock a revision fence it cannot honor.
+ writer, ok := MetadataCASWriterFor(c.conditionalBacking())
if !ok {
return false, ErrConditionalWriteUnsupported
}
diff --git a/internal/beads/caching_store_writes.go b/internal/beads/caching_store_writes.go
index f90cef8da1..a1cfe9770c 100644
--- a/internal/beads/caching_store_writes.go
+++ b/internal/beads/caching_store_writes.go
@@ -424,6 +424,13 @@ func (c *CachingStore) SetMetadataBatch(id string, kvs map[string]string) error
return nil
}
if err := c.backing.SetMetadataBatch(id, kvs); err != nil {
+ // The backing may have rejected, partially committed, or fully committed
+ // the batch before returning an error. Fence the cached pre-write row
+ // until an ordinary read installs backing truth.
+ c.mu.Lock()
+ c.noteMutationLocked(id)
+ c.markDirtyLocked(id)
+ c.mu.Unlock()
return err
}
@@ -466,6 +473,20 @@ func (c *CachingStore) SetMetadataBatch(id string, kvs map[string]string) error
return nil
}
+// SetLocalString delegates directly to the backing store. Clone-local data
+// is never cached or notified: it isn't part of Bead.Metadata, so there is
+// no cached Bead field to keep in sync, and (unlike SetMetadata) writing it
+// never invokes bd's subprocess/hook path, so there is no idempotence check
+// to save a redundant call.
+func (c *CachingStore) SetLocalString(id, key, value string) error {
+ return c.backing.SetLocalString(id, key, value)
+}
+
+// GetLocalString delegates directly to the backing store. See SetLocalString.
+func (c *CachingStore) GetLocalString(id, key string) (string, error) {
+ return c.backing.GetLocalString(id, key)
+}
+
func (c *CachingStore) refreshBeadAfterWrite(id, op string) (Bead, bool) {
fresh, err := c.backing.Get(id)
if err != nil {
diff --git a/internal/beads/caching_store_writes_internal_test.go b/internal/beads/caching_store_writes_internal_test.go
index a71393d84b..f97763a47a 100644
--- a/internal/beads/caching_store_writes_internal_test.go
+++ b/internal/beads/caching_store_writes_internal_test.go
@@ -65,6 +65,25 @@ type releaseRefreshFailOnceStore struct {
failNextGet bool
}
+type ambiguousMetadataBatchStore struct {
+ Store
+ err error
+ commitKeys []string
+}
+
+func (s *ambiguousMetadataBatchStore) SetMetadataBatch(id string, kvs map[string]string) error {
+ committed := make(map[string]string, len(s.commitKeys))
+ for _, key := range s.commitKeys {
+ committed[key] = kvs[key]
+ }
+ if len(committed) > 0 {
+ if err := s.Store.SetMetadataBatch(id, committed); err != nil {
+ return err
+ }
+ }
+ return s.err
+}
+
func (s *releaseRefreshFailOnceStore) Get(id string) (Bead, error) {
if s.failNextGet {
s.failNextGet = false
@@ -268,6 +287,80 @@ func TestCachingStoreSetMetadataBatchNotifiesBeadUpdated(t *testing.T) {
}
}
+func TestCachingStoreSetMetadataBatchErrorFencesStaleRow(t *testing.T) {
+ patch := map[string]string{"state": "asleep", "reason": "healed"}
+ for _, tc := range []struct {
+ name string
+ commitKeys []string
+ wantState string
+ wantReason string
+ }{
+ {name: "rejected", wantState: "active", wantReason: "old"},
+ {name: "partially committed", commitKeys: []string{"state"}, wantState: "asleep", wantReason: "old"},
+ {name: "fully committed", commitKeys: []string{"state", "reason"}, wantState: "asleep", wantReason: "healed"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ mem := NewMemStore()
+ bead, err := mem.Create(Bead{
+ Title: "worker",
+ Type: "session",
+ Metadata: map[string]string{"state": "active", "reason": "old"},
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ writeErr := errors.New("ambiguous metadata batch")
+ backing := &ambiguousMetadataBatchStore{
+ Store: mem,
+ err: writeErr,
+ commitKeys: tc.commitKeys,
+ }
+ notifications := 0
+ cache := NewCachingStoreForTest(backing, func(string, string, json.RawMessage) {
+ notifications++
+ })
+ if err := cache.Prime(context.Background()); err != nil {
+ t.Fatalf("Prime: %v", err)
+ }
+ cache.mu.RLock()
+ startSeq := cache.mutationSeq
+ cache.mu.RUnlock()
+
+ if err := cache.SetMetadataBatch(bead.ID, patch); !errors.Is(err, writeErr) {
+ t.Fatalf("SetMetadataBatch error = %v, want %v", err, writeErr)
+ }
+
+ cache.mu.RLock()
+ fence := cache.beadSeq[bead.ID]
+ _, dirty := cache.dirty[bead.ID]
+ _, local := cache.localBeadAt[bead.ID]
+ cache.mu.RUnlock()
+ if fence <= startSeq || !dirty || local {
+ t.Fatalf("ambiguity fence = seq:%d start:%d dirty:%v local:%v", fence, startSeq, dirty, local)
+ }
+ if notifications != 0 {
+ t.Fatalf("notifications = %d, want 0 for an unconfirmed write", notifications)
+ }
+ query := ListQuery{Type: "session"}
+ if _, ok := cache.CachedList(query); ok {
+ t.Fatal("CachedList served a row whose backing write outcome is unknown")
+ }
+
+ rows, err := cache.List(query)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(rows) != 1 || rows[0].Metadata["state"] != tc.wantState || rows[0].Metadata["reason"] != tc.wantReason {
+ t.Fatalf("List metadata = %#v, want state=%q reason=%q", rows, tc.wantState, tc.wantReason)
+ }
+ if rows, ok := cache.CachedList(query); !ok || len(rows) != 1 ||
+ rows[0].Metadata["state"] != tc.wantState || rows[0].Metadata["reason"] != tc.wantReason {
+ t.Fatalf("CachedList after reread = %#v, ok=%v", rows, ok)
+ }
+ })
+ }
+}
+
func TestCachingStoreReleaseIfCurrentDelegatesAndRefreshesCache(t *testing.T) {
t.Parallel()
diff --git a/internal/beads/doltlite_read_store.go b/internal/beads/doltlite_read_store.go
index f96306e9a6..f29d8a1518 100644
--- a/internal/beads/doltlite_read_store.go
+++ b/internal/beads/doltlite_read_store.go
@@ -120,10 +120,16 @@ func doltliteIssueTypeNotInPredicate(alias string) (string, []any) {
return "COALESCE(" + alias + ".issue_type, '') NOT IN (" + placeholders + ")", args
}
-func NewDoltliteReadStore(dir string, backing *BdStore) (*DoltliteReadStore, error) {
+// doltliteDBPath resolves the physical SQLite database file for the DoltLite
+// store rooted at dir. It is the single source of truth for the
+// .beads/doltlite/.db path so the read path and the maintenance reindex
+// path always target the same file. The database name comes from
+// .beads/metadata.json (dolt_database, then database), falling back to the "hq"
+// default bd uses when neither pins a concrete name.
+func doltliteDBPath(dir string) (string, error) {
meta, err := readDoltliteMetadata(dir)
if err != nil {
- return nil, err
+ return "", err
}
dbName := strings.TrimSpace(meta.DoltDatabase)
if dbName == "" || dbName == "doltlite" {
@@ -132,7 +138,14 @@ func NewDoltliteReadStore(dir string, backing *BdStore) (*DoltliteReadStore, err
if dbName == "" || dbName == "doltlite" {
dbName = "hq"
}
- dbPath := filepath.Join(dir, ".beads", "doltlite", dbName+".db")
+ return filepath.Join(dir, ".beads", "doltlite", dbName+".db"), nil
+}
+
+func NewDoltliteReadStore(dir string, backing *BdStore) (*DoltliteReadStore, error) {
+ dbPath, err := doltliteDBPath(dir)
+ if err != nil {
+ return nil, err
+ }
if _, err := os.Stat(dbPath); err != nil {
return nil, err
}
@@ -149,6 +162,38 @@ func NewDoltliteReadStore(dir string, backing *BdStore) (*DoltliteReadStore, err
return &DoltliteReadStore{BdStore: backing, db: db}, nil
}
+// ReindexDoltliteStore rebuilds the DoltLite store's SQLite secondary indexes.
+// `bd flatten`/`bd gc` rewrite the underlying store and can leave the physical
+// .db's secondary indexes stale, so index-path reads (count/status/list)
+// silently return wrong results until the indexes are rebuilt (ga-7hei).
+// REINDEX is SQLite-specific DDL, so it runs against the physical
+// .beads/doltlite/.db file through the same SQLite engine the read path
+// uses (modernc.org/sqlite) — not `bd sql`, which speaks Dolt/MySQL and cannot
+// execute it. The store is opened read-write only for the duration of the
+// rebuild.
+func ReindexDoltliteStore(dir string) error {
+ dbPath, err := doltliteDBPath(dir)
+ if err != nil {
+ return err
+ }
+ if _, err := os.Stat(dbPath); err != nil {
+ return fmt.Errorf("doltlite store %q: %w", dbPath, err)
+ }
+ db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=rw&_busy_timeout=10000")
+ if err != nil {
+ return fmt.Errorf("opening doltlite store %q: %w", dbPath, err)
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec("REINDEX"); err != nil {
+ _ = db.Close()
+ return fmt.Errorf("reindexing doltlite store %q: %w", dbPath, err)
+ }
+ if err := db.Close(); err != nil {
+ return fmt.Errorf("closing doltlite store %q after reindex: %w", dbPath, err)
+ }
+ return nil
+}
+
func readDoltliteMetadata(dir string) (doltliteMetadata, error) {
var meta doltliteMetadata
data, err := os.ReadFile(filepath.Join(dir, ".beads", "metadata.json"))
diff --git a/internal/beads/doltlite_read_store_test.go b/internal/beads/doltlite_read_store_test.go
index 5b83360a7a..3c9dc72617 100644
--- a/internal/beads/doltlite_read_store_test.go
+++ b/internal/beads/doltlite_read_store_test.go
@@ -12,10 +12,12 @@ import (
"reflect"
"slices"
"strings"
+ "sync/atomic"
"testing"
"time"
"github.com/gastownhall/gascity/internal/rollout/gate"
+ sqlite "modernc.org/sqlite"
)
func TestDoltliteReadStoreListsSessionBeads(t *testing.T) {
@@ -1920,3 +1922,176 @@ func TestDoltliteReadStoreResolveConditionalWriterDegrades(t *testing.T) {
t.Fatalf("require over doltlite = (%v, %v, %v), want (nil, diag, typed refusal)", w, diag, err)
}
}
+
+// TestDoltliteReindexStore is the behavioral proof for ga-7hei: the reindex
+// mechanism must execute a real SQLite REINDEX against the physical
+// .beads/doltlite/.db file (the property `bd sql 'REINDEX'` could not
+// satisfy, since it speaks Dolt/MySQL). After the rebuild the store stays a
+// valid SQLite database whose secondary index returns correct results.
+func TestDoltliteReindexStore(t *testing.T) {
+ dir := t.TempDir()
+ beadsDir := filepath.Join(dir, ".beads")
+ if err := os.MkdirAll(filepath.Join(beadsDir, "doltlite"), 0o755); err != nil {
+ t.Fatalf("mkdir doltlite dir: %v", err)
+ }
+ meta := []byte(`{"backend":"doltlite","database":"doltlite","dolt_database":"hq"}`)
+ if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), meta, 0o600); err != nil {
+ t.Fatalf("write metadata: %v", err)
+ }
+ dbPath := filepath.Join(beadsDir, "doltlite", "hq.db")
+ db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=rwc&_busy_timeout=10000")
+ if err != nil {
+ t.Fatalf("open fixture db: %v", err)
+ }
+ for _, stmt := range []string{
+ `CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT)`,
+ `CREATE INDEX idx_issues_status ON issues(status)`,
+ `INSERT INTO issues (id, status) VALUES ('a','open'),('b','open'),('c','closed')`,
+ } {
+ if _, err := db.Exec(stmt); err != nil {
+ _ = db.Close()
+ t.Fatalf("seed fixture: %v\nstmt: %s", err, stmt)
+ }
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("close fixture db: %v", err)
+ }
+
+ if err := ReindexDoltliteStore(dir); err != nil {
+ t.Fatalf("ReindexDoltliteStore: %v", err)
+ }
+
+ check, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro&_busy_timeout=10000")
+ if err != nil {
+ t.Fatalf("reopen db: %v", err)
+ }
+ defer check.Close() //nolint:errcheck // test cleanup
+
+ var integrity string
+ if err := check.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil {
+ t.Fatalf("integrity_check: %v", err)
+ }
+ if integrity != "ok" {
+ t.Fatalf("integrity_check = %q, want ok", integrity)
+ }
+
+ var openCount int
+ if err := check.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'open'").Scan(&openCount); err != nil {
+ t.Fatalf("indexed count: %v", err)
+ }
+ if openCount != 2 {
+ t.Fatalf("open issues via index = %d, want 2", openCount)
+ }
+}
+
+// reindexStaleCollSeq gives each stale-index fixture a unique collation name.
+// modernc.org/sqlite registers collations globally for the whole process, so a
+// reused name would let a prior run's closure (and its flipped ordering) leak
+// into the next, making the fixture non-deterministic under -count>1.
+var reindexStaleCollSeq atomic.Int64
+
+// TestDoltliteReindexStoreHealsStaleIndex is the regression proof ga-7hei
+// actually needs: it fails unless ReindexDoltliteStore executes a real SQLite
+// REINDEX. It builds a genuinely stale secondary index — the exact condition
+// REINDEX exists to repair, per SQLite's docs: an index built under one
+// collation definition goes stale when that definition changes. We register a
+// collation whose ordering flips after the index is populated, so the persisted
+// index is ordered per the old definition while SQLite now compares per the new
+// one. `PRAGMA integrity_check` then reports the index as corrupt, and only a
+// real REINDEX rebuilds it. Had ReindexDoltliteStore opened the database and
+// skipped db.Exec("REINDEX"), the corruption would survive and the final
+// assertion would fail — the gap the previous healthy-fixture test could not
+// catch.
+func TestDoltliteReindexStoreHealsStaleIndex(t *testing.T) {
+ collName := fmt.Sprintf("gasstalecoll%d", reindexStaleCollSeq.Add(1))
+ var reversed atomic.Bool
+ if err := sqlite.RegisterCollationUtf8(collName, func(a, b string) int {
+ c := strings.Compare(a, b)
+ if reversed.Load() {
+ return -c
+ }
+ return c
+ }); err != nil {
+ t.Fatalf("register collation: %v", err)
+ }
+
+ dir := t.TempDir()
+ beadsDir := filepath.Join(dir, ".beads")
+ if err := os.MkdirAll(filepath.Join(beadsDir, "doltlite"), 0o755); err != nil {
+ t.Fatalf("mkdir doltlite dir: %v", err)
+ }
+ meta := []byte(`{"backend":"doltlite","database":"doltlite","dolt_database":"hq"}`)
+ if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), meta, 0o600); err != nil {
+ t.Fatalf("write metadata: %v", err)
+ }
+ dbPath := filepath.Join(beadsDir, "doltlite", "hq.db")
+
+ // Build the index while the collation sorts ascending.
+ db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=rwc&_busy_timeout=10000")
+ if err != nil {
+ t.Fatalf("open fixture db: %v", err)
+ }
+ for _, stmt := range []string{
+ `CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT COLLATE ` + collName + `)`,
+ `CREATE INDEX idx_issues_status ON issues(status COLLATE ` + collName + `)`,
+ `INSERT INTO issues (id, status) VALUES ('a','alpha'),('b','bravo'),('c','charlie'),('d','delta'),('e','echo')`,
+ } {
+ if _, err := db.Exec(stmt); err != nil {
+ _ = db.Close()
+ t.Fatalf("seed fixture: %v\nstmt: %s", err, stmt)
+ }
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("close fixture db: %v", err)
+ }
+
+ // Change the collation's definition. The persisted index is now ordered per
+ // the old ascending definition, but SQLite compares per the new one.
+ reversed.Store(true)
+
+ integrityCheck := func(tag string) string {
+ c, err := sql.Open("sqlite", "file:"+dbPath+"?mode=rw&_busy_timeout=10000")
+ if err != nil {
+ t.Fatalf("%s open: %v", tag, err)
+ }
+ defer c.Close() //nolint:errcheck // test cleanup
+ var result string
+ if err := c.QueryRow("PRAGMA integrity_check").Scan(&result); err != nil {
+ t.Fatalf("%s integrity_check: %v", tag, err)
+ }
+ return result
+ }
+
+ // Precondition: the fixture is genuinely stale. Without this guard, a future
+ // change that stops producing staleness would let the post-reindex "ok"
+ // assertion pass trivially, silently regressing this back to the toothless
+ // healthy-store check it strengthens.
+ if before := integrityCheck("before"); before == "ok" {
+ t.Fatalf("precondition failed: expected a stale index before reindex, got integrity_check=ok")
+ }
+
+ if err := ReindexDoltliteStore(dir); err != nil {
+ t.Fatalf("ReindexDoltliteStore: %v", err)
+ }
+
+ if after := integrityCheck("after"); after != "ok" {
+ t.Fatalf("integrity_check after reindex = %q, want ok (REINDEX must rebuild the stale index)", after)
+ }
+}
+
+// TestDoltliteReindexStoreRejectsNonDoltlite proves the reindex path refuses a
+// store that metadata.json does not identify as DoltLite, rather than silently
+// operating on the wrong backend.
+func TestDoltliteReindexStoreRejectsNonDoltlite(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, ".beads"), 0o755); err != nil {
+ t.Fatalf("mkdir beads dir: %v", err)
+ }
+ meta := []byte(`{"backend":"dolt","database":"ga"}`)
+ if err := os.WriteFile(filepath.Join(dir, ".beads", "metadata.json"), meta, 0o600); err != nil {
+ t.Fatalf("write metadata: %v", err)
+ }
+ if err := ReindexDoltliteStore(dir); err == nil {
+ t.Fatal("ReindexDoltliteStore accepted a non-doltlite store, want error")
+ }
+}
diff --git a/internal/beads/doltlite_seek_test.go b/internal/beads/doltlite_seek_test.go
index de6b41b0ac..cb05ad2f6f 100644
--- a/internal/beads/doltlite_seek_test.go
+++ b/internal/beads/doltlite_seek_test.go
@@ -36,7 +36,7 @@ func TestDoltliteCountUnsupportedForSeek(t *testing.T) {
}
}
-func TestFilterDoltliteBeforeTimesAppliesSeek(t *testing.T) {
+func TestDoltliteFilterBeforeTimesAppliesSeek(t *testing.T) {
ts := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
rows := []Bead{
{ID: "gc-3", CreatedAt: ts.Add(2 * time.Second)}, // newer than boundary — drop
diff --git a/internal/beads/exec/exec.go b/internal/beads/exec/exec.go
index d0927480d8..50e078d25e 100644
--- a/internal/beads/exec/exec.go
+++ b/internal/beads/exec/exec.go
@@ -11,6 +11,7 @@ import (
"sort"
"strconv"
"strings"
+ "sync"
"time"
"github.com/gastownhall/gascity/internal/beads"
@@ -26,6 +27,14 @@ type Store struct {
script string
timeout time.Duration
env map[string]string
+
+ // localMu and localStrings back SetLocalString/GetLocalString. Clone-local
+ // data is kept in process only, never routed through the script: unlike
+ // every other operation, it must not depend on script latency, since the
+ // whole point is cheap high-churn writes. It does not survive process
+ // restart, which is an accepted limitation for this delegating store.
+ localMu sync.Mutex
+ localStrings map[string]map[string]string
}
// SetEnv sets environment variables passed to the script process.
@@ -468,6 +477,36 @@ func (s *Store) SetMetadataBatch(id string, kvs map[string]string) error {
return nil
}
+// SetLocalString sets a clone-local string value for a bead. See
+// [beads.Store.SetLocalString]. Kept in an in-process map rather than
+// delegated to the script, so it never pays script-invocation latency and
+// never validates that id refers to an existing bead — see the interface
+// doc comment for why.
+func (s *Store) SetLocalString(id, key, value string) error {
+ s.localMu.Lock()
+ defer s.localMu.Unlock()
+ if value == "" {
+ delete(s.localStrings[id], key)
+ return nil
+ }
+ if s.localStrings == nil {
+ s.localStrings = make(map[string]map[string]string)
+ }
+ if s.localStrings[id] == nil {
+ s.localStrings[id] = make(map[string]string)
+ }
+ s.localStrings[id][key] = value
+ return nil
+}
+
+// GetLocalString returns the clone-local string value for a bead. See
+// [beads.Store.GetLocalString].
+func (s *Store) GetLocalString(id, key string) (string, error) {
+ s.localMu.Lock()
+ defer s.localMu.Unlock()
+ return s.localStrings[id][key], nil
+}
+
// Tx executes fn sequentially against the exec store.
func (s *Store) Tx(_ string, fn func(beads.Tx) error) error {
if fn == nil {
@@ -478,8 +517,13 @@ func (s *Store) Tx(_ string, fn func(beads.Tx) error) error {
// Delete permanently removes a bead by calling the "delete" subcommand.
func (s *Store) Delete(id string) error {
- _, err := s.run(nil, "delete", "--force", id)
- return err
+ if _, err := s.run(nil, "delete", "--force", id); err != nil {
+ return err
+ }
+ s.localMu.Lock()
+ delete(s.localStrings, id)
+ s.localMu.Unlock()
+ return nil
}
// Ping verifies the store script is accessible by running a list operation.
diff --git a/internal/beads/filestore.go b/internal/beads/filestore.go
index 574a06c757..65b253cc9e 100644
--- a/internal/beads/filestore.go
+++ b/internal/beads/filestore.go
@@ -32,6 +32,44 @@ type fileData struct {
// any counter a prior writer could have issued (see
// applyBeadRevisionsSealed).
RevisionsSealed bool `json:"revisions_sealed,omitempty"`
+ // Fences persists each bead's ClaimFence out of band, because
+ // Bead.ClaimFence is json:"-" and never survives the on-disk []Bead. Without
+ // it every reloadFromDisk would reset all fences to 0, so a guarded release
+ // holding an older fence could no longer match. Unlike Revisions this needs
+ // no sealed/floor re-seed: a fence bumps only on ownership transitions (not
+ // on every write), so a legacy binary dropping the map resets fences to 0,
+ // which is FAIL-SAFE — a stale guard sees a mismatch and refuses, never
+ // wrongly succeeds. Absent (legacy files) ≡ all zero.
+ Fences map[string]int64 `json:"fences,omitempty"`
+}
+
+// beadFences extracts the out-of-band ClaimFence map for persistence. Zero
+// fences are omitted (absent ≡ 0 on reload), so legacy files round-trip.
+func beadFences(beads []Bead) map[string]int64 {
+ fences := make(map[string]int64, len(beads))
+ for _, b := range beads {
+ if b.ClaimFence != 0 {
+ fences[b.ID] = b.ClaimFence
+ }
+ }
+ if len(fences) == 0 {
+ return nil
+ }
+ return fences
+}
+
+// applyBeadFences stamps persisted fences back onto beads decoded from disk,
+// whose ClaimFence fields are all 0 because of the json:"-" tag. Beads with no
+// entry keep fence 0, matching files that predate the fences map.
+func applyBeadFences(beads []Bead, fences map[string]int64) {
+ if len(fences) == 0 {
+ return
+ }
+ for i := range beads {
+ if f, ok := fences[beads[i].ID]; ok {
+ beads[i].ClaimFence = f
+ }
+ }
}
// beadRevisions extracts the out-of-band revision map for persistence. Zero
@@ -166,6 +204,7 @@ func OpenFileStore(fs fsys.FS, path string) (*FileStore, error) {
return nil, fmt.Errorf("opening file store: %w", err)
}
applyBeadRevisionsSealed(&fd)
+ applyBeadFences(fd.Beads, fd.Fences)
store := &FileStore{
MemStore: NewMemStoreFrom(fd.Seq, fd.Beads, fd.Deps),
fs: fs,
@@ -203,6 +242,7 @@ func (fs *FileStore) reloadFromDisk() error {
return fmt.Errorf("reloading file store: %w", err)
}
applyBeadRevisionsSealed(&fd)
+ applyBeadFences(fd.Beads, fd.Fences)
fs.restoreFrom(fd.Seq, fd.Beads, fd.Deps)
return nil
}
@@ -671,7 +711,7 @@ func (fs *FileStore) save() error {
seq, beads, deps := fs.snapshot()
fs.mu.Unlock()
- fd := fileData{Seq: seq, Beads: beads, Deps: deps, Revisions: beadRevisions(beads), RevisionsSealed: true}
+ fd := fileData{Seq: seq, Beads: beads, Deps: deps, Revisions: beadRevisions(beads), RevisionsSealed: true, Fences: beadFences(beads)}
data, err := json.MarshalIndent(fd, "", " ")
if err != nil {
return fmt.Errorf("saving file store: %w", err)
diff --git a/internal/beads/filestore_test.go b/internal/beads/filestore_test.go
index fe40f9fdba..4429cf4e6e 100644
--- a/internal/beads/filestore_test.go
+++ b/internal/beads/filestore_test.go
@@ -91,6 +91,7 @@ func TestFileStore(t *testing.T) {
beadstest.RunCreationOrderTests(t, factory)
beadstest.RunDepTests(t, factory)
beadstest.RunMetadataTests(t, factory)
+ beadstest.RunFenceConformance(t, factory)
}
func TestFileStoreConditionalWriterConformance(t *testing.T) {
@@ -192,6 +193,135 @@ func TestFileStoreRevisionSurvivesReopen(t *testing.T) {
}
}
+// TestFileStoreFenceSurvivesReopen proves the ownership fence round-trips
+// through disk — ClaimFence is json:"-" on Bead, so it only survives via the
+// out-of-band Fences map. reloadFromDisk runs before every write, so a dropped
+// fence would reset to 0 mid-session in cross-process mode and silently defeat a
+// guarded release. Two beads (one transitioned, one never claimed) catch
+// per-bead persistence bugs: a reload that resets a fenced bead to 0, or a
+// persist that spuriously writes a fence for an untouched fence-0 bead.
+func TestFileStoreFenceSurvivesReopen(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "beads.json")
+ s1, err := beads.OpenFileStore(fsys.OSFS{}, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fenced, err := s1.Create(beads.Bead{Title: "fenced"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Three ownership transitions: claim → handoff → unclaim (fence → 3).
+ for _, a := range []string{"worker-a", "worker-b", ""} {
+ assignee := a
+ if err := s1.Update(fenced.ID, beads.UpdateOpts{Assignee: &assignee}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ untouched, err := s1.Create(beads.Bead{Title: "untouched"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ beforeFenced, err := s1.Get(fenced.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if beforeFenced.ClaimFence != 3 {
+ t.Fatalf("after 3 transitions ClaimFence = %d, want 3", beforeFenced.ClaimFence)
+ }
+
+ // Reopen from disk in a fresh handle (a second process).
+ s2, err := beads.OpenFileStore(fsys.OSFS{}, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ afterFenced, err := s2.Get(fenced.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if afterFenced.ClaimFence != beforeFenced.ClaimFence {
+ t.Fatalf("fence did not survive reopen: %d -> %d", beforeFenced.ClaimFence, afterFenced.ClaimFence)
+ }
+ afterUntouched, err := s2.Get(untouched.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if afterUntouched.ClaimFence != 0 {
+ t.Fatalf("never-claimed bead came back with ClaimFence %d, want 0", afterUntouched.ClaimFence)
+ }
+}
+
+// TestFileStoreReleaseIfCurrentFenceSurvivesReopen covers the marquee
+// guarded-release round-trip: a release through ReleaseIfCurrent (the
+// ConditionalAssignmentReleaser path, NOT on the base Store interface, so the
+// interface-typed fence conformance cannot reach it) must bump the fence AND
+// persist it across a fresh handle. Deleting the save from
+// FileStore.ReleaseIfCurrent ships green without this test.
+func TestFileStoreReleaseIfCurrentFenceSurvivesReopen(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "beads.json")
+ s1, err := beads.OpenFileStore(fsys.OSFS{}, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := s1.Create(beads.Bead{Title: "work"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Claim and set in_progress so ReleaseIfCurrent applies.
+ if err := s1.Update(b.ID, beads.UpdateOpts{Assignee: ptr("worker-1"), Status: ptr("in_progress")}); err != nil {
+ t.Fatal(err)
+ }
+ released, err := s1.ReleaseIfCurrent(b.ID, "worker-1")
+ if err != nil || !released {
+ t.Fatalf("ReleaseIfCurrent released=%v err=%v", released, err)
+ }
+ before, err := s1.Get(b.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before.ClaimFence == 0 {
+ t.Fatalf("ReleaseIfCurrent did not bump ClaimFence: %+v", before)
+ }
+
+ // Reopen from disk in a fresh handle (a second process).
+ s2, err := beads.OpenFileStore(fsys.OSFS{}, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ after, err := s2.Get(b.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after.ClaimFence != before.ClaimFence {
+ t.Fatalf("released fence did not survive reopen: %d -> %d", before.ClaimFence, after.ClaimFence)
+ }
+ if after.Status != "open" || after.Assignee != "" {
+ t.Fatalf("released bead after reopen = %+v, want open/unassigned", after)
+ }
+}
+
+// TestFileStoreLegacyFileHasZeroFence pins the fence downgrade fail-safe: a file
+// written by a fence-unaware binary carries beads but no "fences" map, and must
+// load with ClaimFence 0 (absent ≡ 0) — never a spurious re-seed. This is the
+// fence analog of TestFileStoreConditionalWriteLegacyFileNoRevisions.
+func TestFileStoreLegacyFileHasZeroFence(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "beads.json")
+ legacy := `{"seq":1,"beads":[{"id":"gc-1","title":"legacy","status":"open","issue_type":"task","created_at":"2026-01-01T00:00:00Z"}]}`
+ if err := (fsys.OSFS{}).WriteFile(path, []byte(legacy), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s, err := beads.OpenFileStore(fsys.OSFS{}, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.Get("gc-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ClaimFence != 0 {
+ t.Fatalf("legacy file with no fences map loaded ClaimFence = %d, want 0", got.ClaimFence)
+ }
+}
+
// TestFileStoreConditionalWriteCrossHandle is the load-bearing test for
// FileStore's reason to exist: two handles on one file (two processes). It kills
// mutations that delete the reloadFromDisk or the save from the conditional
diff --git a/internal/beads/local_sidecar.go b/internal/beads/local_sidecar.go
new file mode 100644
index 0000000000..173e9a63dc
--- /dev/null
+++ b/internal/beads/local_sidecar.go
@@ -0,0 +1,129 @@
+package beads
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/gastownhall/gascity/internal/fsys"
+)
+
+// localSidecar persists clone-local key-value data (see Store.SetLocalString)
+// to a JSON file on disk, keyed by bead ID then key. It backs both BdStore
+// and NativeDoltStore, the two Store implementations whose durable state
+// lives in an external process/DB rather than an in-process slice, so
+// neither can simply keep an unexported map next to its beads the way
+// MemStore does. The sidecar never participates in Dolt sync or the bd
+// subprocess path; it is read and written independently of both.
+//
+// A zero-value path means "in-memory only, never persisted" — used by
+// test-only constructors that have no workdir to anchor a file under.
+type localSidecar struct {
+ path string
+
+ mu sync.Mutex
+ data map[string]map[string]string
+ loaded bool
+}
+
+// newLocalSidecar returns a sidecar backed by the JSON file at path. The
+// file is read lazily on first use, not at construction. An empty path
+// makes the sidecar in-memory-only.
+func newLocalSidecar(path string) *localSidecar {
+ return &localSidecar{path: path}
+}
+
+func (l *localSidecar) ensureLoadedLocked() error {
+ if l.loaded {
+ return nil
+ }
+ l.loaded = true
+ if l.path == "" {
+ l.data = make(map[string]map[string]string)
+ return nil
+ }
+ raw, err := os.ReadFile(l.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ l.data = make(map[string]map[string]string)
+ return nil
+ }
+ return fmt.Errorf("loading local sidecar %q: %w", l.path, err)
+ }
+ data := make(map[string]map[string]string)
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, &data); err != nil {
+ return fmt.Errorf("parsing local sidecar %q: %w", l.path, err)
+ }
+ }
+ l.data = data
+ return nil
+}
+
+// Set stores value under (id, key), or clears it when value is "". Mirrors
+// Store.SetLocalString and, like that method, never validates that id
+// refers to an existing bead.
+func (l *localSidecar) Set(id, key, value string) error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if err := l.ensureLoadedLocked(); err != nil {
+ return err
+ }
+ if value == "" {
+ if l.data[id] == nil {
+ return nil
+ }
+ delete(l.data[id], key)
+ if len(l.data[id]) == 0 {
+ delete(l.data, id)
+ }
+ } else {
+ if l.data[id] == nil {
+ l.data[id] = make(map[string]string)
+ }
+ l.data[id][key] = value
+ }
+ return l.saveLocked()
+}
+
+// Get returns the value stored under (id, key), or "" if unset.
+func (l *localSidecar) Get(id, key string) (string, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if err := l.ensureLoadedLocked(); err != nil {
+ return "", err
+ }
+ return l.data[id][key], nil
+}
+
+// DeleteBead removes all clone-local data for id. Callers should invoke this
+// when the bead itself is deleted, so the sidecar doesn't accumulate entries
+// for beads that no longer exist.
+func (l *localSidecar) DeleteBead(id string) error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if err := l.ensureLoadedLocked(); err != nil {
+ return err
+ }
+ if _, ok := l.data[id]; !ok {
+ return nil
+ }
+ delete(l.data, id)
+ return l.saveLocked()
+}
+
+func (l *localSidecar) saveLocked() error {
+ if l.path == "" {
+ return nil
+ }
+ raw, err := json.MarshalIndent(l.data, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling local sidecar %q: %w", l.path, err)
+ }
+ if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
+ return fmt.Errorf("creating local sidecar dir for %q: %w", l.path, err)
+ }
+ return fsys.WriteFileAtomic(fsys.OSFS{}, l.path, raw, 0o644)
+}
diff --git a/internal/beads/local_sidecar_test.go b/internal/beads/local_sidecar_test.go
new file mode 100644
index 0000000000..19006bea1e
--- /dev/null
+++ b/internal/beads/local_sidecar_test.go
@@ -0,0 +1,158 @@
+package beads
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLocalSidecarSetGetRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+ s := newLocalSidecar(path)
+
+ if err := s.Set("gc-1", "last_woke_at", "2026-07-14T00:00:00Z"); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ got, err := s.Get("gc-1", "last_woke_at")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got != "2026-07-14T00:00:00Z" {
+ t.Fatalf("Get = %q, want persisted value", got)
+ }
+}
+
+func TestLocalSidecarGetUnsetReturnsEmpty(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+ s := newLocalSidecar(path)
+
+ got, err := s.Get("gc-1", "never_set")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got != "" {
+ t.Fatalf("Get unset = %q, want empty", got)
+ }
+}
+
+func TestLocalSidecarSetEmptyClears(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+ s := newLocalSidecar(path)
+
+ if err := s.Set("gc-1", "k", "v"); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ if err := s.Set("gc-1", "k", ""); err != nil {
+ t.Fatalf("Set empty: %v", err)
+ }
+ got, err := s.Get("gc-1", "k")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got != "" {
+ t.Fatalf("Get after clear = %q, want empty", got)
+ }
+}
+
+func TestLocalSidecarMultipleKeysPerBead(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+ s := newLocalSidecar(path)
+
+ if err := s.Set("gc-1", "k1", "v1"); err != nil {
+ t.Fatalf("Set k1: %v", err)
+ }
+ if err := s.Set("gc-1", "k2", "v2"); err != nil {
+ t.Fatalf("Set k2: %v", err)
+ }
+ if got, _ := s.Get("gc-1", "k1"); got != "v1" {
+ t.Fatalf("Get k1 = %q, want v1", got)
+ }
+ if got, _ := s.Get("gc-1", "k2"); got != "v2" {
+ t.Fatalf("Get k2 = %q, want v2", got)
+ }
+}
+
+func TestLocalSidecarPersistsAcrossFreshInstance(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+
+ first := newLocalSidecar(path)
+ if err := first.Set("gc-1", "k", "v"); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+
+ second := newLocalSidecar(path)
+ got, err := second.Get("gc-1", "k")
+ if err != nil {
+ t.Fatalf("Get from fresh instance: %v", err)
+ }
+ if got != "v" {
+ t.Fatalf("Get from fresh instance at same path = %q, want v", got)
+ }
+}
+
+func TestLocalSidecarEmptyPathIsMemoryOnlyNotShared(t *testing.T) {
+ first := newLocalSidecar("")
+ if err := first.Set("gc-1", "k", "v"); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ got, err := first.Get("gc-1", "k")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got != "v" {
+ t.Fatalf("Get from same instance = %q, want v", got)
+ }
+
+ second := newLocalSidecar("")
+ got2, err := second.Get("gc-1", "k")
+ if err != nil {
+ t.Fatalf("Get from second instance: %v", err)
+ }
+ if got2 != "" {
+ t.Fatalf("Get from second in-memory-only instance = %q, want empty (no persistence, no shared state)", got2)
+ }
+}
+
+func TestLocalSidecarDeleteBeadScopesToSingleBead(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+ s := newLocalSidecar(path)
+
+ if err := s.Set("gc-1", "k", "v1"); err != nil {
+ t.Fatalf("Set gc-1: %v", err)
+ }
+ if err := s.Set("gc-2", "k", "v2"); err != nil {
+ t.Fatalf("Set gc-2: %v", err)
+ }
+ if err := s.DeleteBead("gc-1"); err != nil {
+ t.Fatalf("DeleteBead: %v", err)
+ }
+
+ if got, err := s.Get("gc-1", "k"); err != nil || got != "" {
+ t.Fatalf("Get gc-1 after DeleteBead = (%q, %v), want empty, nil", got, err)
+ }
+ if got, err := s.Get("gc-2", "k"); err != nil || got != "v2" {
+ t.Fatalf("Get gc-2 after deleting gc-1 = (%q, %v), want v2, nil (untouched)", got, err)
+ }
+}
+
+func TestLocalSidecarWritesJSONFileToDisk(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "local-strings.json")
+ s := newLocalSidecar(path)
+
+ if err := s.Set("gc-1", "k", "v"); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ var data map[string]map[string]string
+ if err := json.Unmarshal(raw, &data); err != nil {
+ t.Fatalf("Unmarshal: %v", err)
+ }
+ if data["gc-1"]["k"] != "v" {
+ t.Fatalf("on-disk data = %+v, want gc-1.k=v", data)
+ }
+}
diff --git a/internal/beads/memstore.go b/internal/beads/memstore.go
index f1f724ea85..e8eee18ca5 100644
--- a/internal/beads/memstore.go
+++ b/internal/beads/memstore.go
@@ -27,6 +27,11 @@ type MemStore struct {
// against a store that reports incapable at runtime (no interface-stripping
// wrapper — see the class_store optional-capability lesson).
DisableConditionalWrites bool
+
+ // localStrings holds clone-local key-value data set via SetLocalString,
+ // keyed by bead ID then key. Deliberately excluded from
+ // restoreFrom/snapshot so FileStore's disk persistence never touches it.
+ localStrings map[string]map[string]string
}
var _ ConditionalAssignmentReleaser = (*MemStore)(nil)
@@ -93,9 +98,10 @@ func (m *MemStore) Create(b Bead) (Bead, error) {
if b.Type == "" {
b.Type = "task"
}
- b.CreatedAt = time.Now()
+ b.CreatedAt = time.Now().Round(0)
b.UpdatedAt = b.CreatedAt
- b.Revision = 1 // first version; every subsequent mutation bumps it
+ b.Revision = 1 // first version; every subsequent mutation bumps it
+ b.ClaimFence = 0 // no ownership history yet; the first claim bumps it to 1
stored := cloneBead(b)
m.beads = append(m.beads, stored)
@@ -129,10 +135,36 @@ func (m *MemStore) indexOfLocked(id string) int {
return -1
}
+// isOwnershipTransition reports whether an update changes a bead's ownership
+// context — an assignee change, or a reopen (closed→open, after which a fresh
+// claim starts a new ownership generation). It mirrors beads'
+// issueops.IsOwnershipTransition so the ClaimFence bump discipline matches the
+// bd-backed store. Deliberate exclusions: a close is not a transition (guarded
+// verbs reject closed rows anyway, and bumping on close would invalidate a
+// legitimate ownership snapshot for no gain); an in_progress→open change that
+// keeps the assignee is not one either — the row stays claimable only by the
+// same owner, and the eventual release bumps at the real boundary.
+func isOwnershipTransition(oldStatus, oldAssignee string, opts UpdateOpts) bool {
+ if opts.Assignee != nil && *opts.Assignee != oldAssignee {
+ return true
+ }
+ // A reopen is closed→a real non-closed status. An empty status string is not
+ // a status write beads recognizes (its IsOwnershipTransition short-circuits
+ // on statusStr == ""), so exclude it here too to keep the predicates literally
+ // aligned.
+ if opts.Status != nil && *opts.Status != "" && oldStatus == "closed" && *opts.Status != "closed" {
+ return true
+ }
+ return false
+}
+
// applyUpdateLocked applies the non-nil fields of opts to the bead at index i,
-// stamps UpdatedAt, and bumps the revision. The caller must hold m.mu. It is
-// shared by Update and UpdateIfMatch so both bump identically.
+// stamps UpdatedAt, bumps the revision, and — when the update is an ownership
+// transition (assignee change or reopen) — bumps the ownership fence. The
+// caller must hold m.mu. It is shared by Update and UpdateIfMatch so both bump
+// identically.
func (m *MemStore) applyUpdateLocked(i int, opts UpdateOpts) {
+ oldStatus, oldAssignee := m.beads[i].Status, m.beads[i].Assignee
if opts.Title != nil {
m.beads[i].Title = *opts.Title
}
@@ -180,6 +212,9 @@ func (m *MemStore) applyUpdateLocked(i int, opts UpdateOpts) {
}
m.beads[i].UpdatedAt = time.Now()
m.beads[i].Revision++
+ if isOwnershipTransition(oldStatus, oldAssignee, opts) {
+ m.beads[i].ClaimFence++
+ }
}
// Update modifies fields of an existing bead. Only non-nil fields in opts
@@ -211,6 +246,7 @@ func (m *MemStore) ReleaseIfCurrent(id, expectedAssignee string) (bool, error) {
m.beads[i].Assignee = ""
m.beads[i].UpdatedAt = time.Now()
m.beads[i].Revision++
+ m.beads[i].ClaimFence++ // clearing an owner is an ownership transition
return true, nil
}
return false, nil
@@ -245,9 +281,16 @@ func (m *MemStore) Reopen(id string) error {
if m.beads[i].Status == "open" {
return nil
}
+ wasClosed := m.beads[i].Status == "closed"
m.beads[i].Status = "open"
m.beads[i].UpdatedAt = time.Now()
m.beads[i].Revision++
+ if wasClosed {
+ // closed→open starts a new ownership generation; an
+ // in_progress→open reopen keeps the same owner and is not a
+ // transition.
+ m.beads[i].ClaimFence++
+ }
return nil
}
}
@@ -506,6 +549,49 @@ func (m *MemStore) SetMetadataBatch(id string, kvs map[string]string) error {
return fmt.Errorf("setting metadata batch on %q: %w", id, ErrNotFound)
}
+// beadExistsLocked reports whether id is present. Caller must hold m.mu.
+func (m *MemStore) beadExistsLocked(id string) bool {
+ for _, b := range m.beads {
+ if b.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
+// SetLocalString sets a clone-local string value for a bead. See
+// Store.SetLocalString. Never touches Bead.Metadata or UpdatedAt.
+func (m *MemStore) SetLocalString(id, key, value string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if !m.beadExistsLocked(id) {
+ return fmt.Errorf("setting local string on %q: %w", id, ErrNotFound)
+ }
+ if value == "" {
+ delete(m.localStrings[id], key)
+ return nil
+ }
+ if m.localStrings == nil {
+ m.localStrings = make(map[string]map[string]string)
+ }
+ if m.localStrings[id] == nil {
+ m.localStrings[id] = make(map[string]string)
+ }
+ m.localStrings[id][key] = value
+ return nil
+}
+
+// GetLocalString returns the clone-local string value for a bead. See
+// Store.GetLocalString.
+func (m *MemStore) GetLocalString(id, key string) (string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if !m.beadExistsLocked(id) {
+ return "", fmt.Errorf("getting local string on %q: %w", id, ErrNotFound)
+ }
+ return m.localStrings[id][key], nil
+}
+
// Tx executes fn sequentially against the MemStore.
func (m *MemStore) Tx(_ string, fn func(Tx) error) error {
return runSequentialTx(m, fn)
@@ -518,6 +604,7 @@ func (m *MemStore) Delete(id string) error {
for i, b := range m.beads {
if b.ID == id {
m.beads = append(m.beads[:i], m.beads[i+1:]...)
+ delete(m.localStrings, id)
return nil
}
}
diff --git a/internal/beads/memstore_test.go b/internal/beads/memstore_test.go
index a43d6e161e..c0bd572265 100644
--- a/internal/beads/memstore_test.go
+++ b/internal/beads/memstore_test.go
@@ -17,6 +17,18 @@ func TestMemStore(t *testing.T) {
beadstest.RunCreationOrderTests(t, factory)
beadstest.RunDepTests(t, factory)
beadstest.RunMetadataTests(t, factory)
+ beadstest.RunFenceConformance(t, factory)
+}
+
+func TestMemStoreCreateUsesSerializableTimestamp(t *testing.T) {
+ store := beads.NewMemStore()
+ created, err := store.Create(beads.Bead{Title: "serializable timestamp"})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if created.CreatedAt != created.CreatedAt.Round(0) {
+ t.Fatalf("CreatedAt retained a process-local monotonic clock: %v", created.CreatedAt)
+ }
}
func TestMemStoreConditionalWriterConformance(t *testing.T) {
@@ -79,6 +91,9 @@ func TestMemStoreReleaseIfCurrent(t *testing.T) {
if got.Status != "in_progress" || got.Assignee != "worker-1" {
t.Fatalf("wrong-assignee release mutated bead: %+v", got)
}
+ if got.ClaimFence != 0 {
+ t.Errorf("no-op release bumped ClaimFence to %d, want 0", got.ClaimFence)
+ }
released, err = s.ReleaseIfCurrent(b.ID, "worker-1")
if err != nil {
@@ -94,6 +109,9 @@ func TestMemStoreReleaseIfCurrent(t *testing.T) {
if got.Status != "open" || got.Assignee != "" {
t.Fatalf("released bead = %+v, want open and unassigned", got)
}
+ if got.ClaimFence != 1 {
+ t.Errorf("ReleaseIfCurrent did not bump ClaimFence: got %d, want 1 (release is an ownership transition)", got.ClaimFence)
+ }
}
func TestMemStoreReleaseIfCurrentSkipsMissingAndWrongStatus(t *testing.T) {
diff --git a/internal/beads/metadata_cas.go b/internal/beads/metadata_cas.go
new file mode 100644
index 0000000000..a567148014
--- /dev/null
+++ b/internal/beads/metadata_cas.go
@@ -0,0 +1,97 @@
+// The narrow metadata value-CAS capability seam.
+//
+// ConditionalWriter (beads.go) bundles four methods: the revision-CAS trio
+// (UpdateIfMatch/CloseIfMatch/DeleteIfMatch) plus CompareAndSetMetadataKey.
+// The trio needs a backend fence token — a revision that advances on every
+// mutation and is never reused. The beads v1.1.0 schema cannot supply one:
+// types.Issue carries no revision field (Get().Revision is 0 and never
+// advances), the issues DDL has no version column, and updated_at is
+// second-granularity — so two same-second writes yield an EQUAL token and a
+// stale fence SILENTLY SUCCEEDS, which is the lost update a fence exists to
+// prevent. Label mutations never touch updated_at at all. A store-maintained
+// counter is not a fence either: the Dolt database is multi-writer (the bd
+// CLI, other gascity processes, graph-apply), and a counter only the fencer
+// maintains fences nothing.
+//
+// CompareAndSetMetadataKey needs no such token — it guards on the key's own
+// current value — so it is soundly implementable today on stores where the
+// trio is not. MetadataCASWriter is that half, split out so a store can
+// declare the capability it actually has. Declaring the whole of
+// ConditionalWriter just to expose the CAS method would make
+// ResolveConditionalWriter RESOLVE under require mode and hand the trio's
+// callers a silently-wrong fence — converting today's loud typed refusal into
+// exactly the silent legacy write under require that the seam exists to make
+// inexpressible. See the condWritesStamp comment in native_dolt_store.go.
+//
+// Upstream beads #4697 (claim_fence) is the missing backend primitive. When it
+// lands, a store can implement the trio soundly and declare ConditionalWriter;
+// until then a narrow-only store declares MetadataCASWriter and nothing more.
+//
+// Resolution is deliberately SEPARATE from ResolveConditionalWriter: this is a
+// capability lookup, not the operator-policy seam. It carries no
+// conditional_writes mode, because its consumers (target_scope member
+// declaration, the D3/D5 lease/claim_generation lane) have no legacy
+// unconditional path to fall back to — the CAS is their only correct
+// implementation, so gating it on a rollout flag would leave them with nothing
+// under the default off. Nothing here can raise enforcement on the trio: a
+// narrow writer never satisfies ConditionalWriter, so the trio's callers stay
+// exactly as refused as they are today.
+
+package beads
+
+// MetadataCASWriter is the metadata value-CAS half of ConditionalWriter,
+// declared on its own so stores that cannot soundly fence on a revision can
+// still offer a sound single-key compare-and-set.
+//
+// The contract is identical to ConditionalWriter.CompareAndSetMetadataKey and
+// is verified by the same assertions (beadstest.RunMetadataCASConformance):
+// expected == "" matches a key that is absent OR present with the empty value
+// (the two states are indistinguishable to callers; release paths write "" to
+// clear). Returns (true, nil) on swap, (false, nil) on a genuine value
+// mismatch — a lost race is NOT an error — and (false, err) for everything
+// else. A store whose conditional writes are disabled at the instance level
+// reports ErrConditionalWriteUnsupported from the call, matching how the trio
+// behaves on those stores.
+//
+// Every ConditionalWriter satisfies MetadataCASWriter structurally, so a fully
+// capable store serves narrow callers unchanged. The converse cannot hold: Go
+// interface satisfaction needs all four methods, which is precisely the
+// property that keeps a narrow store out of ConditionalWriter resolution.
+type MetadataCASWriter interface {
+ CompareAndSetMetadataKey(id, key, expected, next string) (bool, error)
+}
+
+// MetadataCASWriterHandleProvider exposes a metadata-CAS handle for stores
+// whose capability depends on wrapped runtime state. It mirrors
+// ConditionalWriterHandleProvider: a wrapper can delegate the capability
+// without claiming the interface globally.
+type MetadataCASWriterHandleProvider interface {
+ MetadataCASWriterHandle() (MetadataCASWriter, bool)
+}
+
+// MetadataCASWriterFor returns the metadata value-CAS capability for store
+// when one is available.
+//
+// It follows wrapper-declared resolution targets (ConditionalWritesResolveTargeter)
+// for the same reason ResolveConditionalWriter does: interface-embedding
+// wrappers — the cmd/gc policy store, the typed class wrappers in
+// class_store.go — do not promote optional capabilities, so a direct assertion
+// through them fails and the capability would look absent. As with
+// ConditionalWriterFor, this does NOT guess at unwrapping: a wrapper
+// participates only by declaring its target.
+//
+// This is a pure capability lookup and applies no operator policy — see the
+// file comment for why the narrow CAS is not mode-gated.
+func MetadataCASWriterFor(store Store) (MetadataCASWriter, bool) {
+ if store == nil {
+ return nil, false
+ }
+ store = followConditionalWritesResolveTarget(store)
+ if writer, ok := store.(MetadataCASWriter); ok {
+ return writer, true
+ }
+ if provider, ok := store.(MetadataCASWriterHandleProvider); ok {
+ return provider.MetadataCASWriterHandle()
+ }
+ return nil, false
+}
diff --git a/internal/beads/native_dolt_store.go b/internal/beads/native_dolt_store.go
index d0600f7afb..5cc5e82620 100644
--- a/internal/beads/native_dolt_store.go
+++ b/internal/beads/native_dolt_store.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ "github.com/go-sql-driver/mysql"
beadslib "github.com/steveyegge/beads"
)
@@ -31,43 +32,105 @@ type rawDBGetter interface {
var idDefaultRepairTables = []string{"dependencies", "events", "wisp_events"}
// repairIDDefault ensures table.id has DEFAULT (uuid()). It is idempotent and
-// tolerant of an absent table (e.g. wisp_events): it checks INFORMATION_SCHEMA
-// and only issues the ALTER when the id column exists without a default.
+// tolerant of an absent table (e.g. wisp_events): it only issues the ALTER when
+// the id column exists without a default.
//
-//nolint:gosec // G201: table is drawn from idDefaultRepairTables, hardcoded constants.
+// The probe is a single-table SHOW COLUMNS, not INFORMATION_SCHEMA.COLUMNS:
+// Dolt does not push the WHERE predicate into INFORMATION_SCHEMA, so the old
+// probe was a full catalog scan — cheap once, but it runs per store open per
+// repair table, and a fleet of concurrent gc/bd sessions firing it several
+// times a second pegged the shared Dolt server's CPU. SHOW COLUMNS returns the
+// Default cell directly, so one cheap statement replaces the scan.
func repairIDDefault(db *sql.DB, table string) error {
- var idCols, withDefault int
- err := db.QueryRow(`
- SELECT COUNT(*), COUNT(COLUMN_DEFAULT)
- FROM INFORMATION_SCHEMA.COLUMNS
- WHERE TABLE_SCHEMA = DATABASE()
- AND TABLE_NAME = ?
- AND COLUMN_NAME = 'id'
- `, table).Scan(&idCols, &withDefault)
+ // 'id' contains no LIKE wildcards, but Field is still compared exactly
+ // (matching upstream beads' SHOW COLUMNS probes) rather than trusting LIKE.
+ //nolint:gosec // G201: table is drawn from idDefaultRepairTables, hardcoded constants.
+ rows, err := db.Query(fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE 'id'", table))
if err != nil {
+ if isTableNotExistError(err) {
+ return nil
+ }
return fmt.Errorf("checking %s.id default: %w", table, err)
}
- if idCols == 0 || withDefault > 0 {
- // Table/column absent, or the default is already present.
+ defer func() { _ = rows.Close() }()
+
+ cols, err := rows.Columns()
+ if err != nil {
+ return fmt.Errorf("checking %s.id default: %w", table, err)
+ }
+ defaultIdx := -1
+ for i, col := range cols {
+ if strings.EqualFold(col, "Default") {
+ defaultIdx = i
+ break
+ }
+ }
+ if defaultIdx < 0 {
+ return fmt.Errorf("checking %s.id default: SHOW COLUMNS returned no Default column (got %v)", table, cols)
+ }
+
+ idFound, withDefault := false, false
+ cells := make([]sql.RawBytes, len(cols))
+ dest := make([]any, len(cols))
+ for i := range cells {
+ dest[i] = &cells[i]
+ }
+ for rows.Next() {
+ if err := rows.Scan(dest...); err != nil {
+ return fmt.Errorf("checking %s.id default: %w", table, err)
+ }
+ if len(cells) > 0 && string(cells[0]) == "id" {
+ idFound = true
+ withDefault = cells[defaultIdx] != nil
+ break
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("checking %s.id default: %w", table, err)
+ }
+ if !idFound || withDefault {
+ // Column absent, or the default is already present.
return nil
}
- _, err = db.Exec(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `id` char(36) NOT NULL DEFAULT (uuid())", table))
- if err != nil {
+ //nolint:gosec // G201: table is drawn from idDefaultRepairTables, hardcoded constants.
+ if _, err := db.Exec(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `id` char(36) NOT NULL DEFAULT (uuid())", table)); err != nil {
return fmt.Errorf("repairing %s.id default: %w", table, err)
}
return nil
}
+// isTableNotExistError reports whether err is the MySQL/Dolt "table doesn't
+// exist" error (1146). SHOW COLUMNS errors on a missing table where the old
+// INFORMATION_SCHEMA probe returned zero rows; an absent repair table (e.g.
+// wisp_events on an older schema) is not an error.
+func isTableNotExistError(err error) bool {
+ var mysqlErr *mysql.MySQLError
+ if errors.As(err, &mysqlErr) {
+ return mysqlErr.Number == 1146
+ }
+ // The embedded Dolt driver surfaces the same condition without the
+ // go-sql-driver error type; match Dolt's message shape.
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "table not found") || strings.Contains(msg, "doesn't exist")
+}
+
const nativeDoltStoreActor = "gascity"
+// nativeDoltOpenReadyStatuses lists the upstream bd statuses Ready() queries
+// GetReadyWork for. This must match IsReadyCandidateForTier's contract of
+// "open status ... and no future defer_until": only StatusOpen (bd's own
+// status-category table marks it the sole "active" category status) and
+// StatusDeferred (kept only because IsDeferred independently re-checks
+// DeferUntil, so an expired deferral must still resurface) belong here.
+// blocked/hooked are bd's "wip" category and pinned is "frozen" — bd's own
+// ready semantics already exclude them, and Gas City has no analogous
+// re-check for them the way it does for deferred, so querying for them let
+// dependency-blocked beads erase their status to "open" via mapBdStatus and
+// pass IsReadyCandidateForTier's status gate. See ga-3mv5d3 bead notes for
+// the full investigation.
var nativeDoltOpenReadyStatuses = []beadslib.Status{
beadslib.StatusOpen,
- beadslib.StatusBlocked,
beadslib.StatusDeferred,
- beadslib.Status("pinned"),
- beadslib.Status("hooked"),
- beadslib.Status("review"),
- beadslib.Status("testing"),
}
var (
@@ -99,6 +162,25 @@ func nativeDoltOperationContext(parent context.Context) (context.Context, contex
return context.WithTimeout(parent, bdCommandTimeout)
}
+// nativeGraphApplyDeadline scales the graph-apply transaction budget with plan
+// size. The library's AddDependency runs a recursive cycle-reachability query
+// per blocking edge, so a large molecule (67 nodes / ~100 edges on the
+// mol-adopt-pr-v2 shape) cannot finish inside the flat per-command budget: the
+// batch died at the 120s deadline mid-edges, retried into the same wall, and
+// fell back to per-bead creates — turning a single atomic pour into ~9 minutes
+// of partial work (2026-07-17 code red). Until the per-edge check is replaced
+// by one whole-graph CycleThroughEdges pass (needs a beads-side export of
+// DependencyAddOptions), give each node and edge a slice of budget on top of
+// the flat floor so the atomic path completes instead of falling back.
+func nativeGraphApplyDeadline(plan *GraphApplyPlan) time.Duration {
+ d := bdCommandTimeout
+ if plan == nil {
+ return d
+ }
+ const perItem = 2 * time.Second
+ return d + time.Duration(len(plan.Nodes)+len(plan.Edges))*perItem
+}
+
func nativeDoltCleanupContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), bdCommandTimeout)
}
@@ -208,10 +290,33 @@ type NativeDoltStore struct {
readRetryBudgetOverride time.Duration
// condWritesStamp carries the factory-stamped conditional-writes mode.
- // NativeDoltStore implements no ConditionalWriter yet, so the stamp's
- // effect today is require→typed refusal / auto→loud degrade at the
- // seam, never a silent legacy write under require.
+ // NativeDoltStore implements the NARROW metadata value-CAS
+ // (MetadataCASWriter, see native_dolt_store_conditional.go) but NOT
+ // ConditionalWriter, so the stamp's effect at the seam is unchanged:
+ // require→typed refusal / auto→loud degrade, never a silent legacy write
+ // under require.
+ //
+ // The gap is the revision-CAS trio, and it is a BACKEND gap, not an
+ // unwritten method. UpdateIfMatch/CloseIfMatch/DeleteIfMatch need a fence
+ // token that advances on every mutation and is never reused; beads v1.1.0
+ // has none. types.Issue carries no revision, the issues DDL has no version
+ // column, updated_at is second-granularity — so two same-second writes
+ // compare EQUAL and a stale fence silently succeeds, which is the lost
+ // update the fence exists to prevent — and label mutations never touch
+ // updated_at at all. A counter this store maintained itself would fence
+ // nothing, because the Dolt database is multi-writer (bd CLI, other
+ // gascity processes, graph-apply). Upstream beads #4697 (claim_fence) is
+ // the missing primitive; when it lands the trio becomes implementable and
+ // this store can declare ConditionalWriter.
+ //
+ // Declaring ConditionalWriter early to expose the CAS method would make
+ // ResolveConditionalWriter resolve under require and hand the trio's
+ // callers a wrong fence — the precise silent-write failure this refusal
+ // currently makes impossible. internal/beads/metadata_cas.go carries the
+ // full reasoning and the narrow interface's own resolution path.
condWritesStamp
+
+ localStrings *localSidecar // clone-local data; see Store.SetLocalString
}
// NativeStorage is the upstream beads storage handle a NativeDoltStore wraps.
@@ -248,7 +353,7 @@ func newNativeDoltStoreWithStorage(storage beadslib.Storage, actor string) *Nati
if actor == "" {
actor = nativeDoltStoreActor
}
- return &NativeDoltStore{storage: storage, actor: actor}
+ return &NativeDoltStore{storage: storage, actor: actor, localStrings: newLocalSidecar("")}
}
func newNativeDoltStoreWithStorageAndPrefix(storage beadslib.Storage, actor, idPrefix string) *NativeDoltStore {
@@ -279,6 +384,7 @@ func newNativeDoltStoreAt(parent context.Context, scopeRoot string, env map[stri
if projectID, projectErr := storage.GetConfig(ctx, "project_id"); projectErr == nil {
store.projectID = strings.TrimSpace(projectID)
}
+ store.localStrings = newLocalSidecar(filepath.Join(scopeRoot, ".beads", "local-strings.json"))
for _, opt := range opts {
opt(store)
}
@@ -678,7 +784,10 @@ func (s *NativeDoltStore) ApplyGraphPlanWithStorage(parent context.Context, plan
}
defer release()
- ctx, cancel := nativeDoltOperationContext(parent)
+ if parent == nil {
+ parent = context.Background()
+ }
+ ctx, cancel := context.WithTimeout(parent, nativeGraphApplyDeadline(plan))
defer cancel()
keyToID := make(map[string]string, len(plan.Nodes))
@@ -1190,6 +1299,17 @@ func (s *NativeDoltStore) Ready(queries ...ReadyQuery) ([]Bead, error) {
return err
}
for _, issue := range issues {
+ // The StatusDeferred branch exists so an expired time-bound
+ // deferral (defer_until in the past) can resurface. An issue
+ // with no defer_until at all was never time-bound — it's bd
+ // defer's status-based indefinite deferral — and must stay
+ // hidden. mapBdStatus collapses status to "open" and
+ // IsDeferred only inspects DeferUntil, so both would
+ // otherwise look identical to an ordinary open bead once
+ // beadFromNativeIssue erases the raw status.
+ if status == beadslib.StatusDeferred && issue.DeferUntil == nil {
+ continue
+ }
bead, err := beadFromNativeIssue(issue)
if err != nil {
return err
@@ -1313,6 +1433,11 @@ func (s *NativeDoltStore) SetMetadata(id, key, value string) error {
return s.SetMetadataBatch(id, map[string]string{key: value})
}
+const (
+ nativeMetadataWriteAttempts = 3
+ nativeMetadataWriteRetryBackoff = 25 * time.Millisecond
+)
+
// SetMetadataBatch sets multiple metadata keys on a bead.
func (s *NativeDoltStore) SetMetadataBatch(id string, kvs map[string]string) error {
storage, release, err := s.acquireStorage()
@@ -1320,8 +1445,23 @@ func (s *NativeDoltStore) SetMetadataBatch(id string, kvs map[string]string) err
return err
}
defer release()
- ctx, cancel := nativeDoltOperationContext(context.TODO())
- defer cancel()
+
+ for attempt := 1; attempt <= nativeMetadataWriteAttempts; attempt++ {
+ ctx, cancel := nativeDoltOperationContext(context.TODO())
+ err = s.setMetadataBatchOnce(ctx, storage, id, kvs)
+ cancel()
+ if err == nil || !isNativeDoltSerializationConflict(err) || attempt == nativeMetadataWriteAttempts {
+ return err
+ }
+ time.Sleep(time.Duration(attempt) * nativeMetadataWriteRetryBackoff)
+ }
+ return err
+}
+
+// setMetadataBatchOnce performs one complete metadata read-merge-write attempt.
+// A retry must call this whole operation again so metadata committed by the
+// competing transaction is included rather than overwritten from a stale read.
+func (s *NativeDoltStore) setMetadataBatchOnce(ctx context.Context, storage beadslib.Storage, id string, kvs map[string]string) error {
issue, err := storage.GetIssue(ctx, id)
if err != nil {
return nativeStoreError(id, err)
@@ -1346,6 +1486,42 @@ func (s *NativeDoltStore) SetMetadataBatch(id string, kvs map[string]string) err
return nativeStoreError(id, storage.UpdateIssue(ctx, id, map[string]interface{}{"metadata": raw}, s.actor))
}
+// isNativeDoltSerializationConflict reports only Dolt/MySQL transaction
+// serialization conflicts, which are known not to have committed and are safe
+// to retry. Ambiguous connection failures intentionally remain fail-fast.
+func isNativeDoltSerializationConflict(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "error 1213") ||
+ (strings.Contains(msg, "sqlstate") && strings.Contains(msg, "40001")) ||
+ strings.Contains(msg, "(40001)") ||
+ strings.Contains(msg, "this transaction conflicts with a committed transaction")
+}
+
+// SetLocalString sets a clone-local string value for a bead. See
+// Store.SetLocalString. Persisted to a sidecar JSON file under this store's
+// .beads/ directory rather than through Dolt storage: unlike SetMetadata,
+// this never touches the Dolt DB or commits. Does not validate that id
+// refers to an existing bead — see the interface doc comment for why.
+func (s *NativeDoltStore) SetLocalString(id, key, value string) error {
+ if err := s.localStrings.Set(id, key, value); err != nil {
+ return fmt.Errorf("setting local string on %q: %w", id, err)
+ }
+ return nil
+}
+
+// GetLocalString returns the clone-local string value for a bead. See
+// Store.GetLocalString.
+func (s *NativeDoltStore) GetLocalString(id, key string) (string, error) {
+ value, err := s.localStrings.Get(id, key)
+ if err != nil {
+ return "", fmt.Errorf("getting local string on %q: %w", id, err)
+ }
+ return value, nil
+}
+
// Tx executes fn inside a single native Dolt transaction so every write in the
// callback shares one DOLT_COMMIT. This is the coalescing path that lets a
// caller (e.g. an extmsg bind) issue several bead writes at the cost of one
@@ -1410,7 +1586,13 @@ func (s *NativeDoltStore) Delete(id string) error {
defer release()
ctx, cancel := nativeDoltOperationContext(context.TODO())
defer cancel()
- return nativeStoreError(id, storage.DeleteIssue(ctx, id))
+ if err := nativeStoreError(id, storage.DeleteIssue(ctx, id)); err != nil {
+ return err
+ }
+ if sidecarErr := s.localStrings.DeleteBead(id); sidecarErr != nil {
+ return fmt.Errorf("deleting bead %q: cleaning up local strings: %w", id, sidecarErr)
+ }
+ return nil
}
// Ping verifies that the upstream storage is reachable.
@@ -1921,13 +2103,77 @@ func nativePriorityFromIssue(issue *beadslib.Issue) *int {
return &priority
}
+// nativeCreatedLimitPushdown reports the row limit to forward to the backing
+// search for a ListQuery, or 0 to fetch the full candidate set and let
+// ApplyListQuery cut the exact page client-side. Created-order sorts push down
+// to the backing search (IssueFilter.SortBy drives sqlbuild.OrderBy) so the
+// caller's limit survives and the store pages instead of materializing +
+// hydrating the whole corpus (sr-dp9o: the dispatcher's RecentRunsAll(2048) was
+// scanning ~22k closed order-tracking wisps per call with the limit stripped).
+// A backing limit is exact only when the backing's ordering and tie-break match
+// the query's client-side semantics; the guards below keep every shape whose
+// exact result needs client-side work from truncating the page early.
+func nativeCreatedLimitPushdown(query ListQuery) int {
+ if query.Limit <= 0 {
+ return 0
+ }
+ // The wisp tier still needs the gc-side post-filter over the full candidate
+ // set (it can discard rows), so a backing limit would cut the page short.
+ if query.TierMode == TierWisps {
+ return 0
+ }
+ // SeekAfter, UpdatedBefore, and plural Assignees are enforced only Go-side in
+ // ApplyListQuery (q.Matches); they are not pushed to the backing search, so a
+ // backing limit applied before them would cut rows before the residual filter
+ // runs and silently drop page rows. Fetch the full candidate set for those
+ // shapes, mirroring the sibling gates (doltliteCanSelectBoundedTopN,
+ // exec.go, bdstore canApplyWispsServerLimit).
+ if query.SeekAfter != nil || !query.UpdatedBefore.IsZero() || len(query.Assignees) > 0 {
+ return 0
+ }
+ switch query.Sort {
+ case SortCreatedAsc:
+ // The backing renders created-asc ties as `id ASC`, matching the
+ // canonical (created_at ASC, id ASC) order, so a bounded asc read is exact.
+ return query.Limit
+ case SortCreatedDesc:
+ // The backing renders created-desc ties as `id ASC` (upstream
+ // sqlbuild.OrderBy hardcodes the id tie-break), but Gas City's canonical
+ // order and cursor continuation break created_at ties by `id DESC`
+ // (sortBeadsForQuery / SeekBoundary.After). A bounded desc read therefore
+ // keeps the smaller-id tie members at the boundary and drops the larger-id
+ // ties, so an exact or cursor-paginated caller loses rows across the page
+ // seam. Only push the limit when the caller opted into a bounded
+ // newest-by-created_at sample (aggregates); otherwise fetch the full set
+ // and let ApplyListQuery cut the exact (created_at DESC, id DESC) prefix.
+ if query.AllowBackingCreatedLimit {
+ return query.Limit
+ }
+ return 0
+ case SortDefault:
+ // The default backing order (priority, created_at DESC, id ASC) is
+ // deterministic, so a bounded default read cuts a stable prefix.
+ return query.Limit
+ default:
+ // Non-mappable sorts can't page server-side; fetch unbounded and sort
+ // client-side in ApplyListQuery.
+ return 0
+ }
+}
+
func nativeIssueFilterFromListQuery(query ListQuery) beadslib.IssueFilter {
- limit := query.Limit
- if query.Sort != SortDefault || query.TierMode == TierWisps {
- limit = 0
+ var sortBy string
+ var sortDesc bool
+ switch query.Sort {
+ case SortCreatedDesc:
+ sortBy, sortDesc = "created", false // SortDefs["created"] defaults DESC
+ case SortCreatedAsc:
+ sortBy, sortDesc = "created", true // flip the DESC default
}
filter := beadslib.IssueFilter{
- Limit: limit,
+ Limit: nativeCreatedLimitPushdown(query),
+ SortBy: sortBy,
+ SortDesc: sortDesc,
MetadataFields: query.Metadata,
CreatedBefore: zeroTimePtr(query.CreatedBefore),
IncludeDependencies: true,
diff --git a/internal/beads/native_dolt_store_conditional.go b/internal/beads/native_dolt_store_conditional.go
new file mode 100644
index 0000000000..81d1dd8160
--- /dev/null
+++ b/internal/beads/native_dolt_store_conditional.go
@@ -0,0 +1,84 @@
+package beads
+
+import (
+ "context"
+ "fmt"
+
+ beadslib "github.com/steveyegge/beads"
+)
+
+// NativeDoltStore offers the narrow metadata value-CAS and deliberately NOT
+// the full ConditionalWriter. The revision-CAS trio needs a backend fence
+// token that beads v1.1.0 cannot supply, and declaring the interface to get
+// this one method would make ResolveConditionalWriter resolve under require
+// mode — converting a loud typed refusal into a silent wrong-fenced write.
+// internal/beads/metadata_cas.go carries the full reasoning.
+var _ MetadataCASWriter = (*NativeDoltStore)(nil)
+
+// CompareAndSetMetadataKey atomically sets metadata[key] = next when the key's
+// current value equals expected.
+//
+// expected == "" matches a key that is ABSENT or present with the empty value:
+// parsing an absent key out of the stored metadata map yields "", so the two
+// states are indistinguishable here exactly as they are to callers (release
+// paths write "" to clear). Returns (true, nil) on swap, (false, nil) on a
+// genuine value mismatch — a lost race is NOT an error — and (false, err) for
+// a missing bead, a malformed metadata blob, or a transport failure.
+//
+// Atomicity is the read-check-write inside one native Dolt transaction, the
+// same shape ReleaseIfCurrent uses for its assignee guard. The whole
+// read-compare-write runs inside the callback, so the compare and the write
+// commit together or not at all: the upstream storage layer exposes no
+// conditional-UPDATE ... WHERE primitive and no raw-SQL escape hatch, making
+// the transaction the only composition point available.
+//
+// Sibling keys are preserved: the metadata column is a single blob, so the
+// write re-serializes the map read inside this transaction rather than
+// patching one field.
+func (s *NativeDoltStore) CompareAndSetMetadataKey(id, key, expected, next string) (bool, error) {
+ storage, release, err := s.acquireStorage()
+ if err != nil {
+ return false, err
+ }
+ defer release()
+ ctx, cancel := nativeDoltOperationContext(context.TODO())
+ defer cancel()
+
+ swapped := false
+ commitMsg := fmt.Sprintf("gc: compare-and-set metadata %s on bead %s", key, id)
+ err = storage.RunInTransaction(ctx, commitMsg, func(tx beadslib.Transaction) error {
+ issue, err := tx.GetIssue(ctx, id)
+ if err != nil {
+ return nativeStoreError(id, err)
+ }
+ if issue == nil {
+ return fmt.Errorf("compare-and-set metadata on %q: %w", id, ErrNotFound)
+ }
+ metadata, err := metadataMapFromNative(issue.Metadata)
+ if err != nil {
+ return fmt.Errorf("parsing metadata for bead %q: %w", id, err)
+ }
+ if metadata[key] != expected {
+ // A genuine lost race. Returning nil commits an empty transaction
+ // and leaves swapped false, which the caller reads as (false, nil).
+ return nil
+ }
+ if metadata == nil {
+ metadata = make(map[string]string, 1)
+ }
+ metadata[key] = next
+ raw, err := metadataRawFromMap(metadata)
+ if err != nil {
+ return err
+ }
+ if err := tx.UpdateIssue(ctx, id, map[string]interface{}{"metadata": raw}, s.actor); err != nil {
+ return nativeStoreError(id, err)
+ }
+ swapped = true
+ return nil
+ })
+ if err != nil {
+ return false, err
+ }
+ return swapped, nil
+}
diff --git a/internal/beads/native_dolt_store_count.go b/internal/beads/native_dolt_store_count.go
new file mode 100644
index 0000000000..bf4dd34c87
--- /dev/null
+++ b/internal/beads/native_dolt_store_count.go
@@ -0,0 +1,109 @@
+package beads
+
+import (
+ "context"
+ "fmt"
+
+ beadslib "github.com/steveyegge/beads"
+)
+
+// Count implements the optional Counter capability with the pinned beads
+// library's CountIssues: a hydration-free SELECT COUNT(*) over the durable
+// issues table merged with the wisps tier, mirroring SearchIssues' merge
+// semantics (upstream GH#4387 — the wisps undercount that previously kept
+// this store Counter-less is fixed there). This answers the closed-inclusive
+// shapes the CachingStore can never serve from its open-bead cache — most
+// importantly the store-health denominator, whose hydrating List fallback
+// cannot finish inside the status read timeout on a long-lived city (#1896
+// follow-up).
+//
+// Count answers only shapes whose ListQuery→IssueFilter translation is
+// exact, so the backend count equals List's post-ApplyListQuery cardinality;
+// everything else reports ErrCountUnsupported and callers fall back to the
+// hydrating List, exactly as the Counter contract specifies. See
+// nativeDoltCountSupported for the shape-by-shape rationale. Known parity
+// gap: rows whose metadata JSON fails to parse are dropped by List but
+// counted here — that state is store corruption, and counting such a row
+// beats reporting no count at all.
+func (s *NativeDoltStore) Count(ctx context.Context, query ListQuery, excludeTypes ...string) (int, error) {
+ if err := query.Validate(); err != nil {
+ return 0, err
+ }
+ if !query.HasFilter() && !query.AllowScan {
+ return 0, fmt.Errorf("counting beads: %w", ErrQueryRequiresScan)
+ }
+ if !nativeDoltCountSupported(query, excludeTypes) {
+ return 0, fmt.Errorf("counting beads: %w", ErrCountUnsupported)
+ }
+ var n int
+ err := s.withReadRetry(func(readCtx context.Context, storage beadslib.Storage) error {
+ // The Counter contract promises the caller's ctx cancels the backing
+ // query, but withReadRetry runs fn under its own retry-budget context.
+ // Derive a context canceled by either, and surface the caller's
+ // cancellation as the context error itself so the retry loop treats
+ // it as terminal instead of reconnect-worthy.
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ countCtx, cancel := context.WithCancel(readCtx)
+ defer cancel()
+ stop := context.AfterFunc(ctx, cancel)
+ defer stop()
+ total, err := storage.CountIssues(countCtx, "", nativeIssueFilterFromListQuery(query))
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return ctxErr
+ }
+ return err
+ }
+ n = int(total)
+ return nil
+ })
+ if err != nil {
+ return 0, fmt.Errorf("counting beads: %w", err)
+ }
+ return n, nil
+}
+
+// nativeDoltCountSupported reports whether Count can answer query with
+// List-cardinality parity. Rejected shapes and why:
+// - excludeTypes: the upstream IssueFilter has no type-exclusion
+// predicate; List's callers apply it post-hydration.
+// - non-default tiers: nativeIssueFilterFromListQuery defers the wisp
+// tier filter to ApplyListQuery, so the backend result is a superset.
+// - Status "open": translated to an exclude-list (closed, in_progress)
+// while Matches requires status == "open" exactly, so beads in any
+// other non-excluded status would be overcounted.
+// - Assignees / ParentIDs / SeekAfter / UpdatedBefore: not translated
+// into the filter at all; List narrows them Go-side.
+// - Metadata / CreatedBefore / ParentID: translated, but List re-applies
+// them through Matches with Go-side semantics (exact metadata match vs
+// the backend's own predicate, Before() precision, the parent
+// projection) a bare COUNT cannot be proven to reproduce — the same
+// over-conservative gate the DoltLite Counter applies.
+// - Limit: the Counter contract is List cardinality, including List's
+// post-sort limit cap.
+//
+// TierBoth is supported alongside TierIssues: it is the one tier with no
+// narrowing anywhere — no SQL ephemeral predicate (nativeIssueFilterFromListQuery
+// emits no tier filter) and no Go-side re-filter (matchesTier returns true
+// unconditionally) — so CountIssues' SearchIssues-mirroring merge is already
+// the exact List cardinality. This matters in practice: beadPolicyStore
+// expands every TierIssues read to TierBoth, so a TierIssues-only gate makes
+// policy-wrapped cities (any city with bead policies, e.g. order_tracking
+// retention) silently lose the Counter fast path and fall back to hydration.
+// TierWisps stays unsupported: its ephemeral||no-history membership is
+// resolved Go-side and has no exact filter translation.
+func nativeDoltCountSupported(query ListQuery, excludeTypes []string) bool {
+ return len(excludeTypes) == 0 &&
+ (query.TierMode == TierIssues || query.TierMode == TierBoth) &&
+ query.Status != "open" &&
+ len(query.Assignees) == 0 &&
+ len(query.ParentIDs) == 0 &&
+ query.SeekAfter == nil &&
+ query.UpdatedBefore.IsZero() &&
+ len(query.Metadata) == 0 &&
+ query.CreatedBefore.IsZero() &&
+ query.ParentID == "" &&
+ query.Limit == 0
+}
diff --git a/internal/beads/native_dolt_store_count_test.go b/internal/beads/native_dolt_store_count_test.go
index 4aab989bd8..9f15bf7a0a 100644
--- a/internal/beads/native_dolt_store_count_test.go
+++ b/internal/beads/native_dolt_store_count_test.go
@@ -1,17 +1,212 @@
package beads
-import "testing"
-
-// NativeDoltStore must NOT implement Counter. The pinned beads library's
-// CountIssues counts only the issues table, while SearchIssues (the List
-// path) also merges the wisps table — no-history and ephemeral rows — so
-// a backend COUNT undercounts open work relative to List (#1896 review).
-// Hydration-free counting happens at the caching layer instead; when the
-// cache cannot answer, CachingStore.Count reports ErrCountUnsupported and
-// callers fall back to the hydrating List path, which is exact.
-func TestNativeDoltStoreDoesNotImplementCounter(t *testing.T) {
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ beadslib "github.com/steveyegge/beads"
+)
+
+// NativeDoltStore implements Counter through the pinned beads library's
+// CountIssues, which merges the wisps tier with the same semantics as
+// SearchIssues (GH#4387) — the wisps undercount that previously pinned this
+// store as Counter-less is fixed upstream. The supported-shape gate keeps
+// the parity contract: only queries whose ListQuery→IssueFilter translation
+// is exact are answered; everything else reports ErrCountUnsupported so
+// callers fall back to the hydrating List.
+
+func TestNativeDoltStoreImplementsCounter(t *testing.T) {
var store any = &NativeDoltStore{}
- if _, ok := store.(Counter); ok {
- t.Fatal("NativeDoltStore implements Counter; the backing CountIssues misses wisps-table rows (no-history/ephemeral), undercounting vs List (#1896)")
+ if _, ok := store.(Counter); !ok {
+ t.Fatal("NativeDoltStore does not implement Counter; the closed-inclusive store-health count needs the hydration-free CountIssues path")
+ }
+}
+
+func TestNativeDoltStoreCountDelegatesToCountIssues(t *testing.T) {
+ var gotFilter *beadslib.IssueFilter
+ searchCalls := 0
+ storage := &nativeDoltStorageSpy{
+ countIssues: func(_ context.Context, query string, filter beadslib.IssueFilter) (int64, error) {
+ if query != "" {
+ t.Fatalf("CountIssues search text = %q, want empty (same as List)", query)
+ }
+ gotFilter = &filter
+ return 19342, nil
+ },
+ searchIssues: func(context.Context, string, beadslib.IssueFilter) ([]*beadslib.Issue, error) {
+ searchCalls++
+ return nil, nil
+ },
+ }
+ store := newNativeDoltStoreForTest(storage)
+
+ got, err := store.Count(context.Background(), ListQuery{AllowScan: true, IncludeClosed: true})
+ if err != nil {
+ t.Fatalf("Count: %v", err)
+ }
+ if got != 19342 {
+ t.Fatalf("Count = %d, want 19342", got)
+ }
+ if searchCalls != 0 {
+ t.Fatalf("SearchIssues called %d times, want 0 (Count must not hydrate)", searchCalls)
+ }
+ if gotFilter == nil {
+ t.Fatal("CountIssues was never called")
+ }
+ // The filter must be the same translation List uses: closed rows
+ // included (no status exclusion) and the default tier's ephemeral
+ // exclusion applied backend-side.
+ if len(gotFilter.ExcludeStatus) != 0 {
+ t.Fatalf("filter.ExcludeStatus = %v, want none for IncludeClosed", gotFilter.ExcludeStatus)
+ }
+ if gotFilter.Ephemeral == nil || *gotFilter.Ephemeral {
+ t.Fatalf("filter.Ephemeral = %v, want &false for the default tier", gotFilter.Ephemeral)
+ }
+ if gotFilter.Limit != 0 {
+ t.Fatalf("filter.Limit = %d, want 0", gotFilter.Limit)
+ }
+}
+
+func TestNativeDoltStoreCountTranslatesExactFilterShapes(t *testing.T) {
+ var gotFilter *beadslib.IssueFilter
+ storage := &nativeDoltStorageSpy{
+ countIssues: func(_ context.Context, _ string, filter beadslib.IssueFilter) (int64, error) {
+ gotFilter = &filter
+ return 7, nil
+ },
+ }
+ store := newNativeDoltStoreForTest(storage)
+
+ got, err := store.Count(context.Background(), ListQuery{
+ Status: "closed",
+ Type: "task",
+ Assignee: "gascity/builder",
+ Label: "sweep",
+ AllowScan: true,
+ IncludeClosed: true,
+ })
+ if err != nil {
+ t.Fatalf("Count: %v", err)
+ }
+ if got != 7 {
+ t.Fatalf("Count = %d, want 7", got)
+ }
+ if gotFilter == nil {
+ t.Fatal("CountIssues was never called")
+ }
+ if gotFilter.Status == nil || *gotFilter.Status != beadslib.StatusClosed {
+ t.Fatalf("filter.Status = %v, want closed", gotFilter.Status)
+ }
+ if gotFilter.IssueType == nil || *gotFilter.IssueType != beadslib.IssueType("task") {
+ t.Fatalf("filter.IssueType = %v, want task", gotFilter.IssueType)
+ }
+ if gotFilter.Assignee == nil || *gotFilter.Assignee != "gascity/builder" {
+ t.Fatalf("filter.Assignee = %v, want gascity/builder", gotFilter.Assignee)
+ }
+ if len(gotFilter.Labels) != 1 || gotFilter.Labels[0] != "sweep" {
+ t.Fatalf("filter.Labels = %v, want [sweep]", gotFilter.Labels)
+ }
+}
+
+// TestNativeDoltStoreCountBothTiersSupported asserts the TierBoth shape —
+// what beadPolicyStore expands every TierIssues read into — is served by
+// CountIssues with no ephemeral predicate. TierBoth has no narrowing on
+// either side (no SQL tier filter, matchesTier always true), so the merged
+// backend count is exact; gating it out silently cost policy-wrapped cities
+// the store-health fast path.
+func TestNativeDoltStoreCountBothTiersSupported(t *testing.T) {
+ var gotFilter beadslib.IssueFilter
+ store := newNativeDoltStoreForTest(&nativeDoltStorageSpy{
+ countIssues: func(_ context.Context, _ string, filter beadslib.IssueFilter) (int64, error) {
+ gotFilter = filter
+ return 20641, nil
+ },
+ })
+ n, err := store.Count(context.Background(), ListQuery{AllowScan: true, IncludeClosed: true, TierMode: TierBoth})
+ if err != nil {
+ t.Fatalf("Count(TierBoth) err = %v, want nil", err)
+ }
+ if n != 20641 {
+ t.Fatalf("Count(TierBoth) = %d, want 20641", n)
+ }
+ if gotFilter.Ephemeral != nil {
+ t.Fatalf("filter.Ephemeral = %v, want nil (no tier predicate for TierBoth)", *gotFilter.Ephemeral)
+ }
+}
+
+// TestNativeDoltStoreCountUnsupportedShapes asserts Count reports
+// ErrCountUnsupported for every shape List narrows Go-side, so callers fall
+// back to the hydrating path instead of receiving a superset count.
+func TestNativeDoltStoreCountUnsupportedShapes(t *testing.T) {
+ cases := []struct {
+ name string
+ query ListQuery
+ excludeTypes []string
+ }{
+ {name: "excludeTypes", query: ListQuery{AllowScan: true, IncludeClosed: true}, excludeTypes: []string{"message"}},
+ {name: "status open exclude-list translation", query: ListQuery{Status: "open", AllowScan: true}},
+ {name: "wisps tier filtered Go-side", query: ListQuery{AllowScan: true, TierMode: TierWisps}},
+ {name: "metadata re-filtered Go-side", query: ListQuery{AllowScan: true, Metadata: map[string]string{"gc.rig": "fc"}}},
+ {name: "assignees not translated", query: ListQuery{AllowScan: true, Assignees: []string{"a", "b"}}},
+ {name: "parentID Go-side projection", query: ListQuery{AllowScan: true, ParentID: "ga-parent"}},
+ {name: "parentIDs not translated", query: ListQuery{AllowScan: true, ParentIDs: []string{"ga-parent"}}},
+ {name: "createdBefore precision", query: ListQuery{AllowScan: true, CreatedBefore: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}},
+ {name: "updatedBefore not translated", query: ListQuery{AllowScan: true, UpdatedBefore: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}},
+ {name: "limit cap is List-side", query: ListQuery{AllowScan: true, Limit: 5}},
+ {name: "seekAfter resolved Go-side", query: ListQuery{AllowScan: true, Sort: SortCreatedAsc, SeekAfter: &SeekBoundary{ID: "ga-1"}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ store := newNativeDoltStoreForTest(&nativeDoltStorageSpy{
+ countIssues: func(context.Context, string, beadslib.IssueFilter) (int64, error) {
+ t.Error("CountIssues called for an unsupported shape")
+ return 0, nil
+ },
+ })
+ _, err := store.Count(context.Background(), tc.query, tc.excludeTypes...)
+ if !errors.Is(err, ErrCountUnsupported) {
+ t.Fatalf("Count err = %v, want ErrCountUnsupported", err)
+ }
+ })
+ }
+}
+
+func TestNativeDoltStoreCountRequiresFilterOrScan(t *testing.T) {
+ store := newNativeDoltStoreForTest(&nativeDoltStorageSpy{})
+ if _, err := store.Count(context.Background(), ListQuery{}); !errors.Is(err, ErrQueryRequiresScan) {
+ t.Fatalf("Count err = %v, want ErrQueryRequiresScan", err)
+ }
+}
+
+func TestNativeDoltStoreCountHonorsCallerCancellation(t *testing.T) {
+ store := newNativeDoltStoreForTest(&nativeDoltStorageSpy{
+ countIssues: func(context.Context, string, beadslib.IssueFilter) (int64, error) {
+ t.Error("CountIssues called after the caller's context was canceled")
+ return 0, nil
+ },
+ })
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := store.Count(ctx, ListQuery{AllowScan: true, IncludeClosed: true})
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("Count err = %v, want context.Canceled", err)
+ }
+}
+
+func TestNativeDoltStoreCountPropagatesBackendError(t *testing.T) {
+ wantErr := errors.New("count blew up")
+ store := newNativeDoltStoreForTest(&nativeDoltStorageSpy{
+ countIssues: func(context.Context, string, beadslib.IssueFilter) (int64, error) {
+ return 0, wantErr
+ },
+ })
+ got, err := store.Count(context.Background(), ListQuery{AllowScan: true, IncludeClosed: true})
+ if got != 0 {
+ t.Errorf("Count = %d, want zero value on error", got)
+ }
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("Count err = %v, want %v", err, wantErr)
}
}
diff --git a/internal/beads/native_dolt_store_graph_deadline_test.go b/internal/beads/native_dolt_store_graph_deadline_test.go
new file mode 100644
index 0000000000..5f304ccb5a
--- /dev/null
+++ b/internal/beads/native_dolt_store_graph_deadline_test.go
@@ -0,0 +1,30 @@
+package beads
+
+import (
+ "testing"
+ "time"
+)
+
+// A large molecule pour must get more transaction budget than a single bd
+// command: the per-edge cycle checks made a 67-node plan blow the flat 120s
+// deadline mid-transaction and fall back to per-bead creates (2026-07-17).
+func TestNativeGraphApplyDeadlineScalesWithPlanSize(t *testing.T) {
+ t.Parallel()
+
+ if got := nativeGraphApplyDeadline(nil); got != bdCommandTimeout {
+ t.Fatalf("nil plan deadline = %v, want flat %v", got, bdCommandTimeout)
+ }
+ small := &GraphApplyPlan{Nodes: make([]GraphApplyNode, 1)}
+ if got := nativeGraphApplyDeadline(small); got <= bdCommandTimeout {
+ t.Fatalf("small plan deadline = %v, want > flat %v", got, bdCommandTimeout)
+ }
+ big := &GraphApplyPlan{
+ Nodes: make([]GraphApplyNode, 67),
+ Edges: make([]GraphApplyEdge, 100),
+ }
+ got := nativeGraphApplyDeadline(big)
+ want := bdCommandTimeout + 167*2*time.Second
+ if got != want {
+ t.Fatalf("67-node/100-edge plan deadline = %v, want %v", got, want)
+ }
+}
diff --git a/internal/beads/native_dolt_store_integration_test.go b/internal/beads/native_dolt_store_integration_test.go
index 3eef6dddc0..1baf20d9fb 100644
--- a/internal/beads/native_dolt_store_integration_test.go
+++ b/internal/beads/native_dolt_store_integration_test.go
@@ -4,10 +4,18 @@ package beads
import (
"context"
+ "database/sql"
"errors"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
"path/filepath"
+ "strconv"
"testing"
+ "time"
+ _ "github.com/go-sql-driver/mysql"
beadslib "github.com/steveyegge/beads"
)
@@ -224,3 +232,113 @@ func TestNativeDoltStoreRealBackendRoundTrip(t *testing.T) {
t.Fatalf("Get missing error = %v, want ErrNotFound", err)
}
}
+
+// startTestDoltServer launches a throwaway dolt sql-server in a temp data dir
+// and returns a *sql.DB connected to a fresh database on it. Skips the test
+// when the dolt binary is unavailable.
+func startTestDoltServer(t *testing.T) *sql.DB {
+ t.Helper()
+ doltBin, err := exec.LookPath("dolt")
+ if err != nil {
+ t.Skip("dolt binary not in PATH")
+ }
+
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("pick free port: %v", err)
+ }
+ port := lis.Addr().(*net.TCPAddr).Port
+ _ = lis.Close()
+
+ dataDir := t.TempDir()
+ cmd := exec.Command(doltBin, "sql-server", "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--data-dir", dataDir)
+ cmd.Env = append(os.Environ(), "DOLT_ROOT_PATH="+dataDir)
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start dolt sql-server: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = cmd.Process.Kill()
+ _, _ = cmd.Process.Wait()
+ })
+
+ dsn := fmt.Sprintf("root@tcp(127.0.0.1:%d)/", port)
+ db, err := sql.Open("mysql", dsn)
+ if err != nil {
+ t.Fatalf("open dolt connection: %v", err)
+ }
+ deadline := time.Now().Add(30 * time.Second)
+ for {
+ if err := db.Ping(); err == nil {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("dolt sql-server did not become ready on port %d", port)
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ if _, err := db.Exec("CREATE DATABASE repairtest"); err != nil {
+ t.Fatalf("create test database: %v", err)
+ }
+ _ = db.Close()
+
+ db, err = sql.Open("mysql", dsn+"repairtest")
+ if err != nil {
+ t.Fatalf("open test database: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ return db
+}
+
+// TestRepairIDDefaultAgainstDoltServer exercises the SHOW COLUMNS-based probe
+// end-to-end against a real dolt sql-server (the same wire protocol the live
+// fleet uses): a stripped DEFAULT is detected and repaired, an intact DEFAULT
+// is left alone, and an absent table is not an error. This covers the probe
+// rewrite that replaced the per-open INFORMATION_SCHEMA.COLUMNS catalog scan.
+func TestRepairIDDefaultAgainstDoltServer(t *testing.T) {
+ db := startTestDoltServer(t)
+
+ showIDDefault := func(table string) any {
+ var field, colType, null, key, extra string
+ var def any
+ row := db.QueryRow(fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE 'id'", table))
+ if err := row.Scan(&field, &colType, &null, &key, &def, &extra); err != nil {
+ t.Fatalf("SHOW COLUMNS FROM %s: %v", table, err)
+ }
+ return def
+ }
+
+ // Stripped default: probe detects it and the ALTER restores it.
+ if _, err := db.Exec("CREATE TABLE events (id char(36) NOT NULL, note text)"); err != nil {
+ t.Fatalf("create events: %v", err)
+ }
+ if err := repairIDDefault(db, "events"); err != nil {
+ t.Fatalf("repairIDDefault(events): %v", err)
+ }
+ if def := showIDDefault("events"); def == nil {
+ t.Fatal("events.id Default still NULL after repair, want (uuid())")
+ }
+
+ // Intact default: repair is a no-op and must not error.
+ if _, err := db.Exec("CREATE TABLE dependencies (id char(36) NOT NULL DEFAULT (uuid()), note text)"); err != nil {
+ t.Fatalf("create dependencies: %v", err)
+ }
+ if err := repairIDDefault(db, "dependencies"); err != nil {
+ t.Fatalf("repairIDDefault(dependencies) with intact default: %v", err)
+ }
+ if def := showIDDefault("dependencies"); def == nil {
+ t.Fatal("dependencies.id Default = NULL after no-op repair, want (uuid())")
+ }
+
+ // Absent table (e.g. wisp_events on an older schema): tolerated, not an error.
+ if err := repairIDDefault(db, "wisp_events"); err != nil {
+ t.Fatalf("repairIDDefault(wisp_events) on absent table: %v", err)
+ }
+
+ // Table without an id column: nothing to repair, no error.
+ if _, err := db.Exec("CREATE TABLE noid (pk int PRIMARY KEY)"); err != nil {
+ t.Fatalf("create noid: %v", err)
+ }
+ if err := repairIDDefault(db, "noid"); err != nil {
+ t.Fatalf("repairIDDefault(noid) without id column: %v", err)
+ }
+}
diff --git a/internal/beads/native_dolt_store_list_sort_test.go b/internal/beads/native_dolt_store_list_sort_test.go
new file mode 100644
index 0000000000..6bafa9f416
--- /dev/null
+++ b/internal/beads/native_dolt_store_list_sort_test.go
@@ -0,0 +1,345 @@
+package beads
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ beadslib "github.com/steveyegge/beads"
+)
+
+// A created-order ListQuery sort pushes down to the backing search
+// (IssueFilter.SortBy) so the caller's limit survives and the store pages
+// instead of materializing + hydrating the whole retained corpus — ~22k closed
+// order-tracking wisps for the dispatcher's RecentRunsAll read, 5-8s per call,
+// twice a tick (sr-dp9o). The limit only survives for shapes the backing can
+// resolve exactly: created-asc (its `id ASC` tie-break matches Gas City's
+// canonical order) always keeps it, but created-desc keeps it only for aggregate
+// callers that opt in (AllowBackingCreatedLimit), because the backing breaks
+// created_at ties by `id ASC` while the canonical desc order breaks them by
+// `id DESC` — a bounded desc read would otherwise drop the larger-id boundary
+// ties an exact/paginated caller needs.
+func TestNativeIssueFilterPushesCreatedSortAndKeepsLimit(t *testing.T) {
+ cases := []struct {
+ name string
+ sort SortOrder
+ approx bool
+ wantLimit int
+ wantSortBy string
+ wantSortDesc bool
+ }{
+ {"created asc keeps limit", SortCreatedAsc, false, 2048, "created", true},
+ {"created desc without opt-in strips limit", SortCreatedDesc, false, 0, "created", false},
+ {"created desc with opt-in keeps limit", SortCreatedDesc, true, 2048, "created", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ filter := nativeIssueFilterFromListQuery(ListQuery{
+ Label: "order-tracking",
+ Limit: 2048,
+ IncludeClosed: true,
+ Sort: tc.sort,
+ AllowBackingCreatedLimit: tc.approx,
+ })
+ if filter.Limit != tc.wantLimit {
+ t.Errorf("Limit = %d, want %d", filter.Limit, tc.wantLimit)
+ }
+ if filter.SortBy != tc.wantSortBy {
+ t.Errorf("SortBy = %q, want %q", filter.SortBy, tc.wantSortBy)
+ }
+ if filter.SortDesc != tc.wantSortDesc {
+ t.Errorf("SortDesc = %v, want %v", filter.SortDesc, tc.wantSortDesc)
+ }
+ })
+ }
+}
+
+// nativeCreatedLimitPushdown is the single source of truth for whether the
+// caller's limit is safe to forward to the backing search. Pin every gate: the
+// Go-side-only residual filters (SeekAfter, UpdatedBefore, plural Assignees) and
+// the wisp tier must strip the limit for every sort, and created-desc must strip
+// it unless the caller opted into a bounded newest-by-created_at sample.
+func TestNativeCreatedLimitPushdownGates(t *testing.T) {
+ seek := &SeekBoundary{CreatedAt: time.Unix(1, 0).UTC(), ID: "gc-x"}
+ cases := []struct {
+ name string
+ q ListQuery
+ want int
+ }{
+ {"asc pushes", ListQuery{Sort: SortCreatedAsc, Limit: 5}, 5},
+ {"desc without opt-in strips", ListQuery{Sort: SortCreatedDesc, Limit: 5}, 0},
+ {"desc with opt-in pushes", ListQuery{Sort: SortCreatedDesc, Limit: 5, AllowBackingCreatedLimit: true}, 5},
+ {"desc opt-in but seek strips", ListQuery{Sort: SortCreatedDesc, Limit: 5, AllowBackingCreatedLimit: true, SeekAfter: seek}, 0},
+ {"asc but seek strips", ListQuery{Sort: SortCreatedAsc, Limit: 5, SeekAfter: seek}, 0},
+ {"desc opt-in but updated-before strips", ListQuery{Sort: SortCreatedDesc, Limit: 5, AllowBackingCreatedLimit: true, UpdatedBefore: time.Unix(2, 0).UTC()}, 0},
+ {"desc opt-in but plural assignees strips", ListQuery{Sort: SortCreatedDesc, Limit: 5, AllowBackingCreatedLimit: true, Assignees: []string{"a", "b"}}, 0},
+ {"wisp tier strips", ListQuery{Sort: SortCreatedDesc, Limit: 5, AllowBackingCreatedLimit: true, TierMode: TierWisps}, 0},
+ {"default sort pushes", ListQuery{Sort: SortDefault, Limit: 5}, 5},
+ {"zero limit stays zero", ListQuery{Sort: SortCreatedDesc, Limit: 0, AllowBackingCreatedLimit: true}, 0},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := nativeCreatedLimitPushdown(tc.q); got != tc.want {
+ t.Errorf("nativeCreatedLimitPushdown = %d, want %d", got, tc.want)
+ }
+ })
+ }
+}
+
+// TierWisps still needs the gc-side post-filter over the full candidate set,
+// so its limit strip is preserved.
+func TestNativeIssueFilterStillStripsLimitForWispTier(t *testing.T) {
+ filter := nativeIssueFilterFromListQuery(ListQuery{Limit: 10, TierMode: TierWisps, AllowScan: true})
+ if filter.Limit != 0 {
+ t.Errorf("Limit = %d, want 0 for TierWisps", filter.Limit)
+ }
+}
+
+// Backing search results for a pushed-down sort arrive presorted; the
+// client-side ApplyListQuery re-sort must keep them stable and the limit cut
+// must match the server page. Models the dispatcher's RecentRunsAll aggregate
+// read, which opts into the bounded backing limit.
+func TestNativeDoltStoreListSortedLimitedPassesFilterToBacking(t *testing.T) {
+ var got beadslib.IssueFilter
+ storage := &nativeDoltStorageSpy{
+ searchIssues: func(_ context.Context, _ string, f beadslib.IssueFilter) ([]*beadslib.Issue, error) {
+ got = f
+ return nil, nil
+ },
+ }
+ store := newNativeDoltStoreForTest(storage)
+ if _, err := store.List(ListQuery{Label: "order-tracking", Limit: 2048, IncludeClosed: true, Sort: SortCreatedDesc, AllowBackingCreatedLimit: true}); err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if got.Limit != 2048 || got.SortBy != "created" || got.SortDesc {
+ t.Fatalf("backing filter = {Limit:%d SortBy:%q SortDesc:%v}, want {2048 created false}", got.Limit, got.SortBy, got.SortDesc)
+ }
+}
+
+// A bounded created-desc read must return the exact canonical (created_at DESC,
+// id DESC) top-N even when the boundary is a created_at tie. The backing breaks
+// ties by id ASC, so pushing the limit would return the SMALLER ids (gc-01..03)
+// and, after the client re-sort, drop the larger-id ties (gc-04..06) a cursor
+// walk then skips. Without the opt-in the store fetches the full set and cuts
+// the exact prefix client-side; this test fails against a bare id-ASC pushdown.
+func TestNativeDoltStoreListCreatedDescExactTieBreakByDefault(t *testing.T) {
+ ts := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
+ var issues []*beadslib.Issue
+ for i := 1; i <= 6; i++ {
+ issues = append(issues, &beadslib.Issue{
+ ID: fmt.Sprintf("gc-%02d", i), Title: "t", Status: beadslib.StatusOpen,
+ IssueType: beadslib.TypeTask, Priority: 2, CreatedAt: ts,
+ })
+ }
+ storage := &nativeDoltStorageSpy{
+ searchIssues: func(_ context.Context, _ string, f beadslib.IssueFilter) ([]*beadslib.Issue, error) {
+ if f.Limit != 0 {
+ t.Errorf("backing limit = %d pushed for a default created-desc read; the id-ASC boundary tie would drop canonical rows", f.Limit)
+ }
+ return backingSortLimitForTest(issues, f), nil
+ },
+ }
+ store := newNativeDoltStoreForTest(storage)
+
+ got, err := store.List(ListQuery{AllowScan: true, Sort: SortCreatedDesc, Limit: 3})
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ assertBeadIDsForTest(t, got, "gc-06", "gc-05", "gc-04")
+}
+
+// An aggregate caller that opts in accepts the backing's bounded page: the fetch
+// stays O(limit) and the created_at max is preserved, which is all a
+// max-over-the-set reader needs. It does NOT get the canonical id tie-break —
+// that is exactly why only aggregate callers set AllowBackingCreatedLimit.
+func TestNativeDoltStoreListCreatedDescOptInBoundsTheFetch(t *testing.T) {
+ ts := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
+ var issues []*beadslib.Issue
+ for i := 1; i <= 6; i++ {
+ issues = append(issues, &beadslib.Issue{
+ ID: fmt.Sprintf("gc-%02d", i), Title: "t", Status: beadslib.StatusOpen,
+ IssueType: beadslib.TypeTask, Priority: 2, CreatedAt: ts,
+ })
+ }
+ var pushed int
+ storage := &nativeDoltStorageSpy{
+ searchIssues: func(_ context.Context, _ string, f beadslib.IssueFilter) ([]*beadslib.Issue, error) {
+ pushed = f.Limit
+ return backingSortLimitForTest(issues, f), nil
+ },
+ }
+ store := newNativeDoltStoreForTest(storage)
+
+ got, err := store.List(ListQuery{AllowScan: true, Sort: SortCreatedDesc, Limit: 3, AllowBackingCreatedLimit: true})
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if pushed != 3 {
+ t.Fatalf("backing limit = %d, want 3 (aggregate opt-in must bound the fetch)", pushed)
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d rows, want 3", len(got))
+ }
+ for _, b := range got {
+ if !b.CreatedAt.Equal(ts) {
+ t.Fatalf("row %s CreatedAt = %v, want %v (aggregate max must be preserved)", b.ID, b.CreatedAt, ts)
+ }
+ }
+}
+
+// A max(seq) event-cursor reducer (the order dispatcher's Cursor/bdCursor) reads
+// created-desc with a bounded Limit, but must NOT opt into the backing limit: it
+// reduces over seq, a DIFFERENT column than the created_at sort key. seq is
+// forward-only, so the max-seq run is the newest largest-id row — precisely the
+// tie member the backing's id-ASC created-desc limit drops first when a
+// same-second burst exceeds the bound. Without the opt-in the store fetches the
+// full set and cuts the canonical (created_at DESC, id DESC) prefix, keeping the
+// max-seq row; with the opt-in it silently regresses the cursor. Regression guard
+// for the gastownhall/gascity#4214 attempt-2 finding.
+func TestNativeDoltStoreListCreatedDescMaxSeqReducerKeepsHighSeqWithoutOptIn(t *testing.T) {
+ ts := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
+ // Six runs in one wall-clock second; seq is forward-only, so the max seq (6)
+ // sits on the largest id gc-06. The bound (3) is smaller than the burst (6),
+ // and gc-06 is NOT in the backing's id-ASC prefix (gc-01..03).
+ var issues []*beadslib.Issue
+ for i := 1; i <= 6; i++ {
+ issues = append(issues, &beadslib.Issue{
+ ID: fmt.Sprintf("gc-%02d", i), Title: "t", Status: beadslib.StatusOpen,
+ IssueType: beadslib.TypeTask, Priority: 2, CreatedAt: ts,
+ Labels: []string{"order:digest", fmt.Sprintf("seq:%d", i)},
+ })
+ }
+ spyFor := func() *nativeDoltStorageSpy {
+ return &nativeDoltStorageSpy{
+ searchIssues: func(_ context.Context, _ string, f beadslib.IssueFilter) ([]*beadslib.Issue, error) {
+ return backingSortLimitForTest(issues, f), nil
+ },
+ }
+ }
+
+ // Seq-reducer shape (no opt-in): the max-seq row survives the client-side cut.
+ got, err := newNativeDoltStoreForTest(spyFor()).List(ListQuery{AllowScan: true, Sort: SortCreatedDesc, Limit: 3})
+ if err != nil {
+ t.Fatalf("List (no opt-in): %v", err)
+ }
+ assertBeadIDsForTest(t, got, "gc-06", "gc-05", "gc-04")
+ if seq := maxSeqLabelForTest(got); seq != 6 {
+ t.Fatalf("max seq without opt-in = %d, want 6 (the cursor must not drop the newest run)", seq)
+ }
+
+ // The same read WITH the opt-in shows the regression the cursor avoids: the
+ // backing keeps its id-ASC prefix (gc-01..03) and drops the max-seq row gc-06;
+ // the client re-sort then presents them canonically as gc-03, gc-02, gc-01.
+ bugged, err := newNativeDoltStoreForTest(spyFor()).List(ListQuery{AllowScan: true, Sort: SortCreatedDesc, Limit: 3, AllowBackingCreatedLimit: true})
+ if err != nil {
+ t.Fatalf("List (opt-in): %v", err)
+ }
+ assertBeadIDsForTest(t, bugged, "gc-03", "gc-02", "gc-01")
+ if seq := maxSeqLabelForTest(bugged); seq != 3 {
+ t.Fatalf("max seq with opt-in = %d, want 3 (documents the dropped max-seq run)", seq)
+ }
+}
+
+// maxSeqLabelForTest extracts the highest seq: label across the beads,
+// mirroring the order dispatcher's MaxSeqFromLabels reduction (which this lower
+// layer cannot import). It lets a native-store test assert the row a max(seq)
+// cursor reducer needs actually survives the read.
+func maxSeqLabelForTest(got []Bead) uint64 {
+ var maxSeq uint64
+ for _, b := range got {
+ for _, l := range b.Labels {
+ if strings.HasPrefix(l, "seq:") {
+ if n, err := strconv.ParseUint(l[len("seq:"):], 10, 64); err == nil && n > maxSeq {
+ maxSeq = n
+ }
+ }
+ }
+ }
+ return maxSeq
+}
+
+// A seeked created-desc read must fetch the full candidate set and enforce the
+// keyset boundary client-side, even when the caller opted into the aggregate
+// bounded limit: a backing limit applied before the Go-side seek filter cuts the
+// newest rows and starves the page (the page-2 truncation both reviewers
+// flagged). Mirrors the sibling seek gates (exec, doltlite, bdstore).
+func TestNativeDoltStoreListSeekAfterFetchesFullSetForCreatedDesc(t *testing.T) {
+ t3 := time.Date(2026, 7, 11, 12, 0, 3, 0, time.UTC)
+ t2 := time.Date(2026, 7, 11, 12, 0, 2, 0, time.UTC)
+ t1 := time.Date(2026, 7, 11, 12, 0, 1, 0, time.UTC)
+ issues := []*beadslib.Issue{
+ {ID: "gc-a", Title: "t", Status: beadslib.StatusOpen, IssueType: beadslib.TypeTask, Priority: 2, CreatedAt: t3},
+ {ID: "gc-b", Title: "t", Status: beadslib.StatusOpen, IssueType: beadslib.TypeTask, Priority: 2, CreatedAt: t2},
+ {ID: "gc-c", Title: "t", Status: beadslib.StatusOpen, IssueType: beadslib.TypeTask, Priority: 2, CreatedAt: t1},
+ }
+ storage := &nativeDoltStorageSpy{
+ searchIssues: func(_ context.Context, _ string, f beadslib.IssueFilter) ([]*beadslib.Issue, error) {
+ if f.Limit != 0 {
+ t.Errorf("backing limit = %d pushed for a seeked read; a native limit truncates before the Go-side seek boundary", f.Limit)
+ }
+ return backingSortLimitForTest(issues, f), nil
+ },
+ }
+ store := newNativeDoltStoreForTest(storage)
+
+ got, err := store.List(ListQuery{
+ AllowScan: true,
+ Sort: SortCreatedDesc,
+ Limit: 1,
+ AllowBackingCreatedLimit: true,
+ SeekAfter: &SeekBoundary{CreatedAt: t3, ID: "gc-a"},
+ })
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ assertBeadIDsForTest(t, got, "gc-b")
+}
+
+// backingSortLimitForTest reproduces the upstream backing search: order created
+// sorts by (created_at , id ASC) — sqlbuild.OrderBy hardcodes the id ASC
+// tie-break — then apply the row limit as a prefix cut.
+func backingSortLimitForTest(all []*beadslib.Issue, f beadslib.IssueFilter) []*beadslib.Issue {
+ out := make([]*beadslib.Issue, len(all))
+ copy(out, all)
+ if f.SortBy == "created" {
+ desc := !f.SortDesc // SortDefs["created"] defaults DESC; SortDesc flips it
+ sort.SliceStable(out, func(i, j int) bool {
+ a, b := out[i], out[j]
+ if !a.CreatedAt.Equal(b.CreatedAt) {
+ if desc {
+ return a.CreatedAt.After(b.CreatedAt)
+ }
+ return a.CreatedAt.Before(b.CreatedAt)
+ }
+ return a.ID < b.ID
+ })
+ }
+ if f.Limit > 0 && len(out) > f.Limit {
+ out = out[:f.Limit]
+ }
+ cloned := make([]*beadslib.Issue, len(out))
+ for i, iss := range out {
+ cloned[i] = cloneNativeIssueForTest(iss)
+ }
+ return cloned
+}
+
+func assertBeadIDsForTest(t *testing.T, got []Bead, want ...string) {
+ t.Helper()
+ gotIDs := make([]string, len(got))
+ for i, b := range got {
+ gotIDs[i] = b.ID
+ }
+ if len(gotIDs) != len(want) {
+ t.Fatalf("got IDs %v, want %v", gotIDs, want)
+ }
+ for i := range want {
+ if gotIDs[i] != want[i] {
+ t.Fatalf("got IDs %v, want %v", gotIDs, want)
+ }
+ }
+}
diff --git a/internal/beads/native_dolt_store_metadata_cas_integration_test.go b/internal/beads/native_dolt_store_metadata_cas_integration_test.go
new file mode 100644
index 0000000000..b23728d81b
--- /dev/null
+++ b/internal/beads/native_dolt_store_metadata_cas_integration_test.go
@@ -0,0 +1,254 @@
+//go:build integration
+
+package beads
+
+import (
+ "context"
+ "path/filepath"
+ "strconv"
+ "sync"
+ "testing"
+
+ beadslib "github.com/steveyegge/beads"
+)
+
+// openRealNativeDoltStoreForCAS opens a NativeDoltStore over REAL upstream
+// native storage. The narrow CAS contract is a claim about the backend's
+// transaction semantics, and the in-memory fixture used by the unit-level
+// conformance cannot answer it: nativeDoltMemStorage.RunInTransaction
+// snapshots for rollback and then runs the callback UNLOCKED, so it models
+// atomicity but provides no isolation whatsoever.
+func openRealNativeDoltStoreForCAS(t *testing.T, actor string) *NativeDoltStore {
+ t.Helper()
+ ctx := context.Background()
+ storage, err := beadslib.OpenBestAvailable(ctx, filepath.Join(t.TempDir(), ".beads"))
+ if err != nil {
+ t.Skipf("upstream native beads storage unavailable: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := storage.Close(); err != nil {
+ t.Errorf("close upstream storage: %v", err)
+ }
+ })
+ if err := storage.SetConfig(ctx, "issue_prefix", "gc"); err != nil {
+ t.Fatalf("set issue prefix: %v", err)
+ }
+ return newNativeDoltStoreWithStorageAndPrefix(storage, actor, "gc")
+}
+
+// TestNativeDoltStoreMetadataCASSequentialAgainstRealDolt exercises the
+// sequential value-CAS contract — both pinned traps — against real storage.
+func TestNativeDoltStoreMetadataCASSequentialAgainstRealDolt(t *testing.T) {
+ store := openRealNativeDoltStoreForCAS(t, "cas-sequential")
+
+ b, err := store.Create(Bead{Title: "real-dolt-cas"})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ id := b.ID
+
+ // Trap 1: expected "" claims an ABSENT key.
+ if ok, err := store.CompareAndSetMetadataKey(id, "k", "", "one"); err != nil || !ok {
+ t.Fatalf("claim absent key: (%v, %v), want (true, nil)", ok, err)
+ }
+ // ...and also a PRESENT-AND-EMPTY key.
+ if err := store.SetMetadata(id, "k", ""); err != nil {
+ t.Fatalf("SetMetadata clear: %v", err)
+ }
+ if ok, err := store.CompareAndSetMetadataKey(id, "k", "", "two"); err != nil || !ok {
+ t.Fatalf("claim empty-valued key: (%v, %v), want (true, nil)", ok, err)
+ }
+ // ...but never a non-empty one.
+ if ok, err := store.CompareAndSetMetadataKey(id, "k", "", "three"); err != nil || ok {
+ t.Fatalf("claim non-empty key with empty expected: (%v, %v), want (false, nil)", ok, err)
+ }
+
+ // Trap 2: a genuine mismatch is (false, nil), never an error.
+ ok, err := store.CompareAndSetMetadataKey(id, "k", "WRONG", "four")
+ if err != nil {
+ t.Fatalf("value-mismatch CAS returned error: %v (want nil)", err)
+ }
+ if ok {
+ t.Fatal("value-mismatch CAS returned true (want false)")
+ }
+
+ got, err := store.Get(id)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.Metadata["k"] != "two" {
+ t.Fatalf("value = %q, want %q", got.Metadata["k"], "two")
+ }
+}
+
+// TestNativeDoltStoreMetadataCASContentionAgainstRealDolt is the load-bearing
+// test for the lease lane: under concurrency exactly ONE racer may win a claim
+// from a single starting value. This is the property the in-memory fixture
+// cannot evaluate, and the property D3/D5 leases and target_scope member
+// declaration actually depend on — a CAS that admits two winners hands the
+// same lease to two holders.
+func TestNativeDoltStoreMetadataCASContentionAgainstRealDolt(t *testing.T) {
+ store := openRealNativeDoltStoreForCAS(t, "cas-contention")
+
+ b, err := store.Create(Bead{Title: "real-dolt-cas-contention"})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ id := b.ID
+ if err := store.SetMetadata(id, "lease", ""); err != nil {
+ t.Fatalf("SetMetadata: %v", err)
+ }
+
+ const racers = 8
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ winners []string
+ errs []error
+ )
+ start := make(chan struct{})
+ for i := range racers {
+ wg.Add(1)
+ go func(racer int) {
+ defer wg.Done()
+ holder := "holder-" + strconv.Itoa(racer)
+ <-start
+ ok, err := store.CompareAndSetMetadataKey(id, "lease", "", holder)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ errs = append(errs, err)
+ return
+ }
+ if ok {
+ winners = append(winners, holder)
+ }
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+
+ for _, err := range errs {
+ t.Errorf("racer returned an error (a lost race must be (false, nil)): %v", err)
+ }
+ if len(winners) != 1 {
+ t.Fatalf("winners = %d %v, want exactly 1 — no mutual exclusion, so this CAS cannot carry a lease",
+ len(winners), winners)
+ }
+
+ got, err := store.Get(id)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.Metadata["lease"] != winners[0] {
+ t.Fatalf("stored lease = %q, want the sole winner %q", got.Metadata["lease"], winners[0])
+ }
+}
+
+// TestNativeDoltStoreMetadataCASContentionAcrossIndependentHandles is the
+// multi-writer leg, and it is the one that actually decides whether this CAS
+// can carry a lease.
+//
+// The single-handle contention test above cannot distinguish a fence enforced
+// by the DATABASE from exclusion accidentally provided by shared in-process
+// state (a connection pool, a handle-level lock). The gascity Dolt database is
+// multi-writer by design — the bd CLI, other gascity processes and graph-apply
+// all write it — so a guard that only holds within one store handle is not a
+// fence at all, which is precisely why a store-maintained counter was rejected
+// as a revision token.
+//
+// Racing two INDEPENDENTLY OPENED storage handles over the same database
+// directory reproduces that condition inside one test binary: the handles
+// share no Go-level state, so any exclusion observed here is enforced below
+// them.
+func TestNativeDoltStoreMetadataCASContentionAcrossIndependentHandles(t *testing.T) {
+ ctx := context.Background()
+ dir := filepath.Join(t.TempDir(), ".beads")
+
+ openHandle := func(actor string) *NativeDoltStore {
+ t.Helper()
+ storage, err := beadslib.OpenBestAvailable(ctx, dir)
+ if err != nil {
+ t.Skipf("upstream native beads storage unavailable: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := storage.Close(); err != nil {
+ t.Logf("close upstream storage (%s): %v", actor, err)
+ }
+ })
+ if err := storage.SetConfig(ctx, "issue_prefix", "gc"); err != nil {
+ t.Fatalf("set issue prefix (%s): %v", actor, err)
+ }
+ return newNativeDoltStoreWithStorageAndPrefix(storage, actor, "gc")
+ }
+
+ writerA := openHandle("cas-writer-a")
+ writerB := openHandle("cas-writer-b")
+
+ b, err := writerA.Create(Bead{Title: "cross-handle-cas-contention"})
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ id := b.ID
+ if err := writerA.SetMetadata(id, "lease", ""); err != nil {
+ t.Fatalf("SetMetadata: %v", err)
+ }
+ // The second handle must observe the bead before racing for it, otherwise
+ // a miss proves nothing about the fence.
+ if got, err := writerB.Get(id); err != nil || got.ID != id {
+ t.Fatalf("second handle cannot see bead %q: (%v, %v)", id, got.ID, err)
+ }
+
+ type result struct {
+ holder string
+ won bool
+ err error
+ }
+ results := make(chan result, 2)
+ start := make(chan struct{})
+ for _, racer := range []struct {
+ store *NativeDoltStore
+ holder string
+ }{{writerA, "holder-A"}, {writerB, "holder-B"}} {
+ go func(s *NativeDoltStore, holder string) {
+ <-start
+ won, err := s.CompareAndSetMetadataKey(id, "lease", "", holder)
+ results <- result{holder: holder, won: won, err: err}
+ }(racer.store, racer.holder)
+ }
+ close(start)
+
+ var winners []string
+ for range 2 {
+ r := <-results
+ if r.err != nil {
+ // A conflict surfaced as an error is NOT contract-conformant: the
+ // contract says a lost race is (false, nil). Report it as the
+ // contract violation it is rather than tolerating it.
+ t.Errorf("racer %s returned an error (a lost race must be (false, nil)): %v", r.holder, r.err)
+ continue
+ }
+ if r.won {
+ winners = append(winners, r.holder)
+ }
+ }
+ if t.Failed() {
+ return
+ }
+ if len(winners) != 1 {
+ t.Fatalf("winners across independent handles = %d %v, want exactly 1 — the fence does not hold "+
+ "between writers, so this CAS cannot carry a lease in the multi-writer Dolt database",
+ len(winners), winners)
+ }
+
+ // Both handles must agree on who holds the lease.
+ for name, s := range map[string]*NativeDoltStore{"writerA": writerA, "writerB": writerB} {
+ got, err := s.Get(id)
+ if err != nil {
+ t.Fatalf("%s Get: %v", name, err)
+ }
+ if got.Metadata["lease"] != winners[0] {
+ t.Fatalf("%s sees lease %q, want the sole winner %q", name, got.Metadata["lease"], winners[0])
+ }
+ }
+}
diff --git a/internal/beads/native_dolt_store_metadata_cas_internal_test.go b/internal/beads/native_dolt_store_metadata_cas_internal_test.go
new file mode 100644
index 0000000000..7ea219e30c
--- /dev/null
+++ b/internal/beads/native_dolt_store_metadata_cas_internal_test.go
@@ -0,0 +1,112 @@
+package beads
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/rollout/gate"
+)
+
+// TestNativeDoltStoreDeclaresNarrowCASButNotConditionalWriter pins the exact
+// capability split the narrow interface exists to express: NativeDoltStore
+// offers a sound metadata value-CAS and makes NO revision-fence claim.
+//
+// Declaring the full ConditionalWriter would make ResolveConditionalWriter
+// RESOLVE under require mode and hand the revision-CAS trio's callers a
+// silently wrong-fenced write, because no sound revision token exists at
+// beads v1.1.0 (see internal/beads/metadata_cas.go). This asserts the ABSENCE
+// of that capability, which no conformance suite can do.
+func TestNativeDoltStoreDeclaresNarrowCASButNotConditionalWriter(t *testing.T) {
+ store := newNativeDoltStoreForTest(newNativeDoltMemStorage())
+
+ if w, ok := ConditionalWriterFor(store); ok {
+ t.Fatalf("NativeDoltStore resolved a ConditionalWriter (%T); the revision-CAS trio has "+
+ "no sound backend fence at beads v1.1.0, so declaring it is a safety regression", w)
+ }
+ if _, ok := MetadataCASWriterFor(store); !ok {
+ t.Fatal("NativeDoltStore does not resolve a MetadataCASWriter; the narrow value-CAS " +
+ "capability is what unblocks target_scope member-declaration and the D3/D5 lease lane")
+ }
+}
+
+// TestNativeDoltStoreConditionalWritesStillRefuseOrDegrade pins the seam
+// behavior the condWritesStamp comment in native_dolt_store.go guarantees:
+// require yields a typed refusal and auto yields a loud degrade — never a
+// silent legacy write under require. Adding the narrow CAS must not move
+// either verdict.
+func TestNativeDoltStoreConditionalWritesStillRefuseOrDegrade(t *testing.T) {
+ t.Run("require_refuses", func(t *testing.T) {
+ store := newNativeDoltStoreForTest(newNativeDoltMemStorage())
+ store.stampConditionalWritesMode(gate.Require, false)
+
+ writer, diag, err := ResolveConditionalWriter(store)
+ if writer != nil {
+ t.Fatalf("writer = %T, want nil (require must fail closed)", writer)
+ }
+ if !IsConditionalWritesRequired(err) {
+ t.Fatalf("err = %v, want *ConditionalWritesRequiredError", err)
+ }
+ if diag == nil {
+ t.Fatal("diagnostic = nil, want a refusal diagnostic")
+ }
+ })
+
+ t.Run("auto_degrades_loudly", func(t *testing.T) {
+ store := newNativeDoltStoreForTest(newNativeDoltMemStorage())
+ store.stampConditionalWritesMode(gate.Auto, false)
+
+ writer, diag, err := ResolveConditionalWriter(store)
+ if writer != nil {
+ t.Fatalf("writer = %T, want nil (auto must take the legacy path)", writer)
+ }
+ if err != nil {
+ t.Fatalf("err = %v, want nil (auto degrades, it does not refuse)", err)
+ }
+ if diag == nil {
+ t.Fatal("diagnostic = nil, want a loud-degrade diagnostic")
+ }
+ })
+}
+
+// TestCachingStoreOverNativeDoltStoreForwardsNarrowCAS covers the wrapper
+// shape the plan calls out: a CachingStore whose backing offers only the
+// narrow capability must still forward the metadata CAS. The cache resolves
+// its trio verbs through ConditionalWriterFor, so without a narrow fallback
+// this path would answer ErrConditionalWriteUnsupported and the lease lane
+// would be blocked behind the cache.
+func TestCachingStoreOverNativeDoltStoreForwardsNarrowCAS(t *testing.T) {
+ backing := newNativeDoltStoreForTest(newNativeDoltMemStorage())
+ cache := NewCachingStore(backing, nil)
+
+ b, err := cache.Create(Bead{Title: "cache-over-native-cas"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ writer, ok := MetadataCASWriterFor(cache)
+ if !ok {
+ t.Fatal("CachingStore over a narrow-CAS backing does not resolve a MetadataCASWriter")
+ }
+ if swapped, err := writer.CompareAndSetMetadataKey(b.ID, "lease", "", "holder-1"); err != nil || !swapped {
+ t.Fatalf("claim through cache: (%v, %v), want (true, nil)", swapped, err)
+ }
+ // A stale expectation loses cleanly rather than erroring.
+ if swapped, err := writer.CompareAndSetMetadataKey(b.ID, "lease", "", "holder-2"); err != nil || swapped {
+ t.Fatalf("stale claim through cache: (%v, %v), want (false, nil)", swapped, err)
+ }
+ // The winner's value is visible through the cache (the CAS evicted, so the
+ // next read consults the backing).
+ got, err := cache.Get(b.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Metadata["lease"] != "holder-1" {
+ t.Fatalf("lease through cache = %q, want %q", got.Metadata["lease"], "holder-1")
+ }
+
+ // The trio stays refused: the backing makes no revision claim, so the
+ // cache must not report itself conditionally capable over it.
+ if capable, _ := cache.probeConditionalWriteCapability(); capable {
+ t.Fatal("CachingStore reports conditional-write capability over a narrow-only backing; " +
+ "the revision-CAS trio has no sound fence there")
+ }
+}
diff --git a/internal/beads/native_dolt_store_metadata_cas_test.go b/internal/beads/native_dolt_store_metadata_cas_test.go
new file mode 100644
index 0000000000..13f459b086
--- /dev/null
+++ b/internal/beads/native_dolt_store_metadata_cas_test.go
@@ -0,0 +1,52 @@
+package beads_test
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beads"
+ "github.com/gastownhall/gascity/internal/beads/beadstest"
+ "github.com/gastownhall/gascity/internal/fsys"
+)
+
+// TestNativeDoltStoreMetadataCASConformance holds NativeDoltStore to the
+// narrow value-CAS contract, including both traps the in-tree implementations
+// historically diverged on.
+//
+// The contention leg is declared unevaluatable HERE and only here: the
+// in-memory fixture behind this factory snapshots for rollback and then runs
+// the transaction callback unlocked, so concurrent CAS calls interleave freely
+// no matter how the store behaves. The property is not waived — it is proven
+// against real Dolt by
+// TestNativeDoltStoreMetadataCASContentionAgainstRealDolt (build tag
+// `integration`), where 8 racers yield exactly one winner.
+func TestNativeDoltStoreMetadataCASConformance(t *testing.T) {
+ beadstest.RunMetadataCASConformanceWithOptions(t, "NativeDoltStore",
+ func(_ *testing.T) beads.Store { return beads.NewNativeDoltStoreForConformance() },
+ beadstest.MetadataCASOptions{
+ FixtureLacksIsolationReason: "nativeDoltMemStorage.RunInTransaction models rollback but not " +
+ "isolation (it unlocks before running the callback); contention is covered against real " +
+ "Dolt by TestNativeDoltStoreMetadataCASContentionAgainstRealDolt (-tags=integration)",
+ },
+ )
+}
+
+// TestMemStoreMetadataCASConformance and TestFileStoreMetadataCASConformance
+// run the SAME narrow suite against the two stores whose fixtures do provide
+// isolation (both guard the whole CAS under their own lock), so the contention
+// leg is genuinely exercised at unit level and the suite cannot rot into a
+// table where every store has opted out of it.
+func TestMemStoreMetadataCASConformance(t *testing.T) {
+ beadstest.RunMetadataCASConformance(t, "MemStore",
+ func(_ *testing.T) beads.Store { return beads.NewMemStore() })
+}
+
+func TestFileStoreMetadataCASConformance(t *testing.T) {
+ beadstest.RunMetadataCASConformance(t, "FileStore",
+ func(t *testing.T) beads.Store {
+ store, err := beads.OpenFileStore(fsys.OSFS{}, t.TempDir()+"/beads.json")
+ if err != nil {
+ t.Fatalf("OpenFileStore: %v", err)
+ }
+ return store
+ })
+}
diff --git a/internal/beads/native_dolt_store_reconnect_test.go b/internal/beads/native_dolt_store_reconnect_test.go
index 764d613670..7572e1ca05 100644
--- a/internal/beads/native_dolt_store_reconnect_test.go
+++ b/internal/beads/native_dolt_store_reconnect_test.go
@@ -45,22 +45,38 @@ func storeWithReopen(dead beadslib.Storage, fresh beadslib.Storage, reopens *int
return store
}
-func TestNativeDoltStoreGetReconnectsAfterTransientConnError(t *testing.T) {
- healthy := healthySearchStorage(&beadslib.Issue{
- ID: "gc-1", Title: "recovered", Status: beadslib.StatusOpen, IssueType: beadslib.TypeTask, Priority: 2,
+func TestNativeDoltStoreGetReconnectsAndInstallsFreshStorage(t *testing.T) {
+ fresh := healthySearchStorage(&beadslib.Issue{
+ ID: "gc-existing", Title: "recovered", Status: beadslib.StatusOpen, IssueType: beadslib.TypeTask, Priority: 2,
})
- var reopens int32
- store := storeWithReopen(deadSearchStorage(errors.New("begin read tx: dial tcp 127.0.0.1:58216: i/o timeout")), healthy, &reopens)
+ fresh.createIssue = func(_ context.Context, issue *beadslib.Issue, _ string) error {
+ issue.ID = "gc-created"
+ return nil
+ }
+ errDeadCreate := errors.New("create reached dead storage")
+ dead := deadSearchStorage(errors.New("begin read tx: dial tcp 127.0.0.1:58216: i/o timeout"))
+ dead.createIssue = func(context.Context, *beadslib.Issue, string) error {
+ return errDeadCreate
+ }
+ store := newNativeDoltStoreForTest(dead)
+ store.reopen = func(context.Context) (beadslib.Storage, error) {
+ return fresh, nil
+ }
- got, err := store.Get("gc-1")
+ got, err := store.Get("gc-existing")
if err != nil {
t.Fatalf("Get after transient conn error: %v", err)
}
- if got.ID != "gc-1" {
- t.Fatalf("Get.ID = %q, want gc-1", got.ID)
+ if got.ID != "gc-existing" {
+ t.Fatalf("Get.ID = %q, want gc-existing", got.ID)
}
- if n := atomic.LoadInt32(&reopens); n == 0 {
- t.Fatalf("expected the reopen hook to fire; got %d", n)
+
+ created, err := store.Create(Bead{Title: "created after reconnect", Type: "task"})
+ if err != nil {
+ t.Fatalf("Create after reconnect: %v", err)
+ }
+ if created.ID != "gc-created" {
+ t.Fatalf("Create.ID = %q, want gc-created", created.ID)
}
}
diff --git a/internal/beads/native_dolt_store_test.go b/internal/beads/native_dolt_store_test.go
index 97117111ba..758a46d94e 100644
--- a/internal/beads/native_dolt_store_test.go
+++ b/internal/beads/native_dolt_store_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "maps"
"os"
"path/filepath"
"slices"
@@ -330,11 +331,24 @@ func TestNativeDoltStoreListStatusOpenExcludesClosedBeadsFromUpstreamDrift(t *te
}
}
-func TestNativeDoltStoreReadyIncludesOpenNormalizedUpstreamStatuses(t *testing.T) {
+func TestNativeDoltStoreReadyOnlyIncludesOpenAndDeferredUpstreamStatuses(t *testing.T) {
+ // bd's own status-category table (vendored beads internal/types.
+ // BuiltInStatusCategory) marks blocked/hooked as "wip" and pinned as
+ // "frozen" — both excluded from bd's own ready semantics. Only "open"
+ // (category active) and deferred (once DeferUntil has passed, handled
+ // via IsReadyCandidateForTier's IsDeferred check) belong here. This
+ // issue set intentionally includes a blocked bead whose dependency
+ // graph the spy treats as fully satisfied (it is returned unconditionally
+ // whenever queried by status), to prove Ready() must never surface it
+ // even when GetReadyWork would happily return it if asked. gc-deferred
+ // carries a past DeferUntil to represent an expired time-bound deferral;
+ // the no-DeferUntil (indefinite) case is covered separately by
+ // TestNativeDoltStoreReadyExcludesIndefinitelyDeferredBeads.
+ past := time.Now().UTC().Add(-24 * time.Hour)
issues := []*beadslib.Issue{
{ID: "gc-open", Title: "open", Status: beadslib.StatusOpen, IssueType: beadslib.TypeTask, Priority: 2},
{ID: "gc-blocked", Title: "blocked", Status: beadslib.StatusBlocked, IssueType: beadslib.TypeTask, Priority: 2},
- {ID: "gc-deferred", Title: "deferred", Status: beadslib.StatusDeferred, IssueType: beadslib.TypeTask, Priority: 2},
+ {ID: "gc-deferred", Title: "deferred", Status: beadslib.StatusDeferred, IssueType: beadslib.TypeTask, Priority: 2, DeferUntil: &past},
{ID: "gc-pinned", Title: "pinned", Status: beadslib.Status("pinned"), IssueType: beadslib.TypeTask, Priority: 2},
{ID: "gc-hooked", Title: "hooked", Status: beadslib.Status("hooked"), IssueType: beadslib.TypeTask, Priority: 2},
{ID: "gc-review", Title: "review", Status: beadslib.Status("review"), IssueType: beadslib.TypeTask, Priority: 2},
@@ -361,15 +375,14 @@ func TestNativeDoltStoreReadyIncludesOpenNormalizedUpstreamStatuses(t *testing.T
}
wantIDs := map[string]bool{
- "gc-open": true, "gc-blocked": true, "gc-deferred": true,
- "gc-pinned": true, "gc-hooked": true, "gc-review": true,
+ "gc-open": true, "gc-deferred": true,
}
if len(got) != len(wantIDs) {
t.Fatalf("Ready len = %d, want %d; got %+v", len(got), len(wantIDs), got)
}
for _, bead := range got {
if !wantIDs[bead.ID] {
- t.Fatalf("Ready returned unexpected bead %q from %+v", bead.ID, got)
+ t.Fatalf("Ready returned unexpected bead %q from %+v — blocked/pinned/hooked/review must never surface as ready even when their dependency graph is satisfied", bead.ID, got)
}
if bead.Status != "open" {
t.Fatalf("Ready bead %q status = %q, want normalized open", bead.ID, bead.Status)
@@ -411,6 +424,52 @@ func TestNativeDoltStoreReadyExcludesFutureDeferredBeads(t *testing.T) {
}
}
+// TestNativeDoltStoreReadyExcludesIndefinitelyDeferredBeads covers bd defer
+// without --until: a first-class, documented "status-based" indefinite
+// deferral (upstream cmd/bd/defer.go) that sets status=deferred and leaves
+// defer_until NULL, distinct from bd defer --until=