diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index eb39744dc9..9a626e58de 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-11 - Preserve OpenCode session attribution for direct compaction summaries + +### What changed + +- `packages/coding-agent/src/core/provider-attribution.ts`: exports the existing OpenCode session-header predicate as `getOpenCodeSessionHeaders()` so non-AgentSession request paths can reuse the provider/host test without duplicating it. `mergeProviderAttributionHeaders()` continues to apply the same generated defaults and caller override order. + +### Why + +- Builtin compaction dispatches its summary directly through `ModelRuntime.stream()` and therefore cannot reuse the AgentSession wrapper that normally attaches `x-opencode-session`; exposing the narrow helper lets that alternate request boundary preserve the same routing identity. + +### Why an extension could not handle it + +- The session-attribution predicate is core provider request policy used before or beneath extension header transforms; duplicating it in an extension would allow the provider/host rules to drift from normal AgentSession traffic. + +### Expected merge conflict zones + +- LOW: `packages/coding-agent/src/core/provider-attribution.ts` around the OpenCode session-header helper and `mergeProviderAttributionHeaders()`. + ## 2026-09-10 - Atomic account display-name metadata with shape-keyed sentinel guard (senpi#1495) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index b932e2e493..f95614c9b1 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -1,5 +1,24 @@ # changes.md — builtin compaction policy +## 2026-09-11 - Preserve OpenCode session routing on builtin summary requests + +### What changed + +- `packages/coding-agent/src/core/extensions/builtin/compaction/speculative-summary.ts`: seeds the current `SessionManager` id through `getOpenCodeSessionHeaders()` before resolved auth/configured headers and the existing provider-request header transform. +- `packages/coding-agent/test/suite/regressions/issue-1523-opencode-session-attribution.test.ts`: pins provider-id and host routing, configured-header precedence, extension-transform precedence, and non-OpenCode neutrality. + +### Why + +- Issue #1523: the compaction summarizer bypasses `sdk.ts`'s normal AgentSession stream wrapper, so OpenCode Go requests could omit `x-opencode-session` and fail with `400 MissingSessionID` while ordinary turns succeeded. + +### Why an extension could not handle it + +- The omission occurs at the builtin compaction request-construction boundary before `ModelRuntime.stream()`. The fix must seed the normal routing defaults before the already-supported provider header transform, which remains authoritative. + +### Expected merge conflict zones + +- LOW: `speculative-summary.ts` at request-header construction immediately before `summarizationStream()`. + ## Deterministic resume slice for an over-window restored context (2026-09-10) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/speculative-summary.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/speculative-summary.ts index a69d284bd1..8ad36a62c5 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/speculative-summary.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/speculative-summary.ts @@ -18,6 +18,7 @@ import { summarizationMaxDurationMs, } from "../../../compaction/stream-watchdog.ts"; import { convertToLlm } from "../../../messages.ts"; +import { getOpenCodeSessionHeaders } from "../../../provider-attribution.ts"; import type { buildPrompt } from "./prompts.ts"; import { repairOrphanedToolResults } from "./repair-tool-pairs.ts"; import type { SpeculativeCompactionContext, SpeculativeCompactionSnapshot } from "./speculative.ts"; @@ -143,9 +144,18 @@ export async function generateSummaryMessage(options: { ), ...(options.snapshot.tools && options.snapshot.tools.length > 0 ? { tools: options.snapshot.tools } : {}), }; + // Builtin compaction bypasses AgentSession's sdk.ts stream wrapper. Seed only + // the OpenCode routing defaults here, then keep resolved auth/configured + // headers and the provider-request transform authoritative as on normal turns. + const requestHeaders: ProviderHeaders = { + ...getOpenCodeSessionHeaders(options.snapshot.model, options.context.sessionManager.getSessionId()), + ...(options.auth.headers ?? {}), + }; const headers = providerRequest - ? await providerRequest.transformHeaders(options.auth.headers ?? {}) - : options.auth.headers; + ? await providerRequest.transformHeaders(requestHeaders) + : Object.keys(requestHeaders).length > 0 + ? requestHeaders + : undefined; const responseStream = summarizationStream(options.context, options.snapshot.model, requestContext, { apiKey: options.auth.apiKey, headers, diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts index e7a0fbf759..26bcf45d89 100644 --- a/packages/coding-agent/src/core/provider-attribution.ts +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -65,7 +65,10 @@ function getDefaultAttributionHeaders( return undefined; } -function getSessionHeaders(model: Model, sessionId: string | undefined): Record | undefined { +export function getOpenCodeSessionHeaders( + model: Model, + sessionId: string | undefined, +): Record | undefined { if (!sessionId) return undefined; if ( model.provider !== "opencode" && @@ -84,7 +87,7 @@ export function mergeProviderAttributionHeaders( ...headerSources: Array ): ProviderHeaders | undefined { const merged: ProviderHeaders = { - ...getSessionHeaders(model, sessionId), + ...getOpenCodeSessionHeaders(model, sessionId), ...getDefaultAttributionHeaders(model, settingsManager), }; diff --git a/packages/coding-agent/test/suite/regressions/issue-1523-opencode-session-attribution.test.ts b/packages/coding-agent/test/suite/regressions/issue-1523-opencode-session-attribution.test.ts new file mode 100644 index 0000000000..9c58e10a0f --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/issue-1523-opencode-session-attribution.test.ts @@ -0,0 +1,148 @@ +import { + createAssistantMessageEventStream, + fauxAssistantMessage, + type Model, + type ProviderHeaders, +} from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { generateSummaryMessage } from "../../../src/core/extensions/builtin/compaction/speculative-summary.ts"; +import type { + SpeculativeCompactionContext, + SpeculativeCompactionSnapshot, +} from "../../../src/core/extensions/builtin/compaction/speculative.ts"; +import type { ModelRegistry } from "../../../src/core/model-registry.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; + +type TestModel = Model<"openai-completions">; + +function createModel(provider = "opencode-go", baseUrl = "https://opencode.ai/zen/go/v1/messages"): TestModel { + return { + id: "test-model", + name: "Test Model", + api: "openai-completions", + provider, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }; +} + +function completedStream(model: TestModel) { + const stream = createAssistantMessageEventStream(); + stream.end({ + ...fauxAssistantMessage("summary"), + api: model.api, + provider: model.provider, + model: model.id, + }); + return stream; +} + +async function captureSummaryHeaders(options: { + model: TestModel; + authHeaders?: ProviderHeaders; + transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise; +}): Promise { + let capturedHeaders: ProviderHeaders | undefined; + const sessionManager = SessionManager.inMemory(); + sessionManager.newSession({ id: "session-1523" }); + + const modelRuntime = { + stream: (model: TestModel, _context: unknown, streamOptions?: { headers?: ProviderHeaders }) => { + capturedHeaders = streamOptions?.headers; + return completedStream(model); + }, + }; + const context: SpeculativeCompactionContext = { + model: options.model, + modelRegistry: { modelRuntime } as unknown as ModelRegistry, + sessionManager, + getContextUsage: () => undefined, + getMessageRevision: () => 1, + prepareProviderRequest: async (messages) => ({ + messages, + transformPayload: async (payload) => payload, + transformHeaders: async (headers) => + options.transformHeaders ? await options.transformHeaders(headers) : headers, + }), + applyCompaction: async () => ({ applied: true, reason: "ok" }), + }; + const snapshot = { + model: options.model, + contextWindow: options.model.contextWindow, + systemPrompt: "system", + tools: [], + } as unknown as SpeculativeCompactionSnapshot; + + await generateSummaryMessage({ + context, + messages: [{ role: "user", content: [{ type: "text", text: "history" }], timestamp: 1 }], + prompt: { system: "system", user: "summarize" } as never, + snapshot, + auth: { headers: options.authHeaders }, + }); + return capturedHeaders; +} + +// Regression for #1523: builtin compaction bypasses sdk.ts's normal AgentSession +// provider-attribution wrapper but must preserve the same OpenCode routing semantics. +describe("issue #1523: compaction provider session attribution", () => { + it("sends the OpenCode session headers on builtin summarization", async () => { + const headers = await captureSummaryHeaders({ model: createModel() }); + + expect(headers?.["x-opencode-session"]).toBe("session-1523"); + expect(headers?.["x-opencode-client"]).toBe("pi"); + }); + + it("recognizes OpenCode routing by opencode.ai host", async () => { + const headers = await captureSummaryHeaders({ + model: createModel("custom-openai", "https://opencode.ai/zen/go/v1/messages"), + }); + + expect(headers?.["x-opencode-session"]).toBe("session-1523"); + expect(headers?.["x-opencode-client"]).toBe("pi"); + }); + + it("keeps resolved auth or configured headers above generated session defaults", async () => { + const headers = await captureSummaryHeaders({ + model: createModel(), + authHeaders: { + "x-opencode-session": "configured-session", + "x-opencode-client": "configured-client", + }, + }); + + expect(headers?.["x-opencode-session"]).toBe("configured-session"); + expect(headers?.["x-opencode-client"]).toBe("configured-client"); + }); + + it("keeps before-provider header transforms authoritative", async () => { + const headers = await captureSummaryHeaders({ + model: createModel(), + authHeaders: { + "x-opencode-session": "configured-session", + "x-opencode-client": "configured-client", + }, + transformHeaders: (input) => ({ + ...input, + "x-opencode-session": "extension-session", + "x-opencode-client": "extension-client", + }), + }); + + expect(headers?.["x-opencode-session"]).toBe("extension-session"); + expect(headers?.["x-opencode-client"]).toBe("extension-client"); + }); + + it("does not add OpenCode session headers for unrelated providers", async () => { + const headers = await captureSummaryHeaders({ + model: createModel("example", "https://example.test/v1"), + }); + + expect(headers?.["x-opencode-session"]).toBeUndefined(); + expect(headers?.["x-opencode-client"]).toBeUndefined(); + }); +});