Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/coding-agent/src/core/provider-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ function getDefaultAttributionHeaders(
return undefined;
}

function getSessionHeaders(model: Model<Api>, sessionId: string | undefined): Record<string, string> | undefined {
export function getOpenCodeSessionHeaders(
model: Model<Api>,
sessionId: string | undefined,
): Record<string, string> | undefined {
if (!sessionId) return undefined;
if (
model.provider !== "opencode" &&
Expand All @@ -84,7 +87,7 @@ export function mergeProviderAttributionHeaders(
...headerSources: Array<ProviderHeaders | undefined>
): ProviderHeaders | undefined {
const merged: ProviderHeaders = {
...getSessionHeaders(model, sessionId),
...getOpenCodeSessionHeaders(model, sessionId),
...getDefaultAttributionHeaders(model, settingsManager),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProviderHeaders>;
}): Promise<ProviderHeaders | undefined> {
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();
});
});