fix(coderd/x/chatd): check duplicate tool call IDs before dropping rejected finalizer calls - #29991
Conversation
…jected finalizer calls With pre_tool_use hooks enabled and an open structured output request, a step's rejected finalizer calls were removed from the batch before admission checked it for duplicate tool call IDs. An ordinary call that shared a rejected finalizer call's ID was dropped with it, skipped pre_tool_use, and was committed unadmitted without the duplicate check failing the step. Admission now checks the full screened batch first and fails the step as the existing duplicate path does. Structured output requests are not reachable through the API yet, so this lands before the slice that activates them. _Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high_ Signed-off-by: Thomas Kosiewski <[email protected]>
|
Remote UAT on
Recording, segment 1 (login, prompt, live reply): 9599e907-c-ui-recording.webmRecording, segment 2 (reloaded chat): 9c90e461-c-ui-recording-seg2.webmGenerated with |
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |




Checks duplicate tool call IDs on the full screened batch before rejected finalizer calls leave hook admission. It is the dup-tool-call-ids slice (C1b) of the structured-output backend roadmap and is stacked on #29987. The B4 UAT (#29975) found the gap; this slice sits before the activation slices on purpose, so the gap is closed before structured output requests become reachable through the API.
pre_tool_usehooks enabled and an open structured output request,generateAssistantremoved the step's rejected finalizer calls from the batch beforeadmitStepToolCallsran its duplicate tool call ID check. The filter matched by ID, so an ordinary call that shared a rejected finalizer call's ID was dropped with it: it skippedpre_tool_useadmission, the duplicate check never saw the collision, and the call was committed unadmitted.admitStepToolCallsnow receives the screened step content and the rejected call IDs. When there are rejected calls, it runschathooks.RejectDuplicateToolUseIDson the full screened batch first and only then drops the rejected calls. A collision fails the step exactly like the existing duplicate path: the batch'sfind_toolscalls are counted whenfind_toolsis a builtin, and the step fails withGenerationDispatchErrorforpre_tool_use, before any hook dispatch.Regression test:
TestRejectedFinalizerCallKeepsDuplicateToolUseIDCheck. Its step holds acoder_structured_outputcall with invalid arguments and aread_filecall with the same ID; the chat must end inerrorwith "duplicate tool use ID" and nopre_tool_usedispatch. Before the fix the chat endedwaiting.No migration, query, API or frontend change. No generated files. Handwritten: 73 lines, 45 of them tests.
Other deferred follow-ups are tracked in #29982.
Validation:
go testand-raceforcoderd/x/chatd/...,go vet, andgolangci-lintforcoderd/x/chatd.make pre-pushhook on push (fullmake test,test-js, site build).chatstatetest, the wholechatdpackage includingTestRejectedFinalizerCallKeepsDuplicateToolUseIDCheckand the earlier finalizer and stream loop tests, thecoderdchat message tests and the earlier layers' tests),-race, fuzzing, a strace run with one loopback connection, the generated-files check (no drift), an independent full-turn probe with an in-processpre_tool_usehook, plain and with-race(a rejected finalizer call that shares its ID with aread_filecall ends the chat inerrorwith a duplicate tool use ID and no hook dispatch; no collision, rejected calls only, hooks disabled and an exclusive tool after filtering behave as before), the previous slice's end-to-end projection check on a real deployment as a regression, and an ordinary live-model chat smoke with recorded video. All required checks passed in round 1, with no known limitation reproduced. Screenshots and video are in the first comment.📋 Implementation Plan
Coder Agents structured output: backend implementation plan
1. Outcome and scope
Add a per-request
response_formatto the Coder chat API. An agent can use its normal tools, then submit a JSON value. Coder validates that value and publishes a durable, request-correlated success, failure, or cancellation. The later AI SDK adapter reads this result without implementing a finalizer tool or parsing tool names.This plan replaces the previous plan. It targets the inspected checkout at
06d421fbcf2con 2026-09-22, matching the locally availableorigin/mainat inspection. The working tree was clean. Recheck the implementation base before starting; remote freshness was not established by a fetch.Scope: Go server, Go SDK contracts, generated API artifacts, API documentation, and tests. Generated
site/src/api/typesGenerated.tschanges are allowed. No handwritten TypeScript, externalcoder/ai-sdkchanges, UI feature, provider-library upgrade, new endpoint, or new database resource.Guarantee: a successful result contains exactly one server-validated JSON value. This is not a promise of provider-native constrained decoding, factual correctness, or guaranteed model compliance. A model that will not comply produces a bounded failure, never successful plain text.
Initial limitations: inline Draft-07 JSON schemas; no references or custom vocabularies; no partial structured-value streaming; no structured-output contract on plan-mode or specialized child-mode turns. Normal root chats may still use subagents, dynamic tools, MCP tools, and provider tools. The parent's format is not inherited by subagents. Existing authorization and organization scope remain unchanged.
2. Current evidence and corrections
coderd/chat_routes.go:15-98mounts create, messages, edit, queue, interrupt, and stream under/api/v2/chats.codersdk/chats.go:568-683defines create, send, edit, and their response batches.go.mod:111replaces Fantasy withgithub.com/coder/fantasyat9a3598480a71.fantasy.Callhas tools but no output schema;ObjectCallhas a schema but no tools.StreamObject.chatloop/tool_definitions.go:13-47builds a root object fromToolInfo.Parameters, then mutates nested maps throughschema.Normalize.chatloop/chatloop.goexecutes local tools and truncates their ordinary text results;chatprompt.go:765-780stores valid JSON directly, otherwise wraps text.generation.go:562-588loads visible durable history, including compressed rows. Prompt SQL atqueries/chats.sql:496-552drops older compressed input.message_conversion.go:461-475also skips compressed rows.message_conversion.go:307-382writes model-only compaction summaries and copies pending user content into model-only rows.generation.go:1427-1510runs stop hooks before finishing. A hook can keep the turn running.chatstate/transitions.go:602-711replaces edited input with a new row and deletes its suffix and queue.FinishTurn,FinishError, andFinishInterruptionown terminal transitions.waitingorlast_error.db2sdk.go:1740-1755strips internal hook-context parts. Frontend parsers exhaustively switch on public part variants.database/dump.sql:1510-1551advances execution history on any message insert.runner.go:159-173fences active work on history/status, not snapshot changes.stream_loop.go:114-164,226-291gates message deltas on history but stores a snapshot watermark.chatstate/machine.go:254-273reads under a chat-row share lock.chatopenai/responses.goand chatd chain-ID symbols are absent from this checkout.The previous plan also left validator choice,
strict:false, result shape, limits, and delivery boundaries undecided. Those are resolved below.3. Public API contract
3.1 Requests
Add optional
ResponseFormat *ChatResponseFormattoCreateChatRequest,CreateChatMessageRequest, andEditChatMessageRequest.{ "content": [{"type": "text", "text": "Inspect the project and report its test command."}], "response_format": { "type": "json_schema", "json_schema": { "name": "project_report", "schema": { "type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], "additionalProperties": false } } } }nullformat means ordinary text behavior on create/send. Explicit{"type":"text"}is also accepted. Reject an empty format object, unknown type, orjson_schemaaccompanyingtext.json_schemarequiresnameandschema. Name matches^[A-Za-z0-9_-]{1,64}$. Optional description is at most 1024 UTF-8 bytes. Reject unknown fields inside the format envelope, includingstrict. Validation is always enabled; there is no relaxed mode.null. Do not impose an object-only output restriction.nullpreserves the edited message's format; explicittextclears it; explicitjson_schemareplaces it. Read and validate preserved metadata under the same history guard as the edit. The replacement request receives a fresh request ID.codersdk.Response.Validationswith the exactresponse_format...field and HTTP 400. Existing authorization, unavailable-model, conflict, and body-limit statuses keep their meanings.Admission gate: add default-off
chat-structured-outputexperiment (ExperimentChatStructuredOutput) with no development bypass. Gate only acceptance of new formatted create/send/edit requests. When disabled, a syntactically supplied schema request, including an edit preserving a schema, returns HTTP 400 with aresponse_formatvalidation error explaining that the feature is disabled. Explicit text/ordinary requests remain available. Persisted accepted work is still processed when the experiment is disabled. Enable acceptance only after all serving and worker replicas are compatible; drain or cancel accepted work before downgrading. Do not use the experiment to disable recovery or hide persisted results.3.2 Correlation and terminal results
Mint a server-generated UUID once when accepting a structured request. Persist it with the format. Expose optional
structured_output_request_idonChatMessagefor the original input and onChatQueuedMessage. Preserve it through promotion, retries, and compaction copies. Create-chat callers obtain it from the first committed user message via existing message retrieval or streaming; no extra create endpoint is needed.Add optional
ChatMessage.StructuredOutput *ChatStructuredOutput:{ "id": 901, "role": "assistant", "content": [{"type":"text","text":"{\"command\":\"make test\"}"}], "structured_output": { "request_id": "11111111-1111-4111-8111-111111111111", "status": "succeeded", "value": {"command":"make test"} } }The receipt is an ordinary committed message with an additive field. Its assistant role is a transcript convention, not a provider response. It is visible to clients but excluded from provider prompts.
succeededvalue, including explicit JSONnullfailederror: {code, message}not_produced,validation_exhausted,generation_failed,configuration_error.cancellederror: {code, message}interrupted,superseded,queue_deleted. No value.Use
json.RawMessageforvalue, not float64 decoding/re-encoding. Preserve numeric value and exact string data; JSONB may normalize whitespace/key order, so do not promise byte-identical JSON. Do not call the result RFC-canonical JSON.Only committed receipt messages carry
structured_output. Candidate tool arguments, tool acknowledgments,message_partdeltas, and chat status events are not results. Existing message replay/reset/deletion behavior remains authoritative. Clients upsert by message ID and match by request UUID. A successful receipt can arrive while the next queued turn is alreadyrunning.Failure/cancellation receipts have a short text fallback. A success receipt has JSON text as its fallback. Existing clients can display the outcome without recognizing the new field. Whole-chat deletion and retention keep existing resource-deletion semantics; neither is a permanent result archive.
4. Schema validation and resource limits
Use the already-present
github.com/xeipuuv/gojsonschema v1.2.0, promoted from indirect to direct without a version upgrade. PinDraft7and disable dialect auto-detection. Its byte loaders usejson.Number; numeric validation uses rational arithmetic. Do not use the limited Fantasy schema struct as a validator.Keep trusted meta-validation separate from user-schema compilation. Compile only the fixed bundled Draft-07 meta-schema in a fresh trusted loader, validate the parsed user schema as data, then compile it in a different loader with meta-validation already satisfied and a deny-all document loader factory.
SchemaLoader.Validate=truerecursively invokesCompile(NewReferenceLoader(...))and changes loader state, so do not assume a supplied factory controls that nested call. Prove the selected two-stage path offline and with network/filesystem traps before importing it into ingress.The alternative
google/jsonschema-go v0.4.3is already direct and denies remote loads by default, but its current resolver does not fully meta-validate schemas and stores numeric bounds as float64. Avoid adding a second handwritten schema validator to compensate.$schemameans Draft-07. Accept only the canonical Draft-07 identifier when supplied. Reject other dialects rather than silently reinterpret them.$ref,$id, legacyid,$defs,definitions, and custom vocabulary keywords at schema positions. Do not reject an ordinary property or string value merely because it contains those names.anyOf,oneOf,allOf, and fractional numeric bounds. Reject unknown schema keywords. Treat annotations such as title/description/default as annotations; do not apply defaults or coerce values.formatnames use the pinned library's built-in checks. Reject unrecognized names instead of silently skipping them. Document this finite list and thatpatternuses Go regexp syntax. Do not mutate process-global format registries.regexis not in the supported list: the library compiles every instance string, and counted repetitions make that cost not adequately constrained by the selected resource budget.$refrejection.UseNumber; preserveRawMessagefor storage. Apply the same checks to schemas, format strings, model values, and recovered metadata.const/enum/default objects containing$ref,$id, and the trusted meta-schema URI. Those values must neither trigger loading nor shadow the trusted compiler's state.Initial server-side caps, fixed rather than caller-configurable:
allOf/anyOf/oneOfelement, eachnot/if/then/elsesubschema, and each schema-formdependenciesvalue; at most 4 nested branch levelspatternvalues andpatternPropertieskeys)patternPropertiesExploratory measurements against
gojsonschema v1.2.0set the branch, pattern, andpatternPropertieslimits above (cumulative allocation, not peak heap, on the development host). The library recompiles eachpatternPropertiespattern for every key on every validation: 2 small patterns over 4095 keys took 0.26 s and allocated 192 MiB. It keeps every validation error, about 1 KiB each, without fail-fast: 32 failingallOfbranches over 4000 keys produced 128k errors and 131 MiB, and 120 schema-formdependenciesover 4000 keys produced 480k errors and 465 MiB, while 8 branches over 4095 nodes produced 34 MiB in 0.10 s. The schema-position preflight enforces these limits before any library call. The compiler's own resource qualification on the implemented code remains an A1c gate.Enforce limits before expensive operations and on recovered persisted metadata. Use errors for untrusted input, never panics. Assert programmer-owned invariants in tests and return explicit internal errors in production. Add boundary tests, bounded fuzzing, offline loader tests, and worst-case benchmarks. A timeout goroutine around non-cancellable validation is not a resource bound. If qualification shows excessive CPU or memory use at these caps, lower the caps before API activation and update tests/docs together.
5. Durable state and execution design
5.1 Storage and state reconstruction
Use existing JSONB message content, following internal hook-context conventions. Add internal-only request, candidate/control, and outcome metadata shapes. Name the dedicated terminal discriminator
structured-output-outcome; do not reuse it for continuation/control records. Exclude these types from public generated unions, explicitly strip them indb2sdk, and explicitly omit them inchatprompt. The SDK may contain storage structs as it already does for internal parts; mark fields internal for generation. No tables or columns are added. One narrowly scoped execution-history trigger migration is required, as described below.Keep dependencies acyclic:
codersdkowns wire/storage structs and discriminators, with no import of chatd.chatstructuredimportscodersdk, the validator, and Fantasy only. It accepts decoded parts or small evidence records, validates schemas/values, builds a Fantasy definition/runner, and computes pure request state. It does not importchatloop,chatprompt,chatstate,db2sdk, or parent chatd.chatpromptparses database message content and identifies internal-only metadata.db2sdkprojects already-committed receipt metadata; it does not run validation or orchestration.chatloop.ProviderTool, and builds transaction messages. Chatstate uses existing parsing helpers for tool bookkeeping, not parent chatd.Do not create a generic run framework or configurable output-strategy registry.
Every control marker must be visible to the durable-state reader. Attach finish provenance, rejection count evidence, and candidate metadata to existing client-visible assistant/tool rows before their commit. For stop-hook continuation, insert a dedicated
visibility=usercontrol row with short continuation text plus internal invalidation metadata in the same transaction as the model-only hook context. The state reader sees this row even after restart;db2sdkstrips the metadata, and provider prompts omit the row. Treat both control and receipt rows as non-generation rows in all bookkeeping helpers.activeStructuredRequest(history)must:5.2 Finalizer inside the ordinary tool loop
Use reserved local tool
coder_structured_output. It takes exactly{"output": <value>}. It returns a tiny acknowledgment on validation success or bounded tool-error feedback on failure. Its successful value is recorded in internal candidate metadata in the same step commit as its result, outside truncatable tool text.Pass a fresh full
fantasy.FunctionTool.InputSchemathrough the existingchatloop.ProviderTooldefinition plus localRunnerseam. This preserves the wrapper root'sadditionalProperties:falseand the caller's schema without changing all ordinary tool definitions. Do not run the caller schema through mutatingschema.Normalize. Do not add a new Fantasy interface or enable strict tool decoding globally.Qualify configured provider strict-tool settings with actual wire encoders. Disable strictness for this finalizer alone where the pinned provider supports a per-tool override. Otherwise fail the incompatible structured request as
configuration_error; never alter other tools, require optional properties, or rewrite the caller's semantics to satisfy a strict provider. Add a preflight for any incompatible configuration that can be detected locally.ToolChoiceRequiredeverywhere.DynamicToolNames, never dispatched to a workspace, and never accepted through clienttool-resultssubmission.Pre-persistence argument screening: screen the original finalizer argument bytes before converting or committing any assistant row to JSONB, not only when the tool later runs. JSONB otherwise erases duplicate-key evidence and can reject large numbers or Unicode before validation. Enforce the argument byte cap while receiving its deltas. At stream completion, check raw JSON/Unicode/resource limits and finish eligibility before hook admission or step commit. On rejection, persist a small valid placeholder plus server-only rejected-call metadata, then close the tool call with a bounded error result. Never retain the unsafe payload in JSONB or reinterpret the placeholder as valid arguments on replay. The executor still performs schema validation and must check the persisted eligibility marker. Test duplicate keys, excessive exponents, NUL/surrogate escapes, restart after the assistant commit, and interrupted buffered parts.
5.3 Completion, repair, and hooks
A structured request must bypass ordinary text-only completion and unrelated stop-after success. Do not simply add the finalizer to
StopAfterToolsand search for any past successful result.validation_exhaustedif validation failed, otherwisenot_produced. Do not let normal max-step handling report success.5.4 Atomic terminal receipt and cancellation
At successful termination, append the user-visible/model-invisible receipt in the same
ChatMachine.Updatetransaction that invokesFinishTurn, before promotion. For terminal failure, do the equivalent withFinishError. For interruption, include it after partial/cancellation messages but beforeFinishInterruptionpromotes the queue.Use existing
CommitStepwhere legal; extend transition inputs with optional terminal messages only where the current transition cannot append them safely. Reuse message insert/revision publication, not a separate result store. Receipt publication happens only after commit. Failed fence checks produce no receipt. Under the chat lock, an existing receipt for this UUID prevents a duplicate terminal write after ambiguous commit acknowledgment or worker recovery.Receipt rows have
visibility=user, no provider metadata or usage, and normal text plus internal outcome metadata. Explicitly exclude them from completion scans, step counting, unresolved-tool scans, summaries, and provider replay. Visibility filtering alone is not sufficient because generation also reads visible history.Edit ordering is fixed: under the chat lock, collect affected pending UUIDs and existing terminal outcomes first; delete the old suffix and clear the queue; then insert cancellation receipts; then insert the replacement input and its hook suffix. Include every inserted receipt in
EditChatMessageResponse.Messages, in ID order. Do not cancel already-completed requests or delete newly written cancellations in the same edit.Cancellation of a queued request can append a receipt while an unrelated turn is generating or awaiting a tool. Filter receipt/control rows in chatstate pending-tool helpers as well as generation helpers. Regression gate: deleting a queued structured request must neither hide the ordinary turn's pending local/dynamic calls, rerun completed effects, nor make that turn complete.
Execution-history trigger prerequisite: the current message-insert trigger, introduced in migration
000519_chatd_core_state_machine.up.sql, advanceshistory_versionand resetsgeneration_attemptfor every inserted message. A receipt-only insert during unrelated work would therefore invalidate its execution fence. Add a migration replacing the current function to exempt only rows with the dedicated terminal-outcome discriminator,role=assistant, andvisibility=user. Filter transition-table rows before deriving affected chat IDs; a mixed receipt/ordinary-message batch must retain normal fencing. Do not exempt hook continuation or other user-only/control messages.Keep message revision, snapshot version, and stream notifications intact. Receipt-only inserts must not reset execution history, attempts, or retry budgets or restart an unrelated task. Recognize the dedicated server-created terminal row shape, not an arbitrary nested occurrence of its discriminator. Public inputs, dynamic-tool results, and hook payloads cannot manufacture this shape. Receipts are immutable. Keep UPDATE/DELETE triggers unchanged: suffix edits must still invalidate execution. The current dump and later trigger replacements confirm the insert-only seam; use the then-current definitions when implementing the migration.
Independent stream watermark prerequisite:
stream_loop.gocurrently gates fetching and emitting message deltas on execution history, although messagerevisionfollowssnapshot_version. First separate those responsibilities. Fetch on a newer snapshot, query changed messages using the last successfully applied snapshot watermark, and emit message events independently ofhistoryChanged. Preserve existing ID/revision deduplication,after_idreconnect, and tombstone-driven history resets. Keep preview resets and part-sequence clearing tied to execution-history/generation changes, not receipt-only snapshots. Reuse the existing snapshot cursor; no new counter or isolation protocol is needed.ChatMachine.ReadLocktakesFOR SHAREon the chat row within a transaction, excluding theFOR UPDATEtransitions until its message reads finish.applyDBSnapshotalready advancessnapshotVersionafter assembling message events. Preserve those boundaries: a hint or failed read must never advance the cursor. Test initialization, full reload, failed reads, empty deltas, and concurrent commits.streamLoop.partcontinues to filter by execution history, attempt, and sequence, so a receipt does not discard buffered parts.Land stream separation before the trigger migration, and both before any receipt-producing integration. The paired PostgreSQL regression must block an ordinary provider call or dynamic wait, delete a queued structured request, and receive its cancellation through SSE and REST before unblocking. Verify unchanged active task, preview, attempt, and retry accounting; no repeated effect or lost message after resumption; and correct reconnect and edit/reset behavior. Also cover receipt-only and mixed batches, non-exempt controls, malformed discriminator shapes, and rollback atomicity.
cancelled/queue_deletedin the same transaction as deletion. A disconnected client can recover it.cancelled/interrupted; new queued UUID remains separate. No output from partial buffer data.Tx.SetArchivedis disallowed in active states but permitsE1/XE1with queued work. Preserve queued structured requests and UUIDs as suspended, matching ordinary queue semantics; resume only through existing promotion paths. No cancellation on archive.Tx.ClearContextaccepts settledW/E0only, with an empty queue. Existing receipts remain closed; clearing context must not restart their schemas.Tx.ReconcileInvalidStatesynthesizes pending tool cancellations and parks inE0/E1. In the same transaction, close any determinable outstanding active structured request with a failedgeneration_failedreceipt; retain queued requests. Never fabricate success or select an ambiguous request.6. Reviewable delivery sequence
Start with 14 slices across three phases: foundations (A1-A5), durable runtime (B1-B6), and public API/release qualification (C1-C3). Implement one layer at a time on its actual prerequisite. Do not build the whole feature on one branch and split afterward. Each layer includes its own tests, builds independently, leaves ordinary chats usable, and is safe if later layers are delayed.
Size gate for every row: at most 500 handwritten changed lines, including tests and docs; at most 1000 total only when the excess is named generated output. Targets below are budgets, not measured diff sizes. Re-measure against each layer's immediate base. If a layer cannot fit, split at its stated fallback seam before coding further. Never move required tests into a trailing testing PR.
Phase A: internal foundation, HTTP acceptance still absent
coderd/x/chatd/chatstructured/schema.goand tests;go.moddirectness only.chatstructuredtool/value helpers and tests.codersdk/chats.go, variant-tag tests,chatstructured/state.go,chatprompt,db2sdk.stream_loop.goand stream tests.database/dump.sql, PostgreSQL trigger/runner tests.Phase A gate: all new package, variant, prompt, db2sdk, PostgreSQL migration/trigger, runner, and stream tests pass. No unknown public part variant; ordinary chat fixtures unchanged. Stream snapshot isolation and preview preservation are proven before the trigger exception lands. If A1 exceeds budget, split resource scanner/loader qualification from schema compilation, keeping tests with both. A1 uses this split as four layers: A1a raw JSON safety boundary, A1b private schema-position vocabulary screening and sanitization, A1c schema resource limits and regexp screening, and A1d trusted meta-validation, deny-all compilation, output validation, and loader qualification. If A3 exceeds budget, land internal codec/stripping before state reconstruction. Phase A ends at A5; A4 and A5 precede every receipt-producing slice.
Phase B: durable outcomes and runtime, still not publicly accepted
generation.go,message_conversion.go, narrowchatstatetransition inputs if needed.tasks.go,chatstate/transitions.goand state tests.chatd.go,chatstate/transitions.go, stream and state tests.generation.go, stream accumulation/step-commit helpers and tests.generation_preparer.go,generation.go,chatloop, tool-wire tests.generation.go,chatstructured/state.go, hook tests.Phase B gate: internally seeded formatted requests pass real-PostgreSQL lifecycle tests, live stream-isolation tests, and provider wire tests. None of these layers adds an accepted public request option. Earlier candidate/receipt helpers remain dormant unless an internal test explicitly seeds metadata. Ordinary HTTP chats are unchanged. Phase B ends at B6.
If necessary, split B3 into queued deletion and edited-suffix cancellation, B5 into local tool admission and provider-wire compatibility, or B6 into durable repair decisions and stop-hook finalization. These are fallback boundaries, not permission to exceed limits. Every split retains tests for its own invariants and remains unexposed until complete.
Phase C: public API, documentation, and release qualification
codersdk/chats.go,db2sdk,stream_looptests and generated artifacts.codersdk/chats.go,codersdk/deployment.go,exp_chats.go, chatd request options and HTTP tests.EditChatMessageRequest,patchChatMessage, chatd edit plumbing and tests.Critical C2 intermediate behavior: before C3 lands, reject edits of formatted inputs with a documented conflict rather than silently dropping their metadata. Ordinary-message edits keep existing behavior, including cancellation of any structured queued requests they discard. C3 removes that narrow restriction. The public release phase comprises C1-C3; C3 is its final member.
If C2 exceeds its budget, split create activation from send activation. Keep each request type, gate tests, and usage documentation with the endpoint that enforces it. If generation would exceed 1000 total lines, split the affected generator input into separate request/result additions. A regeneration-only follow-up is acceptable only if no intermediate branch has stale required generated files. Do not publish an accepted-but-unenforced request field to shrink a diff.
Delivery mechanics
gh-stackdependency links, each based on its actual prerequisite. No stack or PR is created by this planning task.7. Validation and acceptance
7.1 Automated checks
Each behavior starts with a failing test, then minimal implementation, then a simplification pass with tests still passing. Use existing fixtures in
chatd_test.go,generation_preparer_internal_test.go,message_conversion_test.go,hooks_test.go/stop_test.go,tasks_test.go,chatstate/transitions*_test.go,exp_chats*_test.go, andchatloop/chattestwire helpers. Add focused files rather than expanding giant test functions. Concurrent tests uset.Parallel(), unique IDs, channels or quartz, never timing sleeps.Representative targeted commands after the named packages/tests exist:
Verify new test names actually match; a command reporting no tests is not a gate. Run full touched-package tests after targeted passes. Run lifecycle tests with real PostgreSQL and race detection using repository fixtures, plus
go test -racefor new shared-state logic. Include worker takeover, duplicate completion, rollback, and interrupt-vs-finalize races with deterministic barriers.Provider qualification covers actual pinned request encoders with mock HTTP servers: OpenAI Responses, OpenAI-compatible Chat Completions, and Anthropic with thinking. Prove the schema wrapper and tools remain present, finalizer stays local, malformed/partial output cannot succeed, and transcript replay works. Other transports are not claimed validated without evidence. Provider rejection remains an explicit failure, not text fallback.
Generate the actual affected outputs with
make site/src/api/typesGenerated.tsandmake coderd/apidoc/swagger.json, then runmake genas required by the final diff.make gencovers more than API artifacts and can require Docker/PostgreSQL. Inspect every generated delta. Runmake fmt,make lint,make pre-commit, andmake pre-push; do not bypass hooks or report an unavailable service as a pass. Generated TS still requires the frontend type/lint gates andfrontend-review; no handwritten frontend logic is planned.Follow
write-docsif authoring underdocs/, and run its formatting/lint checks. Add only TODO notes in affectedchatd/ARCHITECTURE.mdsections; the human author writes architecture prose.7.2 Acceptance checklist
nullsucceeds distinctly from absent output. Legacy clients see a usable text fallback without a new content union variant.8. Remote dogfooding and evidence
Use
coder-agents-uatthrough an independent runner on an exact pushed feature SHA. Do not replace remote UAT with a local server in this workspace. No deployment actions are part of the current planning task.Setup and safety gate
./scripts/develop.sh. Use a unique process/resource/port namespace. For a focused binary build, usego build -o <owned-scratch>/coder ./cmd/coder, not fullmake buildduring iteration. On C2 and later, explicitly enablechat-structured-outputonly on the test deployment after verifying its migration and worker versions; also exercise the disabled-admission case. Earlier slices use internal contract fixtures, not a development bypass../scripts/coder-dev.shor an equivalent verified shim with a dedicated config and explicit test URL. HTTP usesUAT_URLand a token minted on that test deployment. Never use shared-deployment tokens or barecoderfor mutations.GO. An empty inherited URL is not proof; verify the hosting deployment from the chat/workspace identity. Repeat the gate after restarts and for later rounds.Hands-on scenarios
Use a small Go/curl harness outside tracked source, not the future TypeScript adapter. Save sanitized requests, committed stream events, REST reads, and validation assertions.
null; validate results independently and match UUIDs.agent-browser. Use snapshots for functionality. Label fake-provider lifecycle tests separately from live-model evidence.Capture PNG screenshots and a playable MP4/WebM covering the steps and final assertions. Logs,
script, or an asciinema cast supplement the evidence but do not replace video. Use a uniquely named agent-browser session; record the real UI or a terminal recording with rendered frames. Attach screenshots for inspection and inspect semantic video frames, not merely file existence or decoder success. If media capture fails, report that gate unverified.Keep evidence out of git, under the run's
.mux-uat/round-N/convention or the current skill's equivalent. Preserve the chat URL, manifest, exact tested SHA, commands, provider modes, raw failure receipts, screenshots, and recording. When attaching evidence to authorized GitHub work, usegh ... --attachwith matching local Markdown paths and verify published links. Do not use browser cookies orgh-image.9. Accepted trade-offs and stopping rules
TurnID. A narrow trigger migration separates terminal receipt publication from execution-history fencing. The default-off admission experiment has no development bypass. Enable it only after all serving/worker replicas and the migration support this contract; disabling acceptance must not stop recovery of already-accepted work. Do not claim a rolling mixed-version or downgrade guarantee before draining accepted work.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh