diff --git a/devlog/_plan/260908_bug6_manual_stack/000_plan.md b/devlog/_plan/260908_bug6_manual_stack/000_plan.md new file mode 100644 index 0000000000..c27b042af9 --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/000_plan.md @@ -0,0 +1,47 @@ +# Six-item manual integration stack + +## Loop specification + +- Archetype: satisfy the six named bug contracts, with a docs-only roadmap cycle followed by six implementation cycles and one integration cycle. +- Trigger: owner request on 2026-09-08 to use one stack, repeated PABCD, Astra high delegates, no local suites, no-verify pushes, and merge through dev. +- Goal: Go/xAI child-result compatibility, separated V2 guidance and server-owned presets, and correctly scoped reset-credit recovery. +- Non-goals: releases, main/preview, account changes, real credit consumption, unrelated cleanup, native GitHub stacks, local product tests/install/typecheck/build. +- Verifier: independent source audits and GitHub `ci.yml` at each candidate head; final dispatch `lane=all`. Docs-only verification checks numbered artifacts and whitespace without running product code. Every activation fixture and observable result is specified in the phase designs. +- Stop: every named item has a fresh terminal disposition, all nonempty layers have landed through reviewed PRs, final hosted gates pass, and fetched ancestry plus landed-tree comparison prove integration. +- Artifacts: this numbered unit, ignored `.tmp/bug6-01a07e9d/`, and session-bound `.codexclaw` ledger/receipts. New unpublished security analysis stays in scratch only. +- Outcomes: DONE requires all evidence; NOOP requires proof the full named contract already landed; unresolved work remains pending; genuine external blockers are reported without inventing proof. +- Escalation: main reclaims failed delegate slices after two distinct failures. Delegation is preplanned below. No further user approval is needed for the explicitly authorized pushes/merges. Permission/ruleset changes remain outside scope. +- Resources: existing repository/GitHub access, synthetic fixtures, gpt-6-astra high leaf agents within host concurrency. No user-set token/cost/time limit; no extra resource budget is invented. Bounded external polls and owned-job cleanup. + +## Baseline and ownership + +Initial integration base: `9e1468d4b7a41b498ed2aca98507ada2c741afea`. +The session remains in its managed worktree and adopts branch `codex/bug6-01a07e9d-roadmap` in place. Thirty pre-existing document files are fingerprinted in ignored scratch; they are excluded from every commit. Main owns Git operations, plan/FSM, CI decisions and merges. Source investigation lanes are disjoint Go/xAI, V2, and credit pairs. B-stage workers receive only the current audited phase scope, never speculative product-write authority. + +## Work-phase map + +| Cycle | Source | Deliverable | Delivery dependency | +| --- | --- | --- | --- | +| wp0 | This roadmap | Audited full file/behavior map and verification contracts | none | +| wp1 | PR #3838 | Remaining Go private-input compatibility and regression coverage | wp0 | +| wp2 | Issue #3907 | Strict xAI child-result continuation | wp1 normalization contract | +| wp3 | PR #3944 | Separate proxy V2 guidance from native policy | wp2, owner-requested cumulative chain | +| wp4 | PR #3951 | Server-owned proactive preset and dashboard semantics | wp3 policy vocabulary | +| wp5 | PR #3965 | Canonical reset operation alias settlement | wp4, owner-requested cumulative chain | +| wp6 | Issue #3973 | Account/scope/generation-bound post-reset recovery | wp5 operation identity | +| wp7 | All six | Exact-head CI, UI artifact observation, bottom-up landing and original dispositions | wp1–wp6 | + +The owner explicitly chose a single chain across otherwise independent domains. Each PR contains only its own layer. No empty product PR is created for already-landed work; its verified NOOP record remains in the chain. Revalidate each decade design at its P boundary. Preserve contributor commit identity/trailers on every carried implementation. + +## Verification and landing contract + +`ci.yml` accepts all pull-request bases (`pull_request: {}`); manual child PRs therefore receive product CI. Its gates job runs typecheck, dashboard tests, privacy scan and the relevant build. Windows/control coverage is dispatched explicitly. A green hygiene or enforce-target check is not product evidence, and skipped/cancelled jobs are not passing tests. Runtime validation stays hosted; local checks are NOT RUN by owner instruction. + +Before each merge refresh head/base, membership, reviews, required checks and actor. Authenticated actor `lidge-jun` has admin permission (live preflight); MAINTAINERS.md permits recorded maintainer integration into dev without self-approval, while retaining outstanding maintainer objections and security review. Merge bottom-up, retarget the next owned child to dev, preserve branches while referenced, and revalidate the resulting base/head. Do not alter source authors' branches. Close only fully resolved source items after landing. + +## Continuity ledger + +- wp0 P: live source intake and complete decade designs in progress; no product changes. +- wp0 A: independent Astra high reviewer returned PASS, zero blockers. Full source appendices remain in ignored scratch. An absent REST stack field means unknown membership, not proven absence; inspect the stacks endpoint before delivery. +- wp0 B/C handoff: all eight numbered roadmap documents are complete. Structural validation passed with 30 pre-existing user files preserved. Next cycle is wp1 Go residual implementation. Candidate cycles c1–c6 require their scoped audited delta and matching-head PR CI; c7 retains all six terminal dispositions and final integration proof. +- Remote documentation verification uses isolated `macmini-cf` scratch, not the deploy-docs workflow. Existing Node 24.20.0 is available under the remote user's nvm tree; select the repository-pinned Bun in that scratch environment and record actual versions. No live service or account state is touched. diff --git a/devlog/_plan/260908_bug6_manual_stack/010_go_compatibility.md b/devlog/_plan/260908_bug6_manual_stack/010_go_compatibility.md new file mode 100644 index 0000000000..8e82a03b8e --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/010_go_compatibility.md @@ -0,0 +1,40 @@ +# wp1: Go compatibility residuals + +Depends on wp0. C3 with independent boundary review. Source PR #3838 head `d84e5a80a5e40a65462a0466d82cdcec463a847e`; baseline dev `9e1468d4b7a41b498ed2aca98507ada2c741afea`. #3942 already landed the generic array agent-message normalizer. Reimplement the remaining Go behavior on current owners; do not restore the removed `opencode-go.ts` or duplicate namespace logic. + +## Main decisions + +Carry Go additional-tools placement and the canonical preset's stateless intent. Retain current all-parts readable/fail-closed agent-message behavior; the old lossy mixed-content hunk is deliberately declined because successful transport alone does not prove complete task content. Original PR disposition must name that decision rather than claim every historical hunk landed. Custom renamed providers retain explicit configuration semantics; no destination-based configuration migration is added. + +## File changes + +- NEW `src/adapters/opencode-go-additional-tools.ts`: export a small immutable placement helper taking body and base URL. Match HTTPS `opencode.ai`, standard port, exact `/zen/go/v1` (optional terminal slash); reject credentials/query/fragment and other paths. For valid `additional_tools` array wrappers append their already-normalized tools to top-level tools and remove the wrapper. Preserve unrelated input and supported nameless hosted tools. Non-array malformed wrappers remain unchanged; no valid wrappers returns the original body. Existing namespace/custom owners perform identity lowering and dedupe before this pass. +- MODIFY `src/adapters/openai-responses.ts`: import helper; invoke only inside non-forward dispatch after existing namespace/custom/search lowering around baseline line 2455, before code-mode/compaction and later hosted-tool pruning. Response alias maps stay owned by prior normalization. +- MODIFY `src/providers/registry.ts`: canonical `opencode-go` entry gains `statelessResponses: true`. Existing derive logic seeds/backfills only absent values; explicit false remains authoritative. +- MODIFY `tests/providers/opencode-go-grok46-responses.test.ts`: replace the old expected private wrapper with promoted tools; cover duplicate containers, distinct namespace same-name children, custom/function handling, hosted Luna search versus Go Grok denial, tool_choice none/allowed list, tool_search_output activation, forward/Zen/lookalike/wrong-port exclusion and immutable replay. +- MODIFY `tests/providers/opencode-go-luna-wire.test.ts`: cover seed/backfill/false, full-history continuation with synthetic reasoning and paired tool results; assert previous_response_id removed, store false, call pairing/history retained. Cover stateless orphan and reasoning-summary interactions through existing focused suites. No new test file is required if these current owners remain reviewable. +- MODIFY `docs-site/src/content/docs/reference/configuration/providers.md` and `structure/04_transports-and-sidecars.md`: record Go wrapper placement and canonical stateless default with explicit override and full-history limits. Update only contradicting translated statements. + +## Before / after flow + +Before: namespace normalization leaves valid declarations inside `input.additional_tools`; strict Go receives a private wrapper. After: the same normalized declarations appear in `tools`, and valid wrappers are removed. Before: canonical Go may forward previous_response_id with replay history. After: existing stateless normalization strips the stored-continuation parameters and sends complete history. + +## Activation and observable coverage + +Use production adapter fixtures, not a duplicate normalizer oracle. Namespace alpha.lookup and beta.lookup must both remain callable; duplicate wire identities follow the existing canonical owner. Nameless hosted tools must survive placement until provider/model pruning. Malformed wrapper, unapproved destination and forward controls stay byte-identical. Seed false must differ from default true. A two-turn synthetic continuation must preserve meaningful reasoning/tool history while removing stored-state references. Inspect existing stateless orphan repair and summary tests; extend any missing Go model coverage without weakening assertions. + +Hosted verification: PR CI covers changed runtime and provider suites, with final full dispatch before integration. Local product tests/install/typecheck/build are NOT RUN. Preserve original PR account-linked Co-authored-by credit; resolve identity from GitHub before commit. The full source investigation is in ignored `.tmp/bug6-01a07e9d/go-xai-plan.md`; it is not public implementation proof. + +## wp1 P refresh + +Previous wp0 D directs Go residual implementation. During live refresh dev advanced to c15662855 (#3975), changing only tests/codex-integration/codex-prompt-text-probe.test.ts. Hook-disabled merge incorporated that unrelated probe fixture correction before B; Go owners and this design are unchanged. The initial A narrative said unchanged dev based on the pre-fetch snapshot; this entry corrects it. + +## C audit foldback and repair plan + +Independent review at 9b42c1a80 found two blockers. F1 accepted: the stateless flag enables content-to-summary output normalization, but the continuation cache records original output; full-history overlap then fails. The adapter-only full-history fixture bypassed the affected server boundary. F2 accepted: baseUrl-only matching misses split/endpoint-inclusive configurations and can affect an overridden non-Go resource. Neither finding conflicts with preserving opaque items or existing fail-closed policy. + +Repair F1: MODIFY `src/server/responses/core.ts` at `rememberPassthroughResponseChecked` only. After current namespace/custom/function restoration, apply existing `rewriteReasoningSummaryInJson` under the same `hideThinkingSummary !== true && routeUsesContentChannelReasoning(provider, model)` condition as client output, then record that representation. Preserve item content and IDs under the existing opaque-item rule; do not weaken overlap comparison or use ID-only matching. This aligns stored output with the actual client serialization for SSE and JSON. Extend the current Go server fixture to send actual full-history plus previous_response_id and assert each prior call/message occurs exactly once; retain delta replay and hiding/opaque controls. The shared callback is an explicit narrow scope expansion required by this newly activated path, not unrelated state refactoring. + +Repair F2: the helper now accepts the final resolved Responses request URL already built by the adapter. Match exact origin and `/zen/go/v1/responses`, rejecting userinfo/query/fragment. Positive fixtures cover normal base, endpoint-inclusive base and split custom path; negative fixtures cover an override resolving to Zen/non-Go and assert both actual request URL and body. Update destination wording in docs and preserve all prior host/port/immutability controls. + +Re-review the repaired diff with the same implementation auditor; retain CI failures and repair evidence. No local product commands are authorized. diff --git a/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md b/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md new file mode 100644 index 0000000000..f7571bca27 --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md @@ -0,0 +1,23 @@ +# wp2: xAI string child-result continuation + +Depends on wp1 current outbound placement and full-history regression controls. C3. Issue #3907 posts string `agent_message.content`; #3942 already implements arrays for all non-forward destinations. Scope is the string residual only. + +## File changes + +- MODIFY `src/adapters/routed-agent-messages.ts`: extend `normalizeRoutedAgentMessages` with optional `{ allowStringContent?: boolean }`, default false. If enabled and content is a nonblank string, create one input_text part containing the exact original string. Existing attribution and array handling continue. Whitespace-only, unknown, malformed and ciphertext shapes remain unchanged; never trim the forwarded text or mutate the input. +- MODIFY `src/adapters/openai-responses.ts`: reuse `isXaiResponsesDestination` from `src/providers/xai-transport.ts`, pass its result as allowStringContent inside the existing `!forward` call. Existing array behavior stays available for other non-forward destinations. No custom-forward exception. +- MODIFY `tests/adapters/routed-agent-messages.test.ts`: exercise both exact xAI hosts, API-key and OAuth, exact text/newlines, attribution, missing transport item ID and input immutability. String controls: native/custom forward, other providers, lookalike hosts and blank content stay unchanged; existing all-parts array/ciphertext tests remain. +- MODIFY `tests/server/server-xai-responses-streaming.test.ts`: extend the synthetic server fixture with parent request, child request, then parent continuation containing string child result plus genuine paired tool history. Upstream stub rejects surviving private agent_message with 422; assert user-message child text, ordinary response completion, paired calls preserved and no repeated incompatible dispatch. This exercises the wire boundary, not the actual Codex scheduler. +- MODIFY `docs-site/src/content/docs/reference/adapters.md`, `docs-site/src/content/docs/reference/configuration/providers.md`, and the contradicting Russian adapters paragraph: describe existing non-forward array conversion and xAI string extension, preserving forward/encrypted exclusions. Sync `structure/04_transports-and-sidecars.md` without broadening the passive manifest claims. + +## Before / after + +Before the raw-body outbound normalizer requires array content and leaves the issue's string item on the strict xAI wire. After it produces `{type: message, role: user, content: [{type: input_text, text: originalText}]}` through the existing attribution rules, only for an approved non-forward xAI destination. No tool result is synthesized and no encrypted message is partially discarded. + +## Verification + +Pin parent/child fixtures to synthetic input. The strict upstream stub must reject the pre-fix request shape and accept the normalized one; destination-negative controls prove the guard is active. Hosted PR CI and final full dispatch execute adapter/server regressions. Local tests/install/typecheck/build remain NOT RUN. Source audit checks raw-body call placement and all consumers of the added option. There is no serialized configuration field or migration: option creation and consumption are both in-memory adapter calls. + +## wp2 P refresh + +Previous wp1 D: PR3986 at d1f61e933 passed run34178540141 and independent source/security audit, with18Go replay scenarios and remote docs425pages. Proceed to xAI string residual. Candidate339e42c1e was prepared in an isolated worktree under the owner-authorized parallel-preparation amendment; it is not yet adopted. Its base exactly equals the certified preceding layer, and the eight-file diff matches this plan. Existing xAI predicate remains the destination owner; no account changes. Issue3907 is still open. Main revalidates candidate before B and retains fresh hosted CI before wp2 closure. diff --git a/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md b/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md new file mode 100644 index 0000000000..2e5219c1a4 --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md @@ -0,0 +1,49 @@ +# wp3: V2 guidance carry + +Depends on wp2 in the owner-requested manual chain. Carry PR #3944 at 6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a with both original commits and contributor trailer. Local product verification is NOT RUN. + + separate proxy routing metadata from native policy + +Source: [pinned collaboration implementation](https://github.com/lidge-jun/opencodex/blob/6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a/src/server/responses/collaboration.ts#L244), [pinned regression changes](https://github.com/lidge-jun/opencodex/blob/6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a/tests/codex-integration/multi-agent-compat.test.ts#L1188). C3 source change, with C4 care for developer-instruction and public-contract semantics; this lane does not run orchestration. + +### Concrete before -> after hunks + +| Exact path / baseline anchor | Before | After to carry | +| --- | --- | --- | +| `src/server/responses/collaboration.ts:242` | Only the native proactive constant exists. | Add private `OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG` / `CLOSE_TAG` constants after it. Leave `PROACTIVE_MULTI_AGENT_MODE_TEXT` unchanged. | +| `src/server/responses/collaboration.ts:466-490` | Custom and built-in v2 bodies use native tags; built-in prescribes overrides, `fork_turns`, and preferred-model use. | Wrap both v2 paths in the proxy tag; replace only built-in preamble with routing-metadata wording; preferred clause ends with a period. Preserve placeholder substitution, effective preferred model, account namespace filtering, roster/fallback text, stale/unknown suppression and roster-drop budget. | +| `src/server/responses/collaboration.ts:543-545` | Exact generated-item predicate only. | Add `generatedGuidanceFamily(text)` recognizing exactly the two outer tag families. This is a dedup classification, not an authorship assertion. | +| `src/server/responses/collaboration.ts:583-594` | Latest-match handling only for native tag; other text dedups against any earlier exact match. | For either known family, compare incoming text to the latest exact generated developer text within that family in the replay prefix. For untagged text retain existing exact-item behavior. | +| `src/types/config.ts:472-475,515-516` | Effort JSDoc prescribes spawn overrides; custom-body wrapper is native tag. | Describe effort as advisory v2 metadata; update wrapper name. Preserve type declarations, injectionModel dependency and reasoning-level validation documentation. | +| `tests/codex-integration/multi-agent-compat.test.ts:270,387,615,741,781,847,1185` | Old v2 wrapper and imperative expectations; native-family A-B-A only. | Carry all changed expectations and new proxy/native replay matrices from the head; preserve existing v1, catalog, placement, sanitization and shape-negative tests. | +| `docs-site/src/content/docs/reference/configuration/agents.md:77-93` | Excludes v1 leaf workers and describes model/fork overrides. | Correct already-shipped leaf eligibility; describe proxy wrapper, advisory metadata, preserved custom body and per-family latest-text dedup. State mixed-version limitations. | +| `docs-site/src/content/docs/ja/reference/configuration/agents.md:31-37` | Old roster/guidance contract. | Carry corresponding roster correction and proxy/native/replay qualification. | +| `docs-site/src/content/docs/ko/reference/configuration/agents.md:31-37` | Same old contract. | Same localized contract. | +| `docs-site/src/content/docs/ru/reference/configuration/agents.md:38-57` | Same old contract, longer prose. | Carry full localized explanation including historical-message limitation. | +| `docs-site/src/content/docs/zh-cn/reference/configuration/agents.md:31-37` | Same old contract. | Same localized contract. | + +Do not mass-replace ``: `collaboration.ts:493-497` remains the v1 max/ultra parity path, and native/legacy history must remain byte-identical. Do not add a settings migration or infer the author of an old tagged message. No revocation of already injected instructions is claimed. + +### Data creation and consumers + +Existing config fields, not new fields: `/api/injection-model` maps `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled` to `OcxConfig.injectionModel`, `injectionEffort`, `injectionPrompt`, `multiAgentGuidanceEnabled` (`agent-settings-routes.ts:501-508,529-589`). `saveConfigPreservingClaudeCode` persists the existing JSON config. No changes to that input/storage contract are needed. + +`src/server/responses/core.ts:2421-2429` passes those fields, route account namespace, featured models and fallback to `multiAgentGuidanceText`. `collaboration.ts:366-405` retains feature/tool/catalog admission; `:409-464` retains request-scoped roster and preferred/fallback derivation. Only the final rendered text changes. `applyInjectionPlaceholders` at `:504-509` still substitutes the four placeholders and preserves unknown placeholders. + +`core.ts:2430-2433` calls `injectDeveloperMessage`. `collaboration.ts:583` creates raw `{type:"message",role:"developer",content:[{type:"input_text",text}]}`; `:597-613` creates the parsed `{role:"developer",content:text,timestamp}` and places it; `:616-621` splices raw input at the matching conversation position. Both representations must retain the same ordered text across stateful replay. `src/server/responses.ts:6` reexports the same public helpers unchanged. `src/codex/subagent-model-fallback.ts:688-692` continues owning fallback prose. Native default sync is an independent consumer per `structure/03_catalog-and-subagents.md:459-465`; do not merge it with guidance. + +### Regression activation + +Carry the existing hermetic `CODEX_HOME` / catalog fixture builders (`multi-agent-compat.test.ts:23-103`), including the fresh catalog override and its cleanup. Test the actual helpers through `parseRequest`, raw input, `_replayPrefixLen`, and `_continuationConversationMessageIndex`. + +1. V2 built-in has proxy tags and model/effort/roster/fallback metadata, and lacks native tag, `fork_turns`, and preferred-model imperative. V1 max/ultra retains native tag and below-top silence. Disabled guidance and stale/unknown catalog produce null. +2. Built-in A -> custom B -> built-in A appends last A; unknown placeholders/newlines in custom B remain unchanged. +3. Native A -> proxy P -> native B -> same proxy P adds nothing; native text dedup ignores later proxy P. +4. Old built-in/native-tagged custom text + native policy + new proxy text preserves the original prefix and appends new proxy metadata. Never assert historical authorship or automatic cleanup. +5. Keep exact-shape negatives, 700-character fixture, external-task input, leading tool-result, compaction marker and raw/parsed alignment cases. +6. Small additional hunk recommended in `tests/server/server-combo-failover-e2e.test.ts:2285` beside the existing generated-guidance replay case: configure a synthetic v2 tool/catalog route, change built-in -> custom -> built-in across actual response continuations, capture adapter input, assert latest proxy guidance and native policy survive once and precede the current task. Use the existing server harness. Preserve current-dev compaction/recall fixtures in this file. This activates the caller/replay integration rather than only manually assigning parsed indices. + +Remote-only focused activation: `bun test tests/codex-integration/multi-agent-compat.test.ts`; additionally `bun test tests/server/server-combo-failover-e2e.test.ts` if adding the caller fixture. **NOT RUN here.** Negative controls for the remote verification owner: reverting the custom wrapper, collapsing the two families, or changing latest-family comparison to any-ever comparison must fail the corresponding transition tests. No local mutation/control execution. + + +Main decision: preserve the complete original diff. The optional extra server caller fixture is deferred unless source audit reveals an untested change; do not duplicate the existing replay matrix merely for volume. Sync structure/03_catalog-and-subagents.md to the new tag and policy boundary. diff --git a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md new file mode 100644 index 0000000000..d12ead3ba9 --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md @@ -0,0 +1,76 @@ +# wp4: server-owned proactive preset carry + +Depends on wp3. Carry PR #3951 at db502d486c8d8de80c0143fcfd6f86238d1ce917 with its contributor trailer. All local product commands are NOT RUN. + + make the server own proactive preset recommendations + +Source: [pinned policy module](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/src/codex/multi-agent-mode-policy.ts#L1), [pinned API additions](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/src/server/management/agent-settings-routes.ts#L252), [pinned GUI source binding](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/gui/src/pages/Subagents.tsx#L48). C3 API/GUI change, C4 care for persisted developer-policy text and management boundary. + +### Concrete before -> after hunks + +| Exact path / baseline anchor | Before | After to carry | +| --- | --- | --- | +| `src/codex/multi-agent-mode-policy.ts` (new, head lines 1-24) | Browser owns the preset; no recommendation owner. | Create dependency-free constant `{revision:"proactive-trigger-v1",text:[six clauses].join(" ")}`, two exact released legacy strings, and exact-equality canonicalizer. The recommendation changes the delegation trigger while preserving user/authority/scope/tool rules. | +| `src/codex/features.ts:39,1077-1094` | Non-null writer probes support then forwards original text. | Import canonicalizer; after existing capability probe set `canonicalValue = value === null ? null : canonicalizeOpenCodexModeHint(value)`; pass only that value to existing `setV2StringField`. No read-time/startup migration. | +| `src/server/management/agent-settings-routes.ts:43,250,423` | GET/PUT return stored hint only. | Import recommendation constant and append `multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION` to both successful serializers. Do not add it to accepted PUT fields or config storage. | +| `gui/src/pages/use-subagent-delegation.ts:22-31` | State has stored hint but no recommendation. | Export `UltraModeHintRecommendation {text:string;revision:string}`; require `recommendation: UltraModeHintRecommendation \| null` in `UltraModeState`. Keep patch type as hint text/null and mode. | +| `gui/src/pages/Subagents.tsx:11,43,60-79` | Mode state outlives apiBase; no recommendation validation. | Add `UNLOADED_ULTRA_MODE` including null recommendation; hold `{apiBase,mode}`; derive current/unloaded state by exact apiBase. Validate nonblank string text AND revision, retaining their exact bytes; malformed/missing recommendation becomes null. Preserve abort/generation/current-server guards. | +| `gui/src/pages/Subagents.tsx:97-118,343` | Save checks busy only; forwarded busy does not describe hydration. | Refuse save without current-server mode or matching current apiBase; forward `ultraSaving || !ultraModeCurrent`. Keep PUT then GET refresh and stale-server outcome suppression. | +| `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:299-328` | Enable/Restore use local constant. | Enable sends current server recommendation.text; disable still sends null. Disable install if forced-v2 eligibility or recommendation is absent; existing nonblank hint remains clearable. Pass nullable server preset to editor. Keep editor key based on stored hint, not recommendation revision. | +| `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:341-391` | Editor requires string preset; exported browser preset remains. | Nullable preset; Restore changes draft only and is disabled without preset; Save preserves nonblank draft bytes. Remove local `ULTRA_MODE_PRESET` export. | +| `gui/tests/multi-agent-guidance.test.tsx:68,153` | Fixtures lack new field. | Add null recommendation and recommendation-refresh/unsaved-draft regression. | +| `gui/tests/subagents-ultra-mode.test.tsx:1-178` | Imports local preset and asserts duplicated text. | Remove preset import; lazy-import createRoot after DOM setup; use distinctive server recommendation fixture; carry API/malformed/edit/restore/save/server-switch cases. | +| `tests/codex-integration/codex-v2-gate.test.ts:47,426,1342` | Existing TOML and API contracts only. | Import recommendation; independently pin both released strings; add writer normalization, custom preservation, GET-no-write/unrelated-PUT preservation, readback and idempotence cases. | + +All nine locale modules retain keys and change exactly these five values: `sub.ultraMode`, `sub.ultraModeText`, `sub.ultraModeLoadFail`, `sub.ultraModeSaveFail`, `sub.ultraModeSaved`. Exact baseline anchors: `gui/src/i18n/en.ts:712`, `de.ts:683`, `fr.ts:695`, `ja.ts:643`, `ko.ts:700`, `ru.ts:698`, `tr.ts:705`, `zh-TW.ts:550`, `zh.ts:693`. English label becomes “Always proactive delegation”; no hardcoded JSX copy or locale-key rename. + +Docs: merge the wp4 proactive-preset section into `docs-site/src/content/docs/reference/configuration/agents.md:48-60`, and add the equivalent section to `ja/`, `ko/`, `ru/`, `zh-cn/` versions of that same path. Preserve every wp3 roster/proxy-wrapper/replay paragraph. These five files are the only shared files between the original PRs; carry their hunks, not whole-file replacement from #3951. The combined original carry touches 26 distinct files. + +Recommended small SOT followups, main to include in its phase write allowlist: at `structure/03_catalog-and-subagents.md:459` record the separate native-hint versus proxy-guidance owners; at `structure/05_gui-and-management-api.md:129` add the response-only recommendation and explicit-save-only canonicalization contract. No broader restructuring. + +### Complete field lifecycle / consumer audit + +1. **Creation:** policy module owns constant `text` and `revision`. Revision labels server guidance; the GUI validates a nonblank string, does not whitelist revisions, does not infer installed native capability from it. +2. **Serialization:** GET at route `:234-254` and successful PUT at `:413-427` add the same nested object. `src/server/auth-cors.ts:259-263` JSON.stringify serializes it with the existing JSON content type. No new envelope, endpoint, auth path or DTO needed. +3. **GUI hydration:** `Subagents.loadUltraMode` GET reads the object, retains both fields only if valid, and stores it with exact apiBase. `SubagentsWorkspace.tsx:47-60` forwards typed state; it needs no runtime logic change. The recommendation is not written to session-list cache. +4. **GUI interaction:** `ultraOn` remains derived from nonblank stored hint (`SubagentDelegationSection.tsx:57-60`). Enable uses recommendation.text, Clear sends null, Restore only updates local draft, Save sends draft. Recommendation-only refresh must not remount the editor; stored-hint changes still do. While a new API source is pending, the previous source's editor/hint disappears and all mode writes are disabled. +5. **Write input:** `/api/v2` accepts only the existing `multiAgentModeHintText?: string|null` for this behavior. Blank/non-string validation at route `:308-310` and unsupported-runtime preflight at `:335-339` remain before mutations. The recommendation object and revision are never sent back as configuration. The GUI PUT body at `Subagents.tsx:103-107` is JSON.stringify(patch). +6. **Persistence:** route `:390` calls `setMultiAgentModeHintText`. Its capability check remains first. Exact legacy strings become recommendation.text; arbitrary custom text, including one-space variants, stays byte-identical; null stays null. `features.ts:951-1061` owns dedicated/inline/boolean TOML forms, sibling/comment/EOL preservation and atomic writes; `:485` owns string escaping. Persist only `features.multi_agent_v2.multi_agent_mode_hint_text`. Never persist text/revision as extra TOML keys: native config rejects unknown members. +7. **Readback:** `getMultiAgentModeHintText` at `features.ts:935-937` uses `getV2StringField` at `:857`; no canonicalization on read. The successful PUT re-reads stored hint and independently returns recommendation. CLI `src/cli/v2.ts:138,151,167` reads or invokes the same writer, so CLI explicit writes also gain exact-legacy normalization without a CLI implementation edit. +8. **Other consumers:** `gui/src/pages/Models.tsx:476-486,1116-1127,1244-1255` deliberately projects flag/mode/thread/hybrid fields and ignores recommendation; `dashboard-core-poll.ts:233-244` projects mode only; `use-dashboard-data.ts:609-615` sends mode only. These clients need no new required field. Existing synthetic missing-recommendation responses remain useful old-server fixtures. `ULTRA_MODE_PRESET` has only the local component and `subagents-ultra-mode.test.tsx` consumers found in the repository. +9. **Native consumer boundary:** the stored hint is for native Codex sessions; OpenCodex's v2 request guidance does not read this field. Keep the v1 proactive constant and new recommendation as distinct owners rather than replacing one with the other. No capability claim or live native delegation behavior was tested here. + +### Regression activation + +Carry backend fixtures at pinned #3951 `codex-v2-gate.test.ts:52-57,434-446,1382-1412`. Independently pinned release strings must remain in tests; do not import the production legacy array, which would make deleting a compatibility entry delete its oracle too. + +Backend cases: each legacy input canonicalizes only on an explicit write; GET preserves exact TOML bytes; unrelated PUT preserves stored legacy; successful PUT returns canonical stored hint plus unchanged recommendation; re-save is changed:false; adjacent comments and concurrency limit survive; null clears; whitespace variants/custom text survive; unsupported binary leaves a combined request unchanged; existing inline, dedicated, boolean, CRLF, quoting and multiline-refusal tests remain active. + +Small additional hunks recommended in the same registered file: + +- Beside baseline API tests at `:1360`: PUT a custom string with leading/trailing spaces, newline, quotes and backslash; assert exact response hint and decoded TOML equality, same recommendation, and no persisted recommendation/revision keys. Assert a recommendation-only body is rejected without changing files, and recommendation extras beside a valid hint never replace the server constant. +- Beside CLI test at `:1626`: `cmdV2(["mode-hint", legacy])` for both released values; read/status returns canonical text; re-save no change; custom one-space variant preserved; `--clear` removes only hint. Use `fixtureConfig`, temporary CODEX_HOME and `installModeHintRuntime(true)` with existing cleanup. + +GUI cases, pinned [subagents-ultra-mode.test.tsx:126-318](https://github.com/lidge-jun/opencodex/blob/db502d486c8d8de80c0143fcfd6f86238d1ce917/gui/tests/subagents-ultra-mode.test.tsx#L126): distinctive server text in exact PUT; missing/bad fields block install; existing custom text remains editable/clearable; Restore sends no PUT; Save sends one; A -> pending B hides A and blocks writes; B missing/malformed/valid selects correct behavior; late A response cannot overwrite B. Preserve recommendation-only refresh draft test in `multi-agent-guidance.test.tsx:154-182`. Extend malformed matrix with null, empty revision, omitted text/revision, and non-object values; no local preset fallback. + +Remote-only focused activation: `bun test tests/codex-integration/codex-v2-gate.test.ts`; from gui, `bun test tests/subagents-ultra-mode.test.tsx tests/multi-agent-guidance.test.tsx`. **NOT RUN here.** The existing test-layout entries are already present at `scripts/test-layout/layout.json:488,825` and `tests/fixtures/test-layout-expected.json:323,660`; additions to these existing files need no new registration. GUI test discovery already covers `gui/tests`. Main's full hosted gates remain authoritative. + +## UI proof: hosted bundle, synthetic API, existing browser (main-owned) + +The author's missing screenshot is **not a blocker**. Use the current workflow's existing artifact: [ci.yml at baseline](https://github.com/lidge-jun/opencodex/blob/9e1468d4b7a41b498ed2aca98507ada2c741afea/.github/workflows/ci.yml#L451). The `gates` job builds GUI when GUI changes, writes `build-commit.txt` / `build-gui-tree.txt`, and uploads `dashboard-preview-${github.sha}` (`:451-470`). No workflow edits are required for this source lane. + +Main's execution recipe: + +1. Select the hosted artifact covering the cumulative carry candidate. Verify run/event/head, archive artifact ID, build-commit, and build-gui-tree against the actual CI checkout. A pull_request merge SHA may differ from branch head; record that and prove the GUI tree matches the carried candidate. Do not relabel an old artifact as current. +2. Unpack into isolated scratch; serve prebuilt JS/CSS/assets unchanged using a small static/synthetic HTTP server. No Bun/Vite/TypeScript build or product server locally. Fixture state lives in memory and receives only synthetic local requests. +3. Serve the actual SPA at `/#subagents` (`gui/src/App.tsx:431`) and open Advanced (`SubagentDelegationSection.tsx:235-236`). Supply synthetic bootstrap metadata in served HTML: token `ocx_session_fixture`, CSRF `fixture-csrf`, browser/server origins equal the scratch origin. `gui/src/api.ts:90-105,139-149` requires these fields. This modifies only served bootstrap HTML, not bundled implementation. Implement the fixture bootstrap path if requested; never connect it to the user's daemon or credentials. +4. Minimal page fixtures: GET `/api/subagent-models` -> `{available:[],chosen:[]}`; GET `/api/subagent-model-fallback` -> `{available:[],models:[],pollMs:60000}`; GET `/api/injection-model` -> `{multiAgentGuidanceEnabled:true,syncCodexSubagentDefaults:false,model:null,effort:null,efforts:[],available:[]}`. Give other app-shell reads safe synthetic responses with appropriate content types; record requested routes rather than silently forwarding unknown routes. Nonfixture writes must fail closed. +5. GET `/api/v2` baseline -> `{enabled:true,multiAgentMode:"v2",keepNativeChatGptOnV1:false,multiAgentModeHintText:null,multiAgentModeHintRecommendation:{text:,revision:"proactive-trigger-v1"}}`. PUT validates/captures the JSON patch, changes only fixture hint/mode, and returns fresh full state plus `ok:true`. Subsequent GET echoes that state. Use the exact candidate text for screenshots, and a second distinctive text/revision for source-of-preset behavioral proof. +6. Exercise: enable -> expected text PUT and editor; edit custom -> no PUT until Save; Restore -> draft changes without PUT; Save -> exact text PUT; Clear -> null; missing and malformed recommendation with/without existing hint; disabled v2; initial GET 503 then retry; PUT 502 -> error without false saved state. Keep request log assertions next to screenshots. +7. Server switching: the actual App keys Subagents by sharedBase (`App.tsx:431`), while unit tests also cover in-place prop updates. Use two synthetic fixture sources through the existing connection UI if supported by the host; otherwise retain hosted component-test proof for the delayed A/B update and explicitly label that branch browser-NOT-RUN. Do not rebuild a special component harness locally or fake a source switch by altering product code. +8. Capture the changed component at 1440, 1024, 768, 390 and dense 320 px widths; EN and KO long labels, light/dark and visible keyboard focus. Check textarea readability, label/button clipping and horizontal overflow. Save representative off/on/custom/old-server/error screenshots and viewport/state/request-log manifest. No need for decorative assets. + +Suggested main-owned scratch outputs: `ui-v2/source-manifest.json`, `ui-v2/requests.jsonl`, `ui-v2/en-desktop-on.png`, `ui-v2/ko-mobile-custom.png`, `ui-v2/old-server.png`, `ui-v2/error-retry.png`. This lane has created none of them. Screenshots prove the carried GUI rendering and fixture interaction; they do not prove real native delegation or TOML persistence. Backend hosted tests supply persistence evidence. Main can attach its observed images to the carrying PR; it need not wait for the author's images. + + +Main decision: preserve original 23-file diff and all regressions. Suggested extra CLI/API test cases remain a targeted audit decision rather than mandatory scope expansion. Sync structure/03_catalog-and-subagents.md and structure/05_gui-and-management-api.md with the response-only recommendation and explicit-write behavior. Render QA uses the existing built artifact. diff --git a/devlog/_plan/260908_bug6_manual_stack/050_credit_alias.md b/devlog/_plan/260908_bug6_manual_stack/050_credit_alias.md new file mode 100644 index 0000000000..bca638a08b --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/050_credit_alias.md @@ -0,0 +1,17 @@ +# wp5: canonical reset-credit operation identity + +Depends on wp4 for the owner-requested delivery chain. C4; no live credit consumption. Source PR #3965 at `6c1477d19c7d1a77a1866cabfd2b4411f1a210d7` carries #3919 by luvs01. Revalidate both source heads and current dev before implementation; do not rewrite their branches. + +## Published patch to carry + +- MODIFY `src/codex/auth-api.ts` at the reset consume handler: `const identity` becomes `let identity`; after execute admission assign `identity = { ...identity, operationId: opened.operationId };`. Upstream dispatch and both durable settlement paths then share the canonical operation ID. Authentication, admission failures, account binding and terminal replay stay before this assignment. +- MODIFY `tests/codex-integration/codex-auth-api.test.ts`: import the existing ledger opener, assert a settled alias replay consumes no additional credit, and construct truly pending canonical operations for thrown fetch, non-2xx and unknown-code alias failures. Assert the durable row becomes ambiguous while account key and canonical ID remain unchanged and terminal code remains null. +- MODIFY `docs-site/src/content/docs/reference/management-api.md`: carry the source paragraph distinguishing unfinished alias joins, known terminal replay and new explicit intent after settlement. + +Retain `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>` and original source commit references. Do not carry auto-redeem worker changes: #3970 is already on baseline. + +## Verification + +The public three-file patch is the diff authority: https://github.com/lidge-jun/opencodex/pull/3965/files . Each negative fixture begins pending, so it observes the changed failure-settlement path instead of rechecking an already ambiguous row. Existing no-operationId and ordinary terminal paths remain regression controls. Hosted CI runs the auth and ledger suites; local tests/typecheck/build/install are NOT RUN by owner instruction. A source/security reviewer verifies the exact carried head before merge. Existing source-PR CI failure is historical and must not be described as passing. + +All additional unpublished security analysis lives in ignored `.tmp/bug6-01a07e9d/credit-plan.md` and later audit artifacts. It must not be copied into this public unit. diff --git a/devlog/_plan/260908_bug6_manual_stack/060_credit_recovery.md b/devlog/_plan/260908_bug6_manual_stack/060_credit_recovery.md new file mode 100644 index 0000000000..9f7f1cfd23 --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/060_credit_recovery.md @@ -0,0 +1,19 @@ +# wp6: manual reset recovery + +Depends on wp5 canonical operation identity. C4. Implements the user-visible contract in public issue https://github.com/lidge-jun/opencodex/issues/3973 . No real account actions or credit consumption are authorized by this development task. + +## File map and private implementation appendix + +- MODIFY `src/codex/auth-api.ts`: connect the authenticated manual operation with the existing quota-observation and routing-recovery ownership contracts. +- MODIFY `src/codex/routing.ts`: reuse narrowly targeted recovery ownership rather than broad account-health clearing. +- MODIFY `tests/codex-integration/codex-auth-api.test.ts` and `tests/codex-integration/codex-cooldown-recovery.test.ts`: mocked endpoint and ownership-race regressions using existing fixture conventions. +- MODIFY `docs-site/src/content/docs/reference/management-api.md` and `structure/08_openai-provider-tiers.md`: document the resulting supported contract when the patch is public, without account examples or internal proof material. +- NO CHANGE to persisted ledger schemas, auto-redemption policy, selected-account policy, GUI, or real credentials. + +The complete before/after design, exact current source anchors, threat model, reachable activation cases and observable negative assertions are recorded in ignored `.tmp/bug6-01a07e9d/credit-plan.md`, section "Layer 2", against baseline `9e1468d4b7a41b498ed2aca98507ada2c741afea`. This is a mandatory implementation appendix, not deferred planning. Repository AGENTS.md requires unpublished security working notes to stay in scratch, overriding public devlog placement. Both the A reviewer and B worker must read the appendix; loss of the appendix requires reconstructing and auditing it before B. + +## Acceptance and verification + +Only the matching account's eligible pre-existing cooldown may be recovered after confirmed reset and fresh supporting evidence. Ordinary successful requests, uncertain results and replay do not gain broader recovery authority. Existing unrelated scopes and caller selections remain intact. The private appendix enumerates the full mocked positive/negative matrix and claim cleanup requirements. + +Run no local product commands. Hosted CI must execute the affected auth, cooldown, quota and provenance suites; independent security review remains required. PR #3848 overlaps the flight interface: refresh before B and integrate any landed change without absorbing its unrelated registration behavior. New code belongs to this owned stack; do not modify other open PRs. Record privacy-safe outcome evidence here only after publication. diff --git a/devlog/_plan/260908_bug6_manual_stack/070_integration.md b/devlog/_plan/260908_bug6_manual_stack/070_integration.md new file mode 100644 index 0000000000..2b546b12cd --- /dev/null +++ b/devlog/_plan/260908_bug6_manual_stack/070_integration.md @@ -0,0 +1,35 @@ +# wp7: hosted verification and integration + +Depends on wp1–wp6. The owner explicitly requested a single manual branch chain. This cycle changes only its delivery records and evidence; a discovered product defect is assigned an audited repair cycle before integration continues. + +## File changes + +- MODIFY this unit's `000_plan.md`: replace in-progress outcomes with exact source commit, PR, run IDs, tested heads and terminal dispositions; record failed/skipped checks separately. +- NEW `071_delivery.md`: six-row original-to-carried PR mapping, attribution, pinned GitHub evidence, active branch/base topology and merge result per layer. Store no account identifiers or private payloads. +- NEW `072_final_proof.md`: fetched dev SHA; per-layer ancestry command results; final candidate tree and landed tree comparison; unchanged pre-existing-file fingerprint verification. If a merge commit contains concurrent changes, isolate and explain each difference rather than claiming whole-tree equality. +- MOVE this completed unit to `devlog/_fin/260908_bug6_manual_stack/` only when all outcomes are terminal. Evidence generated before moving records both paths. Do not move other units. +- GUI screenshot files, if needed, use the existing `.github/pr-assets/` convention after verifying the generated image contains synthetic settings only. + +## Exact delivery actions + +1. For each nonempty candidate use a new owned `codex/bug6-01a07e9d-*` branch. Bottom base is dev; each upper base is the prior owned branch. Preserve author trailers and satisfy every section of `.github/PULL_REQUEST_TEMPLATE.md`. +2. Commit with `git -c core.hooksPath=/dev/null commit`; push with `git -c core.hooksPath=/dev/null push --no-verify`. No install, test, typecheck or build hook runs locally. +3. Read each PR's current head/base and native `stack` field. A native membership conflict is inspected without mutating membership. Our newly created ordinary PRs must remain manual. +4. Inspect `gh pr checks` and matching workflow runs. Before landing obtain final candidate `ci.yml` `workflow_dispatch` with `lane=all` as well as required PR checks. Bind conclusions to `head_sha`, event and run attempt. Retry failed jobs only after investigating the actual failure and ensuring it does not hide a product regression. +5. For the preset UI, download the hosted `dashboard-preview-*` artifact from the verified head. Verify `build-commit.txt` and `build-gui-tree.txt`; serve the prebuilt bundle with synthetic API fixtures on a disposable loopback port; observe preset activation/restoration and server-switch behavior in a browser; capture/read the screenshot. No local product compilation. Existing browser driver only, no installation. +6. Refresh MAINTAINERS.md, live actor permission, reviewer objections and security evidence. Record maintainer integration in the owned PR body. Land only the bottom PR with `--match-head-commit`; retarget the next child to dev and verify exact resulting integration head/CI. Never merge an upper PR into its parent branch as if that landed it in dev. +7. Fetch dev after each merge and prove the merged commit is an ancestor. At final integration compare actual trees against the final certified candidate, including any explicitly reviewed concurrent dev changes. +8. Refresh each original item and mark closed only if its entire user-visible bug is resolved by the landed tree. Preserve unresolved residuals as open; report the exact residual rather than treating overlap as duplication. + +## Activation and observation + +- Failed/queued/cancelled hosted job: inspect actual run/head; no merge until required evidence is successful. +- Base advances: recompute integration tree and obtain fresh evidence; old SHA checks are historical. +- A maintainer objection remains: resolve its concrete finding or obtain withdrawal before merge. +- A source PR lands concurrently: verify its actual delta and remaining contract; use an evidence-backed NOOP rather than reapplying it. +- UI stale-server or malformed-recommendation fixture: preset install disabled; custom edit/clear retained; no cross-server write. +- Final condition: all six source contracts mapped to landed results; no destructive changes to user files, service state or credentials. + +## Validation limits + +Local product tests, installs, typechecks and builds: NOT RUN by owner instruction. Hosted tests and independent source audits provide product evidence; docs-only filesystem/link/whitespace checks provide document evidence. Neither substitutes for the other. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index bc3bab2c82..596fc2255f 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -133,14 +133,17 @@ collision-safe public function tool. Matching request history and JSON/SSE funct translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. -Requests with `authMode` other than `"forward"` convert plaintext Codex `agent_message` -items into public user messages, preserving content parts and readable author/recipient +Requests with `authMode` other than `"forward"` convert Codex `agent_message` +items containing nonempty arrays of supported plaintext parts into public user messages, preserving those parts and readable author/recipient metadata. `agent_message` is private to the ChatGPT Codex backend, and the routed destinations reported so far reject the entire body with `422 unknown item type "agent_message"` — and because Codex replays sub-agent history on every turn, that failure repeats for the rest of the thread. This conversion leaves encrypted or unknown content unchanged. Providers using `authMode: "forward"` retain -these items unchanged. +these items unchanged. For xAI Responses on HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +using the standard port, a nonblank string child result is also converted into an `input_text` +part with its exact whitespace and newlines. Other destinations retain string-valued items; +blank strings and mixed encrypted/unknown parts are not partially converted. See [agent messages](/reference/configuration/providers/#routed-agent-messages) for the separate opt-in encrypted-task recovery behavior. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8f55d6b51f..f32a2fdc09 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -921,6 +921,32 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 } ``` +## OpenCode Go Responses compatibility + +On non-forward requests whose resolved endpoint is `https://opencode.ai/zen/go/v1/responses`, OpenCodex moves +Codex's `additional_tools` input declarations into top-level `tools` after tool and namespace +normalization. Supported hosted tools are preserved until model-specific filtering; malformed +wrappers remain unchanged. This does not discard ciphertext or unknown agent-message content. +The check uses the final URL, so endpoint-inclusive base URLs and split `baseUrl`/`responsesPath` +configurations receive the same behavior. A custom path resolving elsewhere does not. + +The canonical `opencode-go` preset defaults to `statelessResponses: true`: requests use explicit +history with `store: false`, without `previous_response_id`, `conversation`, `background`, +`metadata`, or stored `prompt` references. This avoids Go's rejection of reasoning ciphertext +combined with `previous_response_id`. The continuation cache records reasoning in the same +representation returned to the client, including the visible content-to-summary rewrite, so +echoing full history with `previous_response_id` does not duplicate that history. Hidden-summary +requests and opaque reasoning blobs retain their existing representation. Cache hits can also +supply earlier history for delta continuations; +after a cache miss, resend the complete conversation without `previous_response_id`. Stateless +repair labels orphan results and missing tool results; it cannot reconstruct lost history or +prove whether a missing tool execution succeeded. + +An explicit `statelessResponses: false` is preserved. Existing canonical preset configurations +receive the default only when the setting is absent; custom renamed entries keep their configured +value and do not acquire this default by destination matching. Chat model routes keep their +existing protocol. The stateless flag does not force Responses streaming into JSON. + ## OpenCode Go reasoning efforts Go catalog rows preserve their configured reasoning efforts exactly, including during @@ -942,14 +968,19 @@ their previous behavior. See the ## Routed agent messages -With the [`openai-responses` adapter](/reference/adapters/#openai-responses), plaintext -Codex `agent_message` items become user messages when `authMode` is not `"forward"` +With the [`openai-responses` adapter](/reference/adapters/#openai-responses), Codex +`agent_message` items containing nonempty arrays of supported plaintext parts become user messages when `authMode` is not `"forward"` (for example, `"key"`). Providers using `authMode: "forward"` retain these items unchanged. `agent_message` is private to the ChatGPT Codex backend, and the routed destinations reported so far answer the whole request with `422 unknown item type "agent_message"`; Codex replays sub-agent history on every subsequent turn, so the thread keeps failing until the item is converted. Author and recipient remain explicit text metadata, and the content parts are preserved. +For HTTPS `api.x.ai` and `cli-chat-proxy.grok.com` on the standard port, non-forward +Responses dispatch also accepts a nonblank string child result and turns it into one +`input_text` part. The original string, including leading/trailing whitespace and newlines, +is preserved. Other destinations keep string-valued agent messages unchanged. Empty or +whitespace-only strings remain unchanged, as do incomplete and mixed encrypted/unknown shapes. Encrypted and unknown content is not normalized; native encrypted tasks still require the separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 548192f357..0ec59367b7 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -94,10 +94,20 @@ interface ProviderAdapter { ## `openai-responses` -**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело -запроса и стримит ответ обратно **без преобразования**. +**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает тело +запроса и ответ с преобразованиями совместимости для выбранного провайдера. **Аутентификация:** `forward` (ретрансляция заголовков вызывающей стороны) или `key`. +При `authMode`, отличном от `"forward"`, элементы Codex `agent_message` с непустым +массивом поддерживаемых открытых частей преобразуются в обычные сообщения пользователя. +Содержимое и читаемые поля author/recipient сохраняются. Для HTTPS `api.x.ai` и +`cli-chat-proxy.grok.com` на стандартном порту также поддерживается непустой строковый +результат дочерней задачи: он становится частью `input_text` без удаления пробелов и +переносов строк. Другие адреса сохраняют строковые элементы без изменений. Пустые строки, +зашифрованное содержимое и смешанные массивы с неизвестными или зашифрованными частями +не преобразуются частично. При `authMode: "forward"` элементы `agent_message` остаются +без изменений. + При `key`-аутентификации [`retryOn429`](/ru/reference/configuration/) действует и здесь: 429 до начала потока ждёт и, до любой другой обработки или фейловера, повторяет идентичный запрос на том же ключе, как и в переводимом пути `openai-chat`/Anthropic. Пользовательские транспорты diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 60c679489d..2900fd58b2 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,4 +1,6 @@ import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; +import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools"; +import { isXaiResponsesDestination } from "../providers/xai-transport"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -2365,7 +2367,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); - if (!forward) outBody = normalizeRoutedAgentMessages(outBody); + if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { + allowStringContent: isXaiResponsesDestination(provider), + }); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. @@ -2454,6 +2458,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } + if (!forward) outBody = normalizeOpenCodeGoAdditionalTools(outBody, url); // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would // let a noncanonical custom forward provider skip this rewrite while the server still routes // it as a summarizer turn (#422). The compaction body build removes the tool surface and must diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts new file mode 100644 index 0000000000..35e3aaa3e4 --- /dev/null +++ b/src/adapters/opencode-go-additional-tools.ts @@ -0,0 +1,35 @@ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ +export function normalizeOpenCodeGoAdditionalTools(body: unknown, responseUrl: string): unknown { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return body; + } + if (destination.origin !== "https://opencode.ai" + || destination.pathname !== "/zen/go/v1/responses" + || destination.username || destination.password + || destination.href.includes("?") || destination.href.includes("#")) return body; + if (!isRecord(body) || !Array.isArray(body.input)) return body; + // Do not replace a malformed top-level catalog with a partial promoted one. + if (body.tools !== undefined && !Array.isArray(body.tools)) return body; + + const input: unknown[] = []; + const promoted: unknown[] = []; + let changed = false; + for (const item of body.input) { + if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + changed = true; + // Custom/search/namespace lowering already owns identity and deduplication. This pass + // only moves declarations, including hosted tools that intentionally have no name. + for (const tool of item.tools) promoted.push(tool); + } else { + input.push(item); + } + } + return changed ? { ...body, input, tools: [...(body.tools ?? []), ...promoted] } : body; +} diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts index 7e4b8ecf7b..2ca67162a7 100644 --- a/src/adapters/routed-agent-messages.ts +++ b/src/adapters/routed-agent-messages.ts @@ -9,7 +9,10 @@ * encrypted v2 task surface owns those, through `unreadable_encrypted_agent_task` and the * opt-in recovery route. Providers using `authMode: "forward"` never reach this function. */ -export function normalizeRoutedAgentMessages(body: unknown): unknown { +export function normalizeRoutedAgentMessages( + body: unknown, + { allowStringContent = false }: { allowStringContent?: boolean } = {}, +): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const record = body as Record; if (!Array.isArray(record.input)) return body; @@ -17,9 +20,15 @@ export function normalizeRoutedAgentMessages(body: unknown): unknown { const input = record.input.map((item: unknown) => { if (!item || typeof item !== "object" || Array.isArray(item)) return item; const message = item as Record; - if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; + if (message.type !== "agent_message") return item; + // xAI rejects the private item even when a complete child result is a plain string. + // Trimming decides emptiness only; the original result bytes remain caller-owned. + const content = allowStringContent && typeof message.content === "string" && message.content.trim().length > 0 + ? [{ type: "input_text", text: message.content }] + : message.content; + if (!Array.isArray(content) || content.length === 0) return item; // Genuine ciphertext and unknown part types must retain their existing fail-closed path. - if (!message.content.every(part => part && typeof part === "object" + if (!content.every(part => part && typeof part === "object" && ["input_text", "input_image", "input_file"].includes(part.type))) return item; const identities = Object.fromEntries(["author", "recipient"] .filter(key => typeof message[key] === "string") @@ -29,7 +38,7 @@ export function normalizeRoutedAgentMessages(body: unknown): unknown { type: "message", role: "user", content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), - ...message.content, + ...content, ], }; }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e0aa946ac3..5beb971a4e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1676,6 +1676,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Zen Go can close a Chat stream after a fully assembled function call without sending // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. openaiChatEofTolerance: true, + // Go rejects reasoning.encrypted_content with previous_response_id (#3838). + // Use explicit replay history and the existing stateless Responses policy. + statelessResponses: true, /* [Decision Log] - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617). - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7c1302178a..b961e7cef9 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -347,6 +347,7 @@ import { } from "../responses-item-id-repair"; import { createReasoningSummaryChannelPayloadRewrite, + rewriteReasoningSummaryInJson, rewriteReasoningSummaryInJsonString, routeUsesContentChannelReasoning, } from "../responses-reasoning-summary-rewrite"; @@ -4730,6 +4731,11 @@ async function handleResponsesInner( const restoredResponse = (functionRepairSchemas.size > 0 ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) : restored) as { id?: unknown; output?: unknown; status?: unknown }; + // Replay overlap compares the items the client echoes, including visible reasoning shape. + const replayResponse = parsed.options.hideThinkingSummary !== true + && routeUsesContentChannelReasoning(route.provider, route.modelId) + ? rewriteReasoningSummaryInJson(restoredResponse) as typeof restoredResponse + : restoredResponse; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -4741,7 +4747,7 @@ async function handleResponsesInner( ) { return; } - rememberPassthroughResponse?.(restoredResponse); + rememberPassthroughResponse?.(replayResponse); const firstCompletion = !inspectedCompletionSeen; inspectedCompletionSeen = true; if (firstCompletion && (inspectedTerminal === null || firstTerminalAllowsRecall)) { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index bb46288d50..c98c837cec 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -373,6 +373,22 @@ have no exec-result seam today and are not annotated. - 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. - 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. +### xAI string agent-message continuation + +`normalizeRoutedAgentMessages` owns raw Responses `agent_message` lowering. Its existing +nonempty all-readable array behavior remains shared by non-forward destinations. The optional +`allowStringContent` argument defaults to false and is enabled only by the non-forward adapter +call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +on the standard port. A nonblank string becomes one `input_text` part with the original text; +the same author/recipient attribution is retained and the private transport item id is removed. + +This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, +malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed +path. Forward destinations never enable the option. The parser and encrypted-task recovery +owners are unchanged, and no broad content-schema validation or adapter-wide string conversion +is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over +SSE and JSON while preserving actual tool-call/result pairs. + OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction @@ -380,6 +396,32 @@ does not set `modelResponsesUpstreamStreaming`: client `stream: true` remains re streaming until a current-runtime reproduction justifies a separate bounded-JSON compatibility policy. +Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level +`tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing +custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. +It does not recalculate wire identities or response aliases. The matcher reads the constructed +send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port +and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` +configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. +Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing +URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and +the shared mixed-ciphertext agent-message gate remains fail-closed. + +The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go +rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive +logic fills absent values and preserves explicit false; renamed custom configurations receive +no new destination-based migration. The existing stateless pass sets `store: false`, removes +stored continuation parameters, and repairs orphan calls/results without claiming execution +success. A local replay-cache hit supplies history; a miss cannot reconstruct it, so callers +must resend complete history without `previous_response_id`. This flag also enables the existing +visible content-to-summary rewrite for SSE and JSON; summary-channel items and opaque reasoning +blobs keep their existing response handling. The shared recording callback applies the same +reasoning rewrite under the exact client-visible predicate before caching output, after tool +restoration and function normalization. This keeps full-content replay fingerprints comparable +for both full-history-plus-ID and delta continuations without weakening identity checks. Hidden +summaries and opaque blobs keep their existing cache representation. It does not change streaming selection or Chat +model routes. Go fixtures cover Luna, Grok and Muse against both response formats. + The canonical OpenCode Go transport also derives `x-opencode-session` from the existing hashed session lane before per-model wire selection. One conversation keeps one opaque affinity value across Responses, Chat, retries, and key rotation, while sibling subagents remain distinct. An diff --git a/tests/adapters/routed-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts index cbbb7151d2..2a44d11862 100644 --- a/tests/adapters/routed-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -30,6 +30,94 @@ test("ciphertext and unknown content are never reclassified as plaintext", () => } }); +test("string agent messages require an explicit opt-in and preserve exact text", () => { + const text = " Child result\nwith a trailing line.\n "; + const message = Object.freeze({ type: "agent_message", id: "amsg_string", content: text }); + const raw = Object.freeze({ input: Object.freeze([message]) }); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: false })).toBe(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: true })).toEqual({ input: [{ + type: "message", role: "user", content: [{ type: "input_text", text }], + }] }); + expect(raw.input[0]).toBe(message); + expect(message.content).toBe(text); +}); + +for (const baseUrl of ["https://api.x.ai/v1", "https://cli-chat-proxy.grok.com/v1"]) { + test.each(["key", "oauth"] as const)(`${baseUrl} lowers string child results with %s auth`, async authMode => { + const raw = { model: "grok-4.6", stream: true, input: [{ + type: "agent_message", id: "amsg_string", author: "/root/worker", recipient: "/root", + content: " Complete child result\nSecond line.\n ", + }] }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl, authMode }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(sent.input).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: 'Agent message {"author":"/root/worker","recipient":"/root"}' }, + { type: "input_text", text: original.input[0]!.content }, + ], + }]); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } + }); +} + +test.each([ + { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { baseUrl: "https://api.x.ai/v1", authMode: "forward" as const }, + { baseUrl: "https://cli-chat-proxy.grok.com/v1", authMode: "forward" as const }, + { baseUrl: "https://custom.test/v1", authMode: "forward" as const }, + { baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key" as const }, + { baseUrl: "https://example.test/v1", authMode: "key" as const }, + { baseUrl: "https://api.x.ai.evil.test/v1", authMode: "key" as const }, + { baseUrl: "https://cli-chat-proxy.grok.com.evil.test/v1", authMode: "key" as const }, + { baseUrl: "http://api.x.ai/v1", authMode: "key" as const }, + { baseUrl: "https://api.x.ai:444/v1", authMode: "key" as const }, +])("preserves string messages for $authMode at $baseUrl", async destination => { + const raw = { model: "grok-4.6", input: [{ type: "agent_message", content: "Child result" }] }; + const original = structuredClone(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, ...destination }).buildRequest(parseRequest(raw), { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input).toEqual(original.input); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each([ + "", " \n\t", null, 42, { text: "not a content string" }, [], + [{ type: "encrypted_content", encrypted_content: "opaque" }], + [{ type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }], + [{ type: "input_text", text: "Known prefix" }, { type: "future_type", text: "Unknown suffix" }], +].map(content => ({ content })))("xAI string opt-in leaves incomplete or unreadable content unchanged: %j", async ({ content }) => { + const raw = { model: "grok-4.6", input: [{ type: "agent_message", content }] }; + const original = structuredClone(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: true })).toBe(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://api.x.ai/v1" }).buildRequest(parseRequest(raw), { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input).toEqual(original.input); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + test("image parts stay intact beside the assignment", () => { const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; diff --git a/tests/providers/opencode-go-grok46-responses.test.ts b/tests/providers/opencode-go-grok46-responses.test.ts index 19c2addd46..d35fa6cf8c 100644 --- a/tests/providers/opencode-go-grok46-responses.test.ts +++ b/tests/providers/opencode-go-grok46-responses.test.ts @@ -5,6 +5,7 @@ import { getProviderRegistryEntry } from "../../src/providers/registry"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import type { OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { normalizeOpenCodeGoAdditionalTools } from "../../src/adapters/opencode-go-additional-tools"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -21,18 +22,22 @@ function provider(baseUrl = "https://opencode.ai/zen/go/v1"): OcxProviderConfig } as OcxProviderConfig; } -function build( +function buildRequest( modelId: string, rawBody: Record, configuredProvider = provider(), -): Record { - const request = createResponsesPassthroughAdapter(configuredProvider).buildRequest({ +) { + return createResponsesPassthroughAdapter(configuredProvider).buildRequest({ modelId, context: { messages: [] }, stream: true, options: {}, _rawBody: { model: modelId, input: "ping", ...rawBody }, }, { headers: new Headers() }); +} + +function build(modelId: string, rawBody: Record, configuredProvider = provider()): Record { + const request = buildRequest(modelId, rawBody, configuredProvider); return JSON.parse(request.body) as Record; } @@ -68,7 +73,7 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { expect(body.tools).toEqual([functionTool]); }); - test("drops hosted search from an additional_tools-only request", () => { + test("promotes additional_tools-only declarations before dropping refused hosted search", () => { const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; const body = build("grok-4.6", { input: [{ @@ -77,7 +82,8 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { }], }); - expect(body.input).toEqual([{ type: "additional_tools", tools: [functionTool] }]); + expect(body.input).toEqual([]); + expect(body.tools).toEqual([functionTool]); }); test("disables an explicit choice for a removed hosted tool", () => { @@ -133,3 +139,157 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { expect(body.tools).toEqual([{ type: "web_search" }]); }); }); + +describe("OpenCode Go additional_tools placement", () => { + const lookup = { type: "function", name: "lookup", parameters: { type: "object" } }; + const group = (name: string, tools: unknown[]) => ({ type: "namespace", name, tools }); + + test("preserves the canonical namespace dedupe and distinct response aliases", () => { + const raw = { + tools: [lookup, lookup], + input: [ + { type: "additional_tools", tools: [group("functions", [lookup, lookup]), group("alpha", [lookup])] }, + { type: "additional_tools", tools: [group("alpha", [lookup]), group("beta", [lookup])] }, + ], + }; + const original = structuredClone(raw); + const request = buildRequest("gpt-5.6-luna", raw); + const sent = JSON.parse(request.body); + expect(sent.input).toEqual([]); + expect(sent.tools).toEqual([lookup, { ...lookup, name: "alpha__lookup" }, { ...lookup, name: "beta__lookup" }]); + expect(request.convertedRoutedNamespaceToolAliases?.get("alpha__lookup")) + .toEqual({ namespace: "alpha", name: "lookup", kind: "function" }); + expect(request.convertedRoutedNamespaceToolAliases?.get("beta__lookup")) + .toEqual({ namespace: "beta", name: "lookup", kind: "function" }); + expect(raw).toEqual(original); + }); + + test.each(["none", "allowed"])("preserves custom/function lowering and %s authorization", choice => { + const request = buildRequest("gpt-5.6-luna", { + input: [{ type: "additional_tools", tools: [ + group("alpha", [lookup, { type: "custom", name: "custom_probe", description: "Freeform input" }]), + ] }], + tool_choice: choice === "none" ? "none" : { + type: "allowed_tools", mode: "auto", tools: [{ type: "function", namespace: "alpha", name: "lookup" }], + }, + }, { ...provider(), supportsResponsesCustomTools: false }); + const sent = JSON.parse(request.body); + expect(sent.input).toEqual([]); + expect(sent.tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "function", name: "alpha__lookup" }), + expect.objectContaining({ type: "function", name: "alpha__custom_probe" }), + ])); + expect(request.convertedRoutedNamespaceToolAliases?.has("alpha__custom_probe")).toBe(false); + expect(request.convertedRoutedNamespaceToolAliases?.has("alpha__lookup")).toBe(choice === "allowed"); + expect(sent.tool_choice).toEqual(choice === "none" ? "none" : { + type: "allowed_tools", mode: "auto", tools: [{ type: "function", name: "alpha__lookup" }], + }); + }); + + test("keeps nameless hosted tools for Luna and prunes Go Grok selectors after promotion", () => { + const web = { type: "web_search" }; + const raw = { input: [{ type: "additional_tools", tools: [web, lookup] }], tool_choice: { + type: "allowed_tools", mode: "required", tools: [web, { type: "function", name: "lookup" }], + } }; + expect(build("gpt-5.6-luna", raw).tools).toEqual([web, lookup]); + const grok = build("grok-4.6", raw); + expect(grok.tools).toEqual([lookup]); + expect(grok.tool_choice).toEqual({ + type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }], + }); + expect(build("grok-4.6", { input: [{ type: "additional_tools", tools: [web] }], tool_choice: "required" })) + .toMatchObject({ input: [], tools: [], tool_choice: "none" }); + }); + + test("activates tools loaded by tool search before moving their catalog", () => { + const sent = build("gpt-5.6-luna", { input: [ + { type: "additional_tools", tools: [{ ...lookup, defer_loading: true }] }, + { type: "tool_search_output", id: "tso_loaded", call_id: "call_search", tools: [lookup] }, + ] }); + expect(sent.tools).toEqual([lookup]); + expect((sent.input as Array<{ type: string }>).some(item => item.type === "additional_tools")).toBe(false); + }); + + test.each(["https://opencode.ai/zen/go/v1/responses", "https://opencode.ai:443/zen/go/v1/responses"])( + "promotes only wrappers on %s without mutating frozen caller data", responseUrl => { + const message = Object.freeze({ type: "message", role: "user", content: "keep" }); + const tools = Object.freeze([lookup]); + const raw = Object.freeze({ input: Object.freeze([message, Object.freeze({ type: "additional_tools", tools })]) }); + const result = normalizeOpenCodeGoAdditionalTools(raw, responseUrl) as { input: unknown[]; tools: unknown[] }; + expect(result).not.toBe(raw); + expect(result.input).toEqual([message]); + expect(result.input[0]).toBe(message); + expect(result.tools).toEqual([lookup]); + expect(result.tools[0]).toBe(lookup); + expect(raw.input).toHaveLength(2); + }, + ); + + test.each([ + { baseUrl: "https://opencode.ai/zen/go/v1" }, + { baseUrl: "https://opencode.ai:443/zen/go/v1/" }, + { baseUrl: "https://opencode.ai/zen/go/v1//" }, + { baseUrl: "https://opencode.ai/zen/go/v1/responses" }, + { baseUrl: "https://opencode.ai", responsesPath: "/zen/go/v1/responses" }, + ])("promotes on the final Go endpoint for $baseUrl", destination => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + const request = buildRequest("gpt-5.6-luna", raw, { ...provider(), ...destination }); + expect(new URL(request.url).href).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(JSON.parse(request.body)).toMatchObject({ input: [], tools: [lookup] }); + }); + + test("a custom path overriding a Go base does not inherit Go placement", () => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + const request = buildRequest("gpt-5.6-luna", raw, { ...provider(), responsesPath: "/../../v1/responses" }); + expect(new URL(request.url).href).toBe("https://opencode.ai/zen/v1/responses"); + expect(JSON.parse(request.body).input).toEqual(raw.input); + }); + + test.each([ + "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1/responses/", + "https://opencode.ai/zen/go/v1//responses", + ])("leaves a noncanonical final resource %s unchanged", responseUrl => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + expect(normalizeOpenCodeGoAdditionalTools(raw, responseUrl)).toBe(raw); + }); + + test.each([ + "https://opencode.ai/zen/v1", "https://opencode.ai.evil.test/zen/go/v1", + "http://opencode.ai/zen/go/v1", "https://opencode.ai:444/zen/go/v1", + "https://opencode.ai/zen/go/v10", + (() => { + const url = new URL("https://opencode.ai/zen/go/v1"); + url.username = "fixture-user"; + url.password = "synthetic-password"; + return url.href; + })(), "https://opencode.ai/zen/go/v1?tenant=test", + "https://opencode.ai/zen/go/v1?", "https://opencode.ai/zen/go/v1#", + "https://opencode.ai/zen/go/v1#fragment", "https://example.test/v1", + ])("does not promote for unapproved destination %s", baseUrl => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + const request = buildRequest("gpt-5.6-luna", raw, provider(baseUrl)); + expect(normalizeOpenCodeGoAdditionalTools(raw, request.url)).toBe(raw); + expect(JSON.parse(request.body).input).toEqual(raw.input); + }); + + test("keeps forward wrappers and mixed ciphertext unchanged", () => { + const mixed = { type: "agent_message", content: [ + { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, + ] }; + const raw = { input: [mixed, { type: "additional_tools", tools: [lookup] }] }; + expect(build("gpt-5.6-luna", raw, { ...provider(), authMode: "forward" }).input).toEqual(raw.input); + expect(build("gpt-5.6-luna", raw).input).toEqual([mixed]); + }); + + test("keeps malformed wrappers and no-op bodies; removes a valid empty wrapper", () => { + const valid = { input: [{ type: "additional_tools", tools: [lookup] }] }; + expect(normalizeOpenCodeGoAdditionalTools(valid, "not a URL")).toBe(valid); + for (const raw of [null, [], { input: "ping" }, { input: [] }, + { input: [{ type: "additional_tools", tools: null }] }, + { tools: null, input: [{ type: "additional_tools", tools: [lookup] }] }, + ]) expect(normalizeOpenCodeGoAdditionalTools(raw, "https://opencode.ai/zen/go/v1/responses")).toBe(raw); + const malformed = { type: "additional_tools", tools: null }; + expect(build("gpt-5.6-luna", { input: [malformed, { type: "additional_tools", tools: [] }] })) + .toMatchObject({ input: [malformed], tools: [] }); + }); +}); diff --git a/tests/providers/opencode-go-luna-wire.test.ts b/tests/providers/opencode-go-luna-wire.test.ts index 152783b9bd..c0afb15553 100644 --- a/tests/providers/opencode-go-luna-wire.test.ts +++ b/tests/providers/opencode-go-luna-wire.test.ts @@ -5,13 +5,17 @@ * boundary that lets operators opt out if the upstream changes. */ import { afterEach, describe, expect, test } from "bun:test"; -import { providerConfigSeed } from "../../src/providers/derive"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { handleResponses } from "../../src/server/responses/core"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { parseRequest } from "../../src/responses/parser"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; const MODEL = "gpt-5.6-luna"; +const GO_RESPONSES_MODELS = [MODEL, "grok-4.6", "muse-spark-1.3-contributor"]; function opencodeGo(overrides: Partial = {}): OcxProviderConfig { const entry = getProviderRegistryEntry("opencode-go"); @@ -43,6 +47,174 @@ describe("OpenCode Go GPT 5.6 Luna wire selection (#1482)", () => { }); }); +describe("OpenCode Go stateless Responses", () => { + test("seeds and backfills the canonical preset while preserving explicit false and custom names", () => { + expect(opencodeGo().statelessResponses).toBe(true); + const stale = opencodeGo(); + delete stale.statelessResponses; + enrichProviderFromRegistry("opencode-go", stale); + expect(stale.statelessResponses).toBe(true); + const overridden = opencodeGo({ statelessResponses: false }); + enrichProviderFromRegistry("opencode-go", overridden); + expect(overridden.statelessResponses).toBe(false); + const renamed = opencodeGo(); + delete renamed.statelessResponses; + enrichProviderFromRegistry("my-go", renamed); + expect(renamed.statelessResponses).toBeUndefined(); + expect(providerConfigSeed(getProviderRegistryEntry("cerebras")!).statelessResponses).toBeUndefined(); + }); + + test.each(GO_RESPONSES_MODELS)("%s repairs orphan calls/results and preserves paired results", model => { + const input = [ + { type: "function_call", call_id: "call_done", name: "probe", arguments: "{}" }, + { type: "function_call", call_id: "call_missing", name: "probe", arguments: "{}" }, + { type: "function_call_output", call_id: "call_done", output: "actual result" }, + { type: "function_call_output", call_id: "call_unknown", output: "orphan result" }, + ]; + const raw = { model, input, previous_response_id: "resp_unrecorded_go", stream: true }; + const original = structuredClone(raw); + for (const expanded of [false, true]) { + const parsed = parseRequest(raw); + parsed._previousResponseInputExpanded = expanded; + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...opencodeGo(), adapter: "openai-responses", + })); + const sent = JSON.parse(adapter.buildRequest(parsed).body); + expect(sent.previous_response_id).toBeUndefined(); + expect(sent.store).toBe(false); + expect(sent.input).toEqual([ + input[0], input[1], input[2], + expect.objectContaining({ type: "function_call_output", call_id: "call_missing", output: expect.stringContaining("no tool result was recorded") }), + expect.objectContaining({ type: "message", role: "user", content: expect.any(Array) }), + ]); + expect(JSON.stringify(sent.input[4])).toContain("orphan result"); + expect(raw).toEqual(original); + } + const stateful = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...opencodeGo({ statelessResponses: false }), adapter: "openai-responses", + })); + const sent = JSON.parse(stateful.buildRequest(parseRequest(raw)).body); + expect(sent.previous_response_id).toBe("resp_unrecorded_go"); + expect(sent.store).not.toBe(false); + expect(sent.input).toEqual(input); + }); +}); + +describe("OpenCode Go stateless reasoning and continuation routes", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + const continuations = [ + { id: "full", name: "full history", fullHistory: true, summary: "auto" }, + { id: "delta", name: "delta", fullHistory: false, summary: "auto" }, + { id: "hidden", name: "hidden-summary full history", fullHistory: true, summary: "none" }, + ]; + for (const model of GO_RESPONSES_MODELS) for (const streaming of [true, false]) for (const continuation of continuations) { + test(`${model} preserves ${continuation.name} across two ${streaming ? "SSE" : "JSON"} turns`, async () => { + const requests: Array<{ url: string; body: Record }> = []; + // Opaque synthetic provider state, never a real credential or decrypted task. + const blob = "provider-minted-go-reasoning-state"; + const prefix = `${model.replaceAll(".", "_")}_${streaming ? "sse" : "json"}_${continuation.id}`; + const reasoning = [ + { type: "reasoning", id: `rs_${prefix}_summary`, status: "completed", summary: [{ type: "summary_text", text: "Already summarized" }] }, + { type: "reasoning", id: `rs_${prefix}_content`, status: "completed", content: [{ type: "reasoning_text", text: "Visible thinking" }], summary: [] }, + { type: "reasoning", id: `rs_${prefix}_blob`, status: "completed", content: [{ type: "reasoning_text", text: "Opaque item trace" }], summary: [], encrypted_content: blob }, + ]; + const call = { type: "function_call", id: `fc_${prefix}`, status: "completed", call_id: `call_${prefix}`, name: "probe", arguments: "{}" }; + const priorMessage = { type: "message", id: `msg_${prefix}_prior`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: "Probe requested", annotations: [] }], + }; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body ?? "{}")) }); + const output = requests.length === 1 ? [...reasoning, call, priorMessage] : [{ + type: "message", id: `msg_${prefix}`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: "Continuation accepted", annotations: [] }], + }]; + const response = { id: `resp_${prefix}_${requests.length}`, object: "response", status: "completed", model, output }; + if (!streaming) return Response.json(response); + const payloads: Record[] = [{ type: "response.created", response: { ...response, status: "in_progress", output: [] } }]; + for (const [index, item] of output.entries()) { + payloads.push({ type: "response.output_item.added", output_index: index, item }); + if (requests.length === 1 && index === 1) payloads.push({ + type: "response.reasoning_text.delta", item_id: item.id, output_index: index, content_index: 0, delta: "Visible thinking", + }); + payloads.push({ type: "response.output_item.done", output_index: index, item }); + } + payloads.push({ type: "response.completed", response }); + return new Response(payloads.map((payload, sequence_number) => + `data: ${JSON.stringify({ ...payload, sequence_number })}\n\n` + ).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; + const drive = async (body: Record) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `opencode-go/${model}`, stream: streaming, reasoning: { summary: continuation.summary }, + tools: [{ type: "function", name: "probe", parameters: { type: "object" } }], ...body }), + }), config, { model: "", provider: "" }, { inboundWire: "responses" }); + expect(response.status).toBe(200); + const text = await response.text(); + if (!streaming) return { document: JSON.parse(text), text }; + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + const terminal = events.find(event => event.type === "response.completed"); + expect(terminal).toBeDefined(); + return { document: terminal.response, text }; + }; + const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Run probe" }] }; + const first = await drive({ input: [initial] }); + expect(first.document.output[0]).toEqual(reasoning[0]); + expect(first.document.output[1]).toEqual(continuation.summary === "auto" ? { + type: "reasoning", id: `rs_${prefix}_content`, status: "completed", summary: [{ type: "summary_text", text: "Visible thinking" }], + } : reasoning[1]); + expect(first.document.output[2]).toEqual(reasoning[2]); + expect(first.document.output[3]).toMatchObject(call); + expect(first.document.output[4]).toEqual(priorMessage); + if (streaming) { + const channel = continuation.summary === "auto" ? "reasoning_summary_text" : "reasoning_text"; + expect(first.text).toContain(`"type":"response.${channel}.delta"`); + } + const result = { type: "function_call_output", call_id: call.call_id, output: "probe succeeded" }; + // Echo exactly the client-visible history through handleResponses. An upstream-shape + // cache would prepend it again after the content-to-summary rewrite (F1). + const nextBody = { + input: continuation.fullHistory ? [initial, ...first.document.output, result] : [result], + previous_response_id: first.document.id, store: true, + conversation: "conversation_fixture", background: true, metadata: { fixture: "go" }, prompt: { id: "prompt_fixture" }, + }; + const originalHistory = structuredClone(nextBody); + const second = await drive(nextBody); + for (const field of ["previous_response_id", "conversation", "background", "metadata", "prompt"]) { + expect(requests[1]!.body[field]).toBeUndefined(); + } + expect(nextBody).toEqual(originalHistory); + expect(second.text).toContain("Continuation accepted"); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(request.body.previous_response_id).toBeUndefined(); + expect(request.body.store).toBe(false); + expect(request.body.stream).toBe(streaming); + } + const replay = requests[1]!.body.input as Array>; + expect(replay.filter(item => item.type === "function_call")).toEqual([ + expect.objectContaining({ call_id: call.call_id, name: "probe", arguments: "{}" }), + ]); + expect(replay.filter(item => item.type === "function_call_output")).toEqual([result]); + expect(replay.filter(item => item.type === "message" && item.role === "user")).toEqual([initial]); + expect(replay.filter(item => item.type === "message" && item.role === "assistant")).toEqual([ + expect.objectContaining({ role: "assistant", content: priorMessage.content }), + ]); + expect(replay.filter(item => item.type === "reasoning")).toHaveLength(3); + expect(replay).toContainEqual(expect.objectContaining({ type: "reasoning", encrypted_content: blob })); + expect(JSON.stringify(replay)).toContain("Already summarized"); + if (continuation.summary === "auto") expect(replay).toContainEqual(expect.objectContaining({ + type: "reasoning", summary: [{ type: "summary_text", text: "Visible thinking" }], + })); + expect(JSON.stringify(replay)).not.toContain("no tool result was recorded"); + }); + } +}); + describe("OpenCode Go Luna Responses route (#1482)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index ef0f1e5313..316ee56b91 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -72,6 +72,99 @@ function sse(payload: unknown): Uint8Array { } describe("xAI OAuth Responses streaming opt-in", () => { + test.each([true, false])("continues a routed parent after a string child result (stream=%s)", async stream => { + const captured: Array> = []; + let privateItemRejections = 0; + const childText = " Synthetic worker result\nAll requested observations returned.\n "; + const call = { type: "function_call", id: "fc_parent_probe", status: "completed", + call_id: "call_parent_probe", name: "probe", arguments: "{}", + }; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + // The fixture never falls through to a real OAuth or inference endpoint. + if (url !== RESPONSES_ENDPOINT) throw new Error(`Unexpected fixture destination: ${url}`); + const body = JSON.parse(String(init?.body)) as Record; + captured.push(body); + const items = body.input as Array<{ type?: string }>; + if (items.some(item => item.type === "agent_message")) { + privateItemRejections += 1; + return Response.json({ error: 'unknown item type "agent_message"' }, { status: 422 }); + } + const output = captured.length === 1 ? [call] : [{ + type: "message", id: `msg_child_result_${captured.length}`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: captured.length === 2 ? childText : "Parent continued", annotations: [] }], + }]; + const response = { id: `resp_child_result_${captured.length}`, object: "response", status: "completed", + model: "grok-4.6", output, + }; + if (!stream) return Response.json(response); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(sse({ type: "response.created", sequence_number: 0, + response: { ...response, status: "in_progress", output: [] }, + })); + controller.enqueue(sse({ type: "response.output_item.added", sequence_number: 1, output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.output_item.done", sequence_number: 2, output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.completed", sequence_number: 3, response })); + controller.close(); + }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + saveConfig({ ...config(), multiAgentMode: "v2" }); + const server = startServer(0); + const send = async (session: string, input: unknown[], parentSession?: string) => { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json", "session-id": session, + ...(parentSession ? { "x-codex-parent-thread-id": parentSession } : {}), + }, + body: JSON.stringify({ model: "xai/grok-4.6", stream, store: false, input, + tools: [{ type: "function", name: "probe", parameters: { type: "object", properties: {} } }], + }), + }); + expect(response.status).toBe(200); + if (!stream) return await response.json() as { output: Array> }; + const text = await response.text(); + const events = text.split(/\r?\n/).filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + const terminal = events.find(event => event.type === "response.completed"); + expect(terminal).toBeDefined(); + return terminal.response as { output: Array> }; + }; + try { + const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Collect a worker result" }] }; + const parent = await send("fixture-parent", [initial]); + expect(parent.output[0]).toMatchObject(call); + const child = await send("fixture-worker", [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Return the synthetic observations" }] }, + ], "fixture-parent"); + const childContent = child.output[0]!.content as Array<{ type: string; text: string }>; + expect(childContent[0]).toMatchObject({ type: "output_text", text: childText }); + // Codex-client envelope simulation only: no scheduler or real child process is run. + const toolResult = { type: "function_call_output", call_id: call.call_id, output: "Probe completed" }; + const agentMessage = { type: "agent_message", id: "amsg_worker_result", author: "/root/worker", recipient: "/root", + content: childContent[0]!.text, + }; + const resumed = await send("fixture-parent", [initial, ...parent.output, toolResult, agentMessage]); + expect(resumed.output[0]).toMatchObject({ type: "message", content: [{ type: "output_text", text: "Parent continued" }] }); + expect(privateItemRejections).toBe(0); + expect(captured).toHaveLength(3); + const input = captured[2]!.input as Array>; + expect(input.some(item => item.type === "agent_message")).toBe(false); + expect(input.filter(item => item.type === "function_call")).toEqual([ + expect.objectContaining({ call_id: call.call_id, name: "probe", arguments: "{}" }), + ]); + expect(input.filter(item => item.type === "function_call_output")).toEqual([toolResult]); + expect(input).toContainEqual({ type: "message", role: "user", content: [ + { type: "input_text", text: 'Agent message {"author":"/root/worker","recipient":"/root"}' }, + { type: "input_text", text: childText }, + ] }); + expect(agentMessage.content).toBe(childText); + } finally { + await server.stop(true); + } + }, 10_000); + test("uses the native Responses wire and relays the first delta before completion", async () => { let releaseCompletion!: () => void; const completionGate = new Promise(resolve => { releaseCompletion = resolve; });