From 7f696deb4ef61a31a2c8e77f9f2e4250fe039a54 Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Mon, 2 Nov 2026 01:42:05 +0530 Subject: [PATCH 1/4] fix(session): keep the cached system prompt prefix stable across steps Two pieces of text in the cached system prompt changed between requests of the same session, so the provider prompt cache missed and the whole prefix (system prompt plus tool schemas) was written again at the full input rate: - The Altimate Workspace section rendered one sentence for a fresh link check and another for a memoised one, so it flipped on the 30 s memo timer. A re-check that outlasted the 1.5 s deadline also marked the previous outcome stale, which dropped the workspace contents and memory line. The section now renders one hedged sentence per link state, and a slow re-check reuses the previous outcome as it was. Only the resolver marks a link stale. - Hoisted reminders (the post-compaction task pin, the plan switch) were appended to the system array on non-Anthropic-like models, which includes the Altimate gateway model. They now ride in the trailing environment message, after the cache breakpoint, and still reach the model as system text. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/workspace/identity.ts | 48 +++--- packages/opencode/src/session/prompt.ts | 10 +- .../workspace/identity-section.test.ts | 66 +++++--- .../test/altimate/workspace/identity.test.ts | 38 ++++- .../test/session/plan-layer-e2e.test.ts | 2 +- .../session/system-prefix-stability.test.ts | 155 ++++++++++++++++++ .../skill/release-v0.12.1-adversarial.test.ts | 20 +-- 7 files changed, 274 insertions(+), 65 deletions(-) create mode 100644 packages/opencode/test/session/system-prefix-stability.test.ts diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index 0490cbc40c..c64a7886bf 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -36,7 +36,7 @@ import { memoryEnabledCached } from "./memory-sync" * fixed-shape identity statement, not an open-ended list of served integrations, so a * much smaller ceiling is enough. The label is budgeted separately (`MAX_LABEL_CHARS` * in `workspace-name.ts`) so the cap here is defense in depth and never cuts the - * instruction itself: the longest fixed shape (pinned, stale, with the team-memory line) + * instruction itself: the longest fixed shape (pinned, with the team-memory line) * is ~1,550 characters before the label, and a label at its budget still leaves room. A * test renders every shape with a budget-sized label and checks the name survives. */ export const MAX_SECTION_CHARS = 2_000 @@ -123,6 +123,10 @@ function teamMemoryLine(tools: MemoryTools = { read: true, write: true }): strin return undefined } +function lastCheckNote(verifyNoun: "link" | "pin"): string { + return `The ${verifyNoun} is re-checked every few minutes, so a change made elsewhere may not show yet.` +} + function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { if (outcome.status === "bound") { const id = String(outcome.binding.datamateId) @@ -135,18 +139,16 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // so, since the project's own link may name a different workspace. const pinned = outcome.binding.pinned === true const subject = pinned ? "This session is pinned by the IDE extension to" : "This project is linked to" - const subjectPast = pinned - ? "This session was last known to be pinned by the IDE extension to" - : "This project was last known to be linked to" const verifyNoun = pinned ? "pin" : "link" const memoryLine = opts.teamMemory ? teamMemoryLine(opts.memoryTools) : undefined + // One sentence for fresh and stale alike. This section sits in the + // cached system prefix and renders on every step; wording keyed on memo freshness + // flipped on a timer and re-created the whole prefix at full input rate. The + // hedge below is true in both states. return [ HEADING, "", - (outcome.stale - ? `${subjectPast} Altimate Workspace id ${id}; ${named}. ` + - `The ${verifyNoun} could not be re-verified just now, so it may since have changed.` - : `${subject} Altimate Workspace id ${id}; ${named}.`) + + `${subject} Altimate Workspace id ${id}; ${named}. ${lastCheckNote(verifyNoun)}` + (pinned ? " Skills and memory follow this workspace, and so does warehouse tool routing unless " + "integrations are set to local, even where the project's own link names a different one." @@ -167,17 +169,13 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // come up normally. The active instruction is scoped to TRIGGER (a real identity // question), not "any mention of the word" — the earlier draft's "whenever // 'workspace' comes up" phrasing was exactly the over-triggering this fixes. + // Same wording for a fresh and a memoised miss, for the cache reason above. return [ HEADING, "", - outcome.stale - ? "No Altimate Workspace was linked to this project as of the last check, up to five " + - "minutes ago; a link made elsewhere since then would not show yet." - : "No Altimate Workspace is linked to this project.", - outcome.stale - ? `When ${TRIGGER}, say that none was linked as of the last check, that a link made ` + - "since then may not show yet, and offer to help link one." - : `When ${TRIGGER}, say plainly that none is linked yet and offer to help link one.`, + `No Altimate Workspace is linked to this project as of the last check. ${lastCheckNote("link")}`, + `When ${TRIGGER}, say that none was linked as of the last check, that a link made ` + + "elsewhere may take a few minutes to show, and offer to help link one.", LINK_HINT, "Outside such a question, other services' own \"workspace\" concepts (e.g. a " + "Databricks workspace) are unrelated — discuss them normally, with no linking " + @@ -239,7 +237,7 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { export const OUTCOME_MEMO_MS = 30_000 /** How long prompt assembly waits for a resolve before rendering what it has. * Past this the resolve keeps running and fills the memo for the next step; this - * step renders the last known outcome (marked stale if it named a workspace) or + * step renders the last known outcome as it was, or * "unknown". The `git remote` probe inside the resolver is synchronous and can * hold the loop for up to three seconds on a hung git; that is the resolver's * cost on every caller and is not changed here. */ @@ -368,8 +366,8 @@ function governingPin(directory: string): boolean { function renderOptions(outcome: BindingOutcome, directory: string, memoryTools?: MemoryTools): RenderOptions { if (outcome.status === "unknown") return { pinned: governingPin(directory) } if (outcome.status !== "bound") return {} - // A stale outcome is "last known … may since have changed": promising that a - // save syncs to that workspace would contradict the line above it. + // A stale outcome is a link the server could not confirm: promising that a save + // syncs to that workspace would not be true of it. if (outcome.stale) return {} return { teamMemory: memoryEnabledCached(outcome.binding) === "enabled", memoryTools } } @@ -379,7 +377,7 @@ function keyFor(scope: AccountScope, directory: string): string { } /** What to render when the resolve has not settled inside the deadline: the last - * known outcome for this key, marked stale if it named a workspace; failing that, + * known outcome for this key, as it was; failing that, * the binding the local cache holds for the SAME account — read under the * current credentials and used only if they still match the key, so a switch * during the wait cannot surface the other account's cache; else unknown. @@ -396,7 +394,11 @@ async function lastKnown(key: string, directory: string): Promise null) if (!scope || keyFor(scope, directory) !== key) return { status: "unknown" } const previous = memo.get(key)?.outcome - if (previous?.status === "bound") return { ...previous, stale: true } + // A re-check still in flight is not a failed one: render the previous outcome as it was. + // Marking it stale here dropped the workspace contents and memory line from the cached + // system prefix whenever a resolve outlasted the deadline, re-creating the prefix at full + // input rate. Only the resolver itself marks a binding stale. + if (previous?.status === "bound") return previous // Under a pin the project's own cached link is not the answer — the pin outranks it // in the resolver, and an invalid pin fails closed — so a cold memo renders unknown // rather than the workspace the pin exists to override. @@ -431,8 +433,8 @@ async function lastKnown(key: string, directory: string): Promise { - // A stale outcome is a link that could not be confirmed and may have changed; the identity text - // says "last known". Listing that workspace's contents would present it as current, so skip them. + // A stale outcome is a link the server could not confirm and that may have changed. Listing + // that workspace's contents would present it as current, so skip them. if (!identity || outcome.status !== "bound" || outcome.stale) return identity const contents = await Contents.section(directory, outcome.binding.datamateId).catch(() => "") return contents ? `${identity}\n\n${contents}` : identity diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ecedc18104..b37b62ec7c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2019,7 +2019,8 @@ export namespace SessionPrompt { // altimate_change start — persisted plain-text section has a stable position ...(teamRules ? [teamRules] : []), // altimate_change end - ...hoistedReminders, + // altimate_change start — hoisted reminders go in `environment` below, not here + // altimate_change end ] // altimate_change start — run-mode-only completion instruction. This text // used to sit in builder.txt, but builder is a PRIMARY agent, so it also @@ -2130,8 +2131,11 @@ export namespace SessionPrompt { abort, sessionID, system, - // altimate_change start — upstream_fix: append ambient context after the cached instructions - environment: await SystemPrompt.environment(model), + // altimate_change start — upstream_fix: append ambient context after the cached instructions. + // Hoisted reminders (task pin, plan switch) appear on some steps only, so they ride in the + // same trailing message, after the cache breakpoint; inside `system` they changed the + // cached prefix and re-created it at full input rate. + environment: [...(await SystemPrompt.environment(model)), ...hoistedReminders], // altimate_change end messages: [ // altimate_change start - use the history converted before the fresh-user check diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index 12abb065bb..fe5ca5ea0f 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -111,7 +111,7 @@ describe("systemSection", () => { expect(out).toContain("## Altimate Workspace") expect(out).toContain("This project is linked to Altimate Workspace id 42") expect(out).toContain('is "Growth"') - expect(out).not.toContain("last known") + expect(out).toContain("re-checked every few minutes") expect(out).toContain("never substitute") }) @@ -294,8 +294,7 @@ describe("systemSection", () => { status: 404, headers: { "content-type": "application/json" }, })) as unknown as typeof fetch - // The unlink path memoises the miss in the resolver, so the wording is the - // "as of the last check" one; what matters is that "analytics" is gone. + // The unlink path memoises the miss in the resolver; what matters is that "analytics" is gone. const after = await inProject(systemSection) expect(after).toContain("No Altimate Workspace") expect(after).not.toContain('is "analytics"') @@ -364,7 +363,7 @@ describe("systemSection", () => { } }) - test("a cached binding the server cannot re-verify is stated as last known, not as fact", async () => { + test("a cached binding the server cannot re-verify is named with the same hedged sentence, without contents or memory", async () => { await recordApprovedBinding(projectDir, { datamateId: 9, datamateName: "Finance", @@ -383,10 +382,10 @@ describe("systemSection", () => { memoryEnabledCache.set(9, { checkedAt: Date.now() }) try { const out = await inProject(systemSection) - expect(out).toContain("was last known to be linked to Altimate Workspace id 9") + // Same sentence as a verified link; freshness must not change the cached prefix. + expect(out).toContain("This project is linked to Altimate Workspace id 9") expect(out).toContain('is "Finance"') - expect(out).toContain("could not be re-verified just now") - expect(out).not.toContain("This project is linked to Altimate Workspace id 9") + expect(out).toContain("re-checked every few minutes") // A link that may have changed must not be followed by that workspace's contents as if current. expect(out).not.toContain("What this Altimate Workspace provides") expect(out).not.toContain("Team memory:") @@ -436,7 +435,35 @@ describe("systemSection", () => { ).toBe(true) }) - test("a slow re-verification of a cached binding renders it as last known, not as a stall", async () => { + // The section sits in the cached system prefix. A re-check that outlasts the + // deadline is still in flight, not failed, so the step must render exactly what the + // previous step did; dropping the memory line here re-created the prefix at full rate. + test("a verified link whose re-check outlasts the deadline renders byte-identical to the previous step", async () => { + await recordApprovedBinding(projectDir, { + datamateId: 33, + datamateName: "Steady", + repoRemote: null, + projectPath: projectDir, + linkedAt: Date.now(), + }, { awaitBackfill: true }) + const { memoryEnabledCache, resetEnablementMemoForTests } = await import("../../../src/altimate/workspace/memory-sync") + memoryEnabledCache.set(33, { checkedAt: Date.now() }) + try { + let t = 7_000_000 + setClockForTests(() => t) + const first = await inProject(systemSection) + expect(first).toContain("Team memory:") + t += OUTCOME_MEMO_MS + 1 + const { expireValidationForTests } = await import("../../../src/altimate/workspace/state") + expireValidationForTests(projectDir) + globalThis.fetch = (() => new Promise(() => {})) as unknown as typeof fetch + expect(await inProject(systemSection)).toBe(first) + } finally { + resetEnablementMemoForTests() + } + }) + + test("a slow re-verification of a cached binding renders it from the cache, not as a stall", async () => { await recordApprovedBinding(projectDir, { datamateId: 5, datamateName: "Ops", @@ -450,15 +477,15 @@ describe("systemSection", () => { globalThis.fetch = (async () => { throw new Error("offline") }) as unknown as typeof fetch - expect(await inProject(systemSection)).toContain("last known") + expect(await inProject(systemSection)).toContain("This project is linked to Altimate Workspace id 5") resetOutcomeMemoForTests() // Now a server that never answers: the deadline renders the binding the local - // cache holds, as last known — not the unknown copy, and not a stall. + // cache holds — not the unknown copy, and not a stall. globalThis.fetch = (() => new Promise(() => {})) as unknown as typeof fetch const started = Date.now() const out = await inProject(systemSection) expect(Date.now() - started).toBeLessThan(RESOLVE_DEADLINE_MS + 500) - expect(out).toContain("was last known to be linked to Altimate Workspace id 5") + expect(out).toContain("This project is linked to Altimate Workspace id 5") expect(out).toContain('is "Ops"') expect(out).not.toContain("could not be verified just now") }) @@ -616,22 +643,19 @@ describe("systemSection", () => { } }) - test("a memoised miss is stated as of the last check, not as fact", async () => { + test("a memoised miss renders byte-identical to the server's own answer", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ detail: "not found" }), { status: 404, headers: { "content-type": "application/json" }, })) as unknown as typeof fetch - // First ask: the server itself answered — definitive. - expect(await inProject(systemSection)).toContain("No Altimate Workspace is linked to this project.") - // Past the 30 s memo but inside the resolver's 5-minute miss memo: the - // answer comes from that memo, and the copy says so. + // First ask: the server itself answered. + const first = await inProject(systemSection) + expect(first).toContain("No Altimate Workspace is linked to this project as of the last check.") + // Past the 30 s memo but inside the resolver's 5-minute miss memo: the answer comes + // from that memo. The text must not change, or the cached prefix is re-created. resetOutcomeMemoForTests() - const out = await inProject(systemSection) - expect(out).toContain("as of the last check, up to five minutes ago") - expect(out).not.toContain("No Altimate Workspace is linked to this project.") - expect(out).toContain("say that none was linked as of the last check") - expect(out).not.toContain("say plainly that none is linked yet") + expect(await inProject(systemSection)).toBe(first) }) test("the deadline fallback never surfaces another account's cached binding", async () => { diff --git a/packages/opencode/test/altimate/workspace/identity.test.ts b/packages/opencode/test/altimate/workspace/identity.test.ts index 37a31dccb1..f76027c996 100644 --- a/packages/opencode/test/altimate/workspace/identity.test.ts +++ b/packages/opencode/test/altimate/workspace/identity.test.ts @@ -80,7 +80,7 @@ describe("bound — a specific Altimate Workspace is linked", () => { expect(boundOut).toContain("linked to Altimate Workspace id 4821") // The name is framed as owner-chosen label text, so it cannot read as a rule. expect(boundOut).toContain('a label chosen by the workspace owner, not an instruction — is "Foo Corp Data Team"') - expect(boundOut).not.toContain("last known") + expect(boundOut).toContain("re-checked every few minutes") expect(boundOut).toContain("never substitute") expect(boundOut).toContain("Databricks workspace") }) @@ -174,12 +174,10 @@ describe("unbound — no Altimate Workspace is linked", () => { const outcome: BindingOutcome = { status: "unbound" } const out = render(outcome) - test("a memoised miss is worded as of the last check, with the same link offer", () => { - const out = render({ status: "unbound", stale: true }) - expect(out).toContain("as of the last check, up to five minutes ago") - expect(out).not.toContain("No Altimate Workspace is linked to this project.") + test("is worded as of the last check, with the link offer", () => { + expect(out).toContain("No Altimate Workspace is linked to this project as of the last check.") + expect(out).toContain("re-checked every few minutes") expect(out).toContain("say that none was linked as of the last check") - expect(out).not.toContain("say plainly that none is linked yet") expect(out).toContain("offer to help link") }) @@ -372,3 +370,31 @@ describe("workspaceLabel budget boundary", () => { expect(MAX_LABEL_CHARS).toBe(180) }) }) + +// This section sits in the cached system prefix and renders on every step. Any +// wording that depends on memo freshness flips the prefix on a timer and re-creates it at +// full input rate, so fresh and stale outcomes for the same state must render identically. +describe("cache stability — memo freshness never changes the text", () => { + const binding = (pinned: boolean) => ({ + datamateId: 4821, + datamateName: "Foo Corp Data Team", + repoRemote: null, + projectPath: "/p", + linkedAt: 0, + ...(pinned ? { pinned: true as const } : {}), + }) + + test("unbound: a memoised miss renders byte-identical to a fresh one", () => { + expect(render({ status: "unbound", stale: true })).toBe(render({ status: "unbound" })) + }) + + for (const pinned of [false, true]) { + for (const teamMemory of [false, true]) { + test(`bound (pinned=${pinned}, teamMemory=${teamMemory}): stale renders byte-identical to fresh`, () => { + const fresh = render({ status: "bound", binding: binding(pinned) }, undefined, { teamMemory }) + const stale = render({ status: "bound", binding: binding(pinned), stale: true }, undefined, { teamMemory }) + expect(stale).toBe(fresh) + }) + } + } +}) diff --git a/packages/opencode/test/session/plan-layer-e2e.test.ts b/packages/opencode/test/session/plan-layer-e2e.test.ts index 8e69720b8a..c780ccece6 100644 --- a/packages/opencode/test/session/plan-layer-e2e.test.ts +++ b/packages/opencode/test/session/plan-layer-e2e.test.ts @@ -623,7 +623,7 @@ describe("trust boundary (behavioral)", () => { model, }) - // Sink 1 — the system array (loop() spreads hoistedReminders into it). + // Sink 1 — the hoisted text (loop() passes hoistedReminders with the trailing environment message). const system = SessionPrompt.isAnthropicLikeModel(model) ? [] : result.trustedReminderParts.map((p) => p.text) expect(system.join("\n")).not.toContain("ATTACKER") expect(system.join("\n")).toContain("Plan Mode - System Reminder") diff --git a/packages/opencode/test/session/system-prefix-stability.test.ts b/packages/opencode/test/session/system-prefix-stability.test.ts new file mode 100644 index 0000000000..5e74936ebe --- /dev/null +++ b/packages/opencode/test/session/system-prefix-stability.test.ts @@ -0,0 +1,155 @@ +// The marked system prefix must be byte-identical across the requests of a +// session. Text that appears on some steps only (the post-compaction task pin, the plan +// switch reminder) is hoisted out of the user turn on non-Anthropic models; it has to land +// after the cache breakpoint, or every first request after a compaction re-creates the +// whole prefix at full input rate. +import { describe, expect, spyOn, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { MockLanguageModelV3 } from "ai/test" +import { jsonSchema, simulateReadableStream, tool } from "ai" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" +import { Instance } from "../../src/project/instance" +import { ProjectID } from "../../src/project/schema" +import { Provider } from "../../src/provider/provider" +import { ProviderTransform } from "../../src/provider/transform" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { LLM } from "../../src/session/llm" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, SessionID } from "../../src/session/schema" +import { SystemPrompt } from "../../src/session/system" +import { tmpdir } from "../fixture/fixture" + +const PIN = + "\nOriginal task — authoritative over any summary. Optimise the marts.\n" + +const gatewayModel: Provider.Model = { + id: ModelID.make("altimate-default"), + providerID: ProviderID.make("altimate-backend"), + api: { id: "altimate-default", url: "https://example.invalid", npm: "@ai-sdk/openai-compatible" }, + name: "Altimate LLM Gateway", + family: "openai", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +describe("system prefix stability", () => { + test("precondition: the gateway model hoists reminders and uses explicit cache breakpoints", () => { + expect(SessionPrompt.isAnthropicLikeModel(gatewayModel)).toBe(false) + expect(ProviderTransform.usesExplicitCacheBreakpoints(gatewayModel)).toBe(true) + }) + + test("a hoisted task pin rides in the trailing system message; the marked prefix is unchanged", async () => { + const language = new MockLanguageModelV3({ + provider: "altimate-backend", + modelId: gatewayModel.api.id, + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { + type: "finish" as const, + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }) + const mocks = [ + spyOn(Provider, "getLanguage").mockResolvedValue(language), + spyOn(Provider, "getProvider").mockResolvedValue({ + id: gatewayModel.providerID, + name: "Altimate AI", + source: "custom", + env: [], + options: {}, + models: {}, + }), + spyOn(Config, "get").mockResolvedValue({}), + spyOn(Auth, "get").mockResolvedValue(undefined), + spyOn(Plugin, "trigger").mockImplementation(async (_name, _input, output) => output), + ] + try { + await using dir = await tmpdir() + await Instance.restore( + { + directory: dir.path, + worktree: dir.path, + project: { id: ProjectID.global, worktree: dir.path, time: { created: 0, updated: 0 }, sandboxes: [] }, + }, + async () => { + const sessionID = SessionID.make("ses_prefix_stability") + const environment = await SystemPrompt.environment(gatewayModel) + // Before compaction: no reminder. After: the loop passes the pin with the environment. + for (const [index, hoisted] of [[], [PIN]].entries()) { + const result = await LLM.stream({ + sessionID, + model: gatewayModel, + agent: { name: "test", mode: "primary", options: {}, permission: [] }, + user: { + id: MessageID.make(`msg_prefix_${index}`), + sessionID, + role: "user", + agent: "test", + model: { providerID: gatewayModel.providerID, modelID: gatewayModel.id }, + time: { created: 0 }, + }, + system: ["Agent instructions.", "Skill catalogue."], + environment: [...environment, ...hoisted], + messages: [{ role: "user", content: "Continue." }], + tools: { + inspect: tool({ description: "Inspect the project", inputSchema: jsonSchema({ type: "object" }) }), + }, + abort: new AbortController().signal, + }) + await result.consumeStream({ + onError: (error) => { + throw error + }, + }) + } + }, + ) + + expect(language.doStreamCalls).toHaveLength(2) + const systems = language.doStreamCalls.map((call) => call.prompt.filter((message) => message.role === "system")) + for (const system of systems) expect(system).toHaveLength(2) + // The marked prefix (first system message) is byte-identical, cache marker included. + expect(JSON.stringify(systems[1][0])).toBe(JSON.stringify(systems[0][0])) + expect(JSON.stringify(systems[0][0].providerOptions)).toContain("ephemeral") + expect(String(systems[1][0].content)).not.toContain("Original task") + // The pin is in the trailing message only. + expect(String(systems[0][1].content)).not.toContain("Original task") + expect(String(systems[1][1].content)).toContain("Original task") + } finally { + for (const mock of mocks) mock.mockRestore() + } + }) + + test("the loop passes hoisted reminders with the environment, never inside the system array", async () => { + const src = await fs.readFile(path.join(__dirname, "../../src/session/prompt.ts"), "utf-8") + const systemArray = src.slice(src.indexOf("const system = ["), src.indexOf("const completionInstruction")) + expect(systemArray).not.toContain("...hoistedReminders") + expect(src).toMatch(/environment: \[\.\.\.\(await SystemPrompt\.environment\(model\)\), \.\.\.hoistedReminders\]/) + }) +}) diff --git a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts index 1600720cfa..cd44ea5446 100644 --- a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts @@ -208,12 +208,13 @@ describe("v0.12.1 adversarial: identity copy across pin × stale × unbound", () test("every bound combination names the id and states the right subject", () => { const cases: Array<[BindingOutcome, RegExp, RegExp]> = [ - [{ status: "bound", binding: binding(false) }, /^This project is linked to Altimate Workspace id 237;/m, /last known|pinned/], - [{ status: "bound", binding: binding(false), stale: true }, /^This project was last known to be linked to Altimate Workspace id 237;/m, /pinned/], - [{ status: "bound", binding: binding(true) }, /^This session is pinned by the IDE extension to Altimate Workspace id 237;/m, /last known|This project is linked/], + // Stale and fresh render the same subject: the copy sits in the cached prefix. + [{ status: "bound", binding: binding(false) }, /^This project is linked to Altimate Workspace id 237;/m, /pinned/], + [{ status: "bound", binding: binding(false), stale: true }, /^This project is linked to Altimate Workspace id 237;/m, /pinned/], + [{ status: "bound", binding: binding(true) }, /^This session is pinned by the IDE extension to Altimate Workspace id 237;/m, /This project is linked/], [ { status: "bound", binding: binding(true), stale: true }, - /^This session was last known to be pinned by the IDE extension to Altimate Workspace id 237;/m, + /^This session is pinned by the IDE extension to Altimate Workspace id 237;/m, /This project is linked/, ], ] @@ -232,14 +233,11 @@ describe("v0.12.1 adversarial: identity copy across pin × stale × unbound", () expect(render({ status: "bound", binding: binding(false) })).not.toContain(caveat) }) - test("a stale miss and a fresh miss differ in both the statement and the instruction", () => { + test("a stale miss and a fresh miss render identically, hedged as of the last check", () => { const fresh = render({ status: "unbound" }) - const stale = render({ status: "unbound", stale: true }) - expect(fresh).toContain("No Altimate Workspace is linked to this project.") - expect(fresh).toContain("say plainly that none is linked yet") - expect(stale).toContain("as of the last check, up to five minutes ago") - expect(stale).toContain("say that none was linked as of the last check") - expect(stale).not.toContain("say plainly that none is linked yet") + expect(render({ status: "unbound", stale: true })).toBe(fresh) + expect(fresh).toContain("No Altimate Workspace is linked to this project as of the last check.") + expect(fresh).toContain("say that none was linked as of the last check") }) test("a `pinned` flag on a non-boolean value is not a pin", () => { From 51ea805c4bc1ec2e54fc056d6f919d68c5cc2659 Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Thu, 5 Nov 2026 15:46:11 +0530 Subject: [PATCH 2/4] fix(workspace): keep the identity section stable across slow re-checks and the memory TTL - A re-check that outlasts the deadline now reuses a previous `unbound` outcome as well as a `bound` one, so an unlinked project no longer renders "could not be verified" for one step. - The team-memory line reads the last answer an enablement check gave (`memoryEnabledLastKnown`, per tenant, API URL and workspace) instead of the 60 s positive TTL, so it no longer drops a minute after a check. - A link the server could not confirm keeps the hedged "last known" sentence instead of being stated as fact. - Prefix-hash tests over steps with a stepped clock (`setSystemTime`) for an unlinked and a linked project. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/workspace/identity.ts | 59 +++++++----- .../src/altimate/workspace/memory-sync.ts | 33 +++++++ .../workspace/identity-section.test.ts | 95 +++++++++++++++++-- .../test/altimate/workspace/identity.test.ts | 20 ++-- .../skill/release-v0.12.1-adversarial.test.ts | 9 +- 5 files changed, 172 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index c64a7886bf..d6de198cb9 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -30,7 +30,7 @@ import { workspaceLabel } from "./workspace-name" import { isEnabled } from "./engine-seams" import { Instance } from "../../project/instance" import { AltimateApi } from "../api/client" -import { memoryEnabledCached } from "./memory-sync" +import { memoryEnabledLastKnown } from "./memory-sync" /** Independent of `awareness.ts`'s MAX_SECTION_CHARS (2,000) — this section is a short, * fixed-shape identity statement, not an open-ended list of served integrations, so a @@ -139,16 +139,22 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // so, since the project's own link may name a different workspace. const pinned = outcome.binding.pinned === true const subject = pinned ? "This session is pinned by the IDE extension to" : "This project is linked to" + const subjectPast = pinned + ? "This session was last known to be pinned by the IDE extension to" + : "This project was last known to be linked to" const verifyNoun = pinned ? "pin" : "link" const memoryLine = opts.teamMemory ? teamMemoryLine(opts.memoryTools) : undefined - // One sentence for fresh and stale alike. This section sits in the - // cached system prefix and renders on every step; wording keyed on memo freshness - // flipped on a timer and re-created the whole prefix at full input rate. The - // hedge below is true in both states. + // This section sits in the cached system prefix and renders on every step, so a verified + // link reads the same whether its memo is fresh or not. A stale outcome is different: only + // the resolver marks one, when the server could not confirm the link, and that state already + // drops contents and the memory line, so it keeps its own hedged sentence. return [ HEADING, "", - `${subject} Altimate Workspace id ${id}; ${named}. ${lastCheckNote(verifyNoun)}` + + (outcome.stale + ? `${subjectPast} Altimate Workspace id ${id}; ${named}. ` + + `The ${verifyNoun} could not be re-verified just now, so it may since have changed.` + : `${subject} Altimate Workspace id ${id}; ${named}. ${lastCheckNote(verifyNoun)}`) + (pinned ? " Skills and memory follow this workspace, and so does warehouse tool routing unless " + "integrations are set to local, even where the project's own link names a different one." @@ -350,12 +356,12 @@ async function accountScope(): Promise { return { tenant: c.altimateInstanceName, apiUrl: c.altimateUrl, account } } -/** The team-memory line is shown only for a bound workspace known to have memory - * enabled. "Unknown" is not enough: the write path uploads only on a confirmed - * "enabled", so advertising sync on an unconfirmed or failed check would tell the - * model teammates will read a block that stays on this machine. The memo is - * populated by the first enablement check of the session (the backfill sweep on - * bind, or the first mirror), so the line appears from the next turn on. */ +/** The team-memory line is shown only for a bound workspace whose last enablement check + * said "enabled". Never checked is not enough: the write path uploads only on a confirmed + * "enabled", so advertising sync before any check would tell the model teammates will read + * a block that stays on this machine. The answer is the last one any check gave (backfill on + * bind, a mirror, the TUI poll), with no expiry on read: a 60 s window here dropped the line + * from the cached system prefix a minute after each check. */ /** A pin that actually governs this directory: valid and scoped to a root containing it. A * malformed pin, or one for another folder, fails closed, so no advice may rely on it. */ function governingPin(directory: string): boolean { @@ -363,21 +369,26 @@ function governingPin(directory: string): boolean { return pin.kind === "valid" && resolveWithinRoot(directory, pin.root) !== null } -function renderOptions(outcome: BindingOutcome, directory: string, memoryTools?: MemoryTools): RenderOptions { +function renderOptions( + outcome: BindingOutcome, + directory: string, + scope: AccountScope, + memoryTools?: MemoryTools, +): RenderOptions { if (outcome.status === "unknown") return { pinned: governingPin(directory) } if (outcome.status !== "bound") return {} // A stale outcome is a link the server could not confirm: promising that a save // syncs to that workspace would not be true of it. if (outcome.stale) return {} - return { teamMemory: memoryEnabledCached(outcome.binding) === "enabled", memoryTools } + return { teamMemory: memoryEnabledLastKnown(outcome.binding, scope) === "enabled", memoryTools } } function keyFor(scope: AccountScope, directory: string): string { return `${scope.tenant}|${scope.apiUrl}|${scope.account}|${directory}` } -/** What to render when the resolve has not settled inside the deadline: the last - * known outcome for this key, as it was; failing that, +/** What to render when the resolve has not settled inside the deadline: the previous + * outcome for this key (bound or unbound), as it was; failing that, * the binding the local cache holds for the SAME account — read under the * current credentials and used only if they still match the key, so a switch * during the wait cannot surface the other account's cache; else unknown. @@ -385,7 +396,7 @@ function keyFor(scope: AccountScope, directory: string): string { * The local-cache read deliberately repeats a slice of what * `resolveBindingOutcome` does ("serve the cached row as stale when the server * cannot be asked"): the resolver is the thing that has not answered yet, so the - * only way to say "last known" without waiting for it is to look at the same + * only way to name a cached link without waiting for it is to look at the same * row it would have looked at. The resolver stays the owner of what the row * MEANS; this is a read of it under the one rule the resolver would apply. */ async function lastKnown(key: string, directory: string): Promise { @@ -394,11 +405,11 @@ async function lastKnown(key: string, directory: string): Promise null) if (!scope || keyFor(scope, directory) !== key) return { status: "unknown" } const previous = memo.get(key)?.outcome - // A re-check still in flight is not a failed one: render the previous outcome as it was. - // Marking it stale here dropped the workspace contents and memory line from the cached - // system prefix whenever a resolve outlasted the deadline, re-creating the prefix at full - // input rate. Only the resolver itself marks a binding stale. - if (previous?.status === "bound") return previous + // A re-check still in flight is not a failed one: render the previous outcome as it was, + // linked or not. Marking a link stale here dropped the workspace contents and memory line, + // and letting a previous "unbound" fall through rendered "could not be verified"; either + // re-created the cached system prefix for one step. Only the resolver marks an outcome stale. + if (previous && previous.status !== "unknown") return previous // Under a pin the project's own cached link is not the answer — the pin outranks it // in the resolver, and an invalid pin fails closed — so a cold memo renders unknown // rather than the workspace the pin exists to override. @@ -468,7 +479,7 @@ export async function systemSection(opts: { memoryTools?: MemoryTools } = {}): P }) const key = keyFor(scope, directory) const hit = memo.get(key) - if (fresh(hit)) return withContents(hit!.outcome, directory, render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory, opts.memoryTools))) + if (fresh(hit)) return withContents(hit!.outcome, directory, render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory, scope, opts.memoryTools))) // The fallback is itself raced against a small budget, so the wait is // bounded by RESOLVE_DEADLINE_MS + FALLBACK_BUDGET_MS, not by the disk. const deadline = after(RESOLVE_DEADLINE_MS, () => @@ -476,7 +487,7 @@ export async function systemSection(opts: { memoryTools?: MemoryTools } = {}): P ) try { const outcome = await Promise.race([resolve(key, directory), deadline]) - return withContents(outcome, directory, render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory, opts.memoryTools))) + return withContents(outcome, directory, render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory, scope, opts.memoryTools))) } finally { for (const t of timers) clearTimeout(t) } diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index df928a7cd3..b407e1a771 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -280,6 +280,34 @@ export function memoryEnabledCached(binding: CachedBinding): "enabled" | "disabl return "unknown" } +/** The last answer any enablement check gave, per account and workspace, with no expiry on + * read. For the system prompt's team-memory line, which renders on every step into the cached + * prefix: reading the 60 s positive TTL there dropped the line a minute after each check and + * re-created the prefix. Keyed by tenant and API URL as well as id (ids are tenant-local). + * A failed or inconclusive check keeps the previous answer; a definite one replaces it. */ +const memoryVerdict = new Map() + +function verdictKey(tenant: string, apiUrl: string, datamateId: number): string { + return `${tenant}|${apiUrl}|${datamateId}` +} + +async function verdictKeyFor(binding: CachedBinding): Promise { + const creds = await AltimateApi.getCredentials().catch(() => undefined) + if (!creds?.altimateInstanceName || !creds.altimateUrl) return undefined + return verdictKey(creds.altimateInstanceName, creds.altimateUrl, binding.datamateId) +} + +/** The workspace's memory setting for a per-step reader: a check inside the positive or + * negative TTL first, else the last answer recorded for this account. Never the network. */ +export function memoryEnabledLastKnown( + binding: CachedBinding, + account: { tenant: string; apiUrl: string }, +): "enabled" | "disabled" | "unknown" { + const cached = memoryEnabledCached(binding) + if (cached !== "unknown") return cached + return memoryVerdict.get(verdictKey(account.tenant, account.apiUrl, binding.datamateId)) ?? "unknown" +} + /** Test seam: both memos are process-global, and an earlier case's answer * would otherwise leak into a later one. */ /** Test seam: record the workspace's "memory off" answer without a request. */ @@ -290,6 +318,7 @@ export function noteMemoryDisabledForTests(datamateId: number): void { export function resetEnablementMemoForTests(): void { memoryEnabledCache.clear() memoryDisabledMemo.clear() + memoryVerdict.clear() } /** Warn once per workspace, not once per write. */ @@ -337,6 +366,8 @@ async function memoryStatus( ): Promise { const cached = opts.fresh ? undefined : memoryEnabledCache.get(binding.datamateId) if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled" + // Read before the request, so the answer is filed under the account that asked. + const verdictAt = await verdictKeyFor(binding) try { const workspaces = await WorkspaceApi.listDatamates() const match = workspaces.find((w) => w.id === binding.datamateId) @@ -362,6 +393,7 @@ async function memoryStatus( }) } const value = match.memoryEnabled === true + if (verdictAt) memoryVerdict.set(verdictAt, value ? "enabled" : "disabled") if (value) { memoryEnabledCache.set(binding.datamateId, { checkedAt: Date.now() }) memoryDisabledMemo.delete(binding.datamateId) @@ -1259,6 +1291,7 @@ export function resetPollMemoForTests(): void { pollInFlight.clear() memoryEnabledCache.clear() memoryDisabledMemo.clear() + memoryVerdict.clear() } /** Split blocks into those the workspace still needs and those already there at diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index fe5ca5ea0f..bb5582c794 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -4,7 +4,8 @@ // call `prompt.ts` makes on every step — renders for a real binding cache, a real // instance context, and the pilot flag in each position. The pure `render()` is // covered in identity.test.ts; this file is about the gate and the plumbing. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, setSystemTime, test } from "bun:test" +import { createHash } from "node:crypto" import { mkdirSync, mkdtempSync, rmSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -363,7 +364,7 @@ describe("systemSection", () => { } }) - test("a cached binding the server cannot re-verify is named with the same hedged sentence, without contents or memory", async () => { + test("a cached binding the server cannot re-verify is named as last known, without contents or memory", async () => { await recordApprovedBinding(projectDir, { datamateId: 9, datamateName: "Finance", @@ -382,10 +383,10 @@ describe("systemSection", () => { memoryEnabledCache.set(9, { checkedAt: Date.now() }) try { const out = await inProject(systemSection) - // Same sentence as a verified link; freshness must not change the cached prefix. - expect(out).toContain("This project is linked to Altimate Workspace id 9") + // An unconfirmed link is hedged, not stated as fact. + expect(out).toContain("This project was last known to be linked to Altimate Workspace id 9") expect(out).toContain('is "Finance"') - expect(out).toContain("re-checked every few minutes") + expect(out).toContain("could not be re-verified just now") // A link that may have changed must not be followed by that workspace's contents as if current. expect(out).not.toContain("What this Altimate Workspace provides") expect(out).not.toContain("Team memory:") @@ -477,7 +478,7 @@ describe("systemSection", () => { globalThis.fetch = (async () => { throw new Error("offline") }) as unknown as typeof fetch - expect(await inProject(systemSection)).toContain("This project is linked to Altimate Workspace id 5") + expect(await inProject(systemSection)).toContain("This project was last known to be linked to Altimate Workspace id 5") resetOutcomeMemoForTests() // Now a server that never answers: the deadline renders the binding the local // cache holds — not the unknown copy, and not a stall. @@ -485,7 +486,7 @@ describe("systemSection", () => { const started = Date.now() const out = await inProject(systemSection) expect(Date.now() - started).toBeLessThan(RESOLVE_DEADLINE_MS + 500) - expect(out).toContain("This project is linked to Altimate Workspace id 5") + expect(out).toContain("This project was last known to be linked to Altimate Workspace id 5") expect(out).toContain('is "Ops"') expect(out).not.toContain("could not be verified just now") }) @@ -959,3 +960,83 @@ describe("systemSection", () => { expect(out).toContain("could not be verified") }) }) + +// The section is part of the cached system prefix, so it must hash the same on every step of a +// session whatever the clocks do: the identity memo (30 s), the enablement check (60 s), the +// resolver's validation window and miss memo (5 min), and a re-check slower than the deadline. +// `setSystemTime` moves `Date.now` for every module at once; timers stay real. +describe("prefix hash over steps with a stepped clock", () => { + const sha = (text: string) => createHash("sha256").update(text).digest("hex") + const notFound = (async () => + new Response(JSON.stringify({ detail: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + const hanging = (() => new Promise(() => {})) as unknown as typeof fetch + + afterEach(() => { + setSystemTime() + }) + + test("an unlinked project: one hash across steps, including a re-check slower than the deadline", async () => { + const t0 = Date.now() + globalThis.fetch = notFound + const hashes: string[] = [] + const step = async (at: number, f: typeof fetch) => { + setSystemTime(new Date(t0 + at)) + globalThis.fetch = f + hashes.push(sha(await inProject(systemSection))) + } + await step(0, notFound) + await step(31_000, notFound) + await step(4 * 60_000, notFound) + // Both the outcome memo and the miss memo have expired: the step asks the server, which hangs. + await step(5 * 60_000 + 1_000, hanging) + await step(5 * 60_000 + 40_000, notFound) + await step(11 * 60_000, notFound) + expect(new Set(hashes).size).toBe(1) + expect(hashes).toHaveLength(6) + }) + + test("a linked project with memory on: one hash across steps, the team-memory line never drops", async () => { + const { memoryEnabledForPoller, resetEnablementMemoForTests, resetPollMemoForTests } = await import( + "../../../src/altimate/workspace/memory-sync" + ) + const t0 = Date.now() + const serving = (async (input: any) => + new Response( + JSON.stringify( + String(input).includes("/datamates/") ? { datamates: [{ id: 64, name: "Team", memory_enabled: true }] } : {}, + ), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch + globalThis.fetch = serving + const binding = { datamateId: 64, datamateName: "Team", repoRemote: null, projectPath: projectDir, linkedAt: t0 } + await recordApprovedBinding(projectDir, binding, { awaitBackfill: true }) + resetEnablementMemoForTests() + resetPollMemoForTests() + try { + // The enablement check the TUI poll (or a mirror, or the backfill) runs. + expect(await memoryEnabledForPoller(binding as never)).toBe("enabled") + const outs: string[] = [] + const step = async (at: number, f: typeof fetch) => { + setSystemTime(new Date(t0 + at)) + globalThis.fetch = f + outs.push(await inProject(systemSection)) + } + await step(0, serving) + await step(30_000, serving) + // Past the 60 s enablement TTL with no new check. + await step(61_000, serving) + await step(2 * 60_000, serving) + // Past the resolver's validation window and the summary TTL: the re-check hangs. + await step(5 * 60_000 + 1_000, hanging) + await step(5 * 60_000 + 20_000, hanging) + for (const out of outs) expect(out).toContain("Team memory:") + expect(new Set(outs.map(sha)).size).toBe(1) + } finally { + resetEnablementMemoForTests() + resetPollMemoForTests() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/identity.test.ts b/packages/opencode/test/altimate/workspace/identity.test.ts index f76027c996..2a1ff449e9 100644 --- a/packages/opencode/test/altimate/workspace/identity.test.ts +++ b/packages/opencode/test/altimate/workspace/identity.test.ts @@ -373,7 +373,7 @@ describe("workspaceLabel budget boundary", () => { // This section sits in the cached system prefix and renders on every step. Any // wording that depends on memo freshness flips the prefix on a timer and re-creates it at -// full input rate, so fresh and stale outcomes for the same state must render identically. +// full input rate, so a memoised and a fresh answer for the same state must render identically. describe("cache stability — memo freshness never changes the text", () => { const binding = (pinned: boolean) => ({ datamateId: 4821, @@ -388,13 +388,17 @@ describe("cache stability — memo freshness never changes the text", () => { expect(render({ status: "unbound", stale: true })).toBe(render({ status: "unbound" })) }) + // A stale bound outcome is a link the server could not confirm (only the resolver marks one + // stale). That state already drops contents and the memory line, so byte equality buys nothing + // there; the copy keeps the hedge rather than stating an unconfirmed link as fact. for (const pinned of [false, true]) { - for (const teamMemory of [false, true]) { - test(`bound (pinned=${pinned}, teamMemory=${teamMemory}): stale renders byte-identical to fresh`, () => { - const fresh = render({ status: "bound", binding: binding(pinned) }, undefined, { teamMemory }) - const stale = render({ status: "bound", binding: binding(pinned), stale: true }, undefined, { teamMemory }) - expect(stale).toBe(fresh) - }) - } + test(`bound (pinned=${pinned}): an unconfirmed link is hedged, not stated as fact`, () => { + const fresh = render({ status: "bound", binding: binding(pinned) }) + const stale = render({ status: "bound", binding: binding(pinned), stale: true }) + expect(stale).not.toBe(fresh) + expect(stale).toContain(pinned ? "This session was last known to be pinned" : "This project was last known to be linked") + expect(stale).toContain(`The ${pinned ? "pin" : "link"} could not be re-verified just now, so it may since have changed.`) + expect(fresh).not.toContain("last known") + }) } }) diff --git a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts index cd44ea5446..83e966a27f 100644 --- a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts @@ -208,13 +208,12 @@ describe("v0.12.1 adversarial: identity copy across pin × stale × unbound", () test("every bound combination names the id and states the right subject", () => { const cases: Array<[BindingOutcome, RegExp, RegExp]> = [ - // Stale and fresh render the same subject: the copy sits in the cached prefix. - [{ status: "bound", binding: binding(false) }, /^This project is linked to Altimate Workspace id 237;/m, /pinned/], - [{ status: "bound", binding: binding(false), stale: true }, /^This project is linked to Altimate Workspace id 237;/m, /pinned/], - [{ status: "bound", binding: binding(true) }, /^This session is pinned by the IDE extension to Altimate Workspace id 237;/m, /This project is linked/], + [{ status: "bound", binding: binding(false) }, /^This project is linked to Altimate Workspace id 237;/m, /last known|pinned/], + [{ status: "bound", binding: binding(false), stale: true }, /^This project was last known to be linked to Altimate Workspace id 237;/m, /pinned/], + [{ status: "bound", binding: binding(true) }, /^This session is pinned by the IDE extension to Altimate Workspace id 237;/m, /last known|This project is linked/], [ { status: "bound", binding: binding(true), stale: true }, - /^This session is pinned by the IDE extension to Altimate Workspace id 237;/m, + /^This session was last known to be pinned by the IDE extension to Altimate Workspace id 237;/m, /This project is linked/, ], ] From 36ec951870ecb9af5843163c19b2591bba336ca3 Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Thu, 5 Nov 2026 15:46:11 +0530 Subject: [PATCH 3/4] fix(workspace): a failed summary refresh keeps the cached contents A refresh at the 5-minute TTL that fails now keeps the previous summary (and retries next step) instead of replacing it with "not known", which dropped the integrations, memory and knowledge lines for that step. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/workspace/contents.ts | 12 ++++++++---- .../test/altimate/workspace/contents.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/contents.ts b/packages/opencode/src/altimate/workspace/contents.ts index 7bdfc4f8fe..99af57e350 100644 --- a/packages/opencode/src/altimate/workspace/contents.ts +++ b/packages/opencode/src/altimate/workspace/contents.ts @@ -176,7 +176,7 @@ export async function workspaceSkills(directory: string, datamateId: number): Pr type Summary = { integrations: string[] | null; memoryEnabled: boolean | null; knowledge: WorkspaceKnowledge | null } const UNKNOWN: Summary = { integrations: null, memoryEnabled: null, knowledge: null } /** `complete`: everything the response promised was read, so the entry may be reused. */ -type Fetched = { value: Summary; complete: boolean } +type Fetched = { value: Summary; complete: boolean; failed?: boolean } /** Keyed by account AND workspace id: ids are per tenant, so another account's workspace with the * same id must never be answered from this cache. */ const summaries = new Map }>() @@ -239,7 +239,7 @@ async function fetchSummary(datamateId: number, creds: Credentials): Promise { + // A failed refresh keeps the answer it was refreshing, filed with `at: 0` so the next step + // retries. Replacing it with "not known" dropped lines from the cached system prefix. + const kept: Fetched = fetched.failed && previous ? { value: previous, complete: false } : fetched // An incomplete fetch is kept for this step only, so the next step retries. - summaries.set(key, { at: fetched.complete ? Date.now() : 0, value: fetched.value }) - return fetched + summaries.set(key, { at: kept.complete ? Date.now() : 0, value: kept.value }) + return kept }) summaries.set(key, { at: hit?.at ?? 0, value: hit?.value, pending }) } diff --git a/packages/opencode/test/altimate/workspace/contents.test.ts b/packages/opencode/test/altimate/workspace/contents.test.ts index 1bcb3ecd57..8a3634b19f 100644 --- a/packages/opencode/test/altimate/workspace/contents.test.ts +++ b/packages/opencode/test/altimate/workspace/contents.test.ts @@ -236,6 +236,25 @@ describe("workspace summary", () => { expect(spy).toHaveBeenCalledTimes(2) }) + // The summary renders into the cached system prefix on every step. A refresh at the TTL that + // fails must keep the good answer it was refreshing, or the Integrations, memory and knowledge + // lines vanish for that step and the whole prefix is re-created. + test("a failed refresh past the TTL keeps the cached answer, and the next step retries", async () => { + const good = summary({ integrations: [{ id: "github" }], memory_enabled: true }) + const spy = spyOn(AltimateApi, "getDatamate") + .mockResolvedValueOnce(good) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(summary({ integrations: [{ id: "snowflake" }], memory_enabled: true })) + const t0 = Date.now() + const expected = { integrations: ["github"], memoryEnabled: true, knowledge: null } + expect(await Contents.workspaceSummary(39, t0)).toEqual(expected) + const late = t0 + 5 * 60_000 + 1 + expect(await Contents.workspaceSummary(39, late)).toEqual(expected) + // Still the good answer, and not cached as fresh: the next step asks again. + expect(await Contents.workspaceSummary(39, late)).toEqual({ integrations: ["snowflake"], memoryEnabled: true, knowledge: null }) + expect(spy).toHaveBeenCalledTimes(3) + }) + test("a slow service does not hold the step past its wait; the answer arrives on a later step", async () => { let release!: (v: any) => void const spy = spyOn(AltimateApi, "getDatamate").mockImplementation(() => new Promise((r) => (release = r)) as any) From 3d2a47381284dda3af1d4a03394ad347133d71f4 Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Thu, 5 Nov 2026 15:46:11 +0530 Subject: [PATCH 4/4] test(session): assert the marked prefix through the real prompt loop Replaces the hand-built `LLM.stream` test and the source-text guard (both passed on main) with a loop-level test against a mock OpenAI-compatible server: a builder turn after a plan turn carries the hoisted plan-to-build reminder as system text, and the first system message is byte-identical to the earlier builder turn. Fails with the previous `prompt.ts`. Drops the empty `altimate_change` pair. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/prompt.ts | 2 - .../session/system-prefix-stability.test.ts | 299 +++++++++--------- 2 files changed, 152 insertions(+), 149 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bd23f478ab..e2ff48739f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2065,8 +2065,6 @@ export namespace SessionPrompt { // altimate_change start — persisted plain-text section has a stable position ...(teamRules ? [teamRules] : []), // altimate_change end - // altimate_change start — hoisted reminders go in `environment` below, not here - // altimate_change end ] // altimate_change start — run-mode-only completion instruction. This text // used to sit in builder.txt, but builder is a PRIMARY agent, so it also diff --git a/packages/opencode/test/session/system-prefix-stability.test.ts b/packages/opencode/test/session/system-prefix-stability.test.ts index 5e74936ebe..d95e1fedaa 100644 --- a/packages/opencode/test/session/system-prefix-stability.test.ts +++ b/packages/opencode/test/session/system-prefix-stability.test.ts @@ -1,155 +1,160 @@ -// The marked system prefix must be byte-identical across the requests of a -// session. Text that appears on some steps only (the post-compaction task pin, the plan -// switch reminder) is hoisted out of the user turn on non-Anthropic models; it has to land -// after the cache breakpoint, or every first request after a compaction re-creates the -// whole prefix at full input rate. -import { describe, expect, spyOn, test } from "bun:test" -import fs from "node:fs/promises" -import path from "node:path" -import { MockLanguageModelV3 } from "ai/test" -import { jsonSchema, simulateReadableStream, tool } from "ai" -import { Auth } from "../../src/auth" -import { Config } from "../../src/config/config" -import { Plugin } from "../../src/plugin" -import { Instance } from "../../src/project/instance" -import { ProjectID } from "../../src/project/schema" -import { Provider } from "../../src/provider/provider" -import { ProviderTransform } from "../../src/provider/transform" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { LLM } from "../../src/session/llm" +// The marked system prefix (the first system message, which carries the cache breakpoint) must be +// byte-identical across the requests of a session. Text that appears on some steps only is hoisted +// out of the user turn on models that are not Anthropic-like, which includes the Altimate gateway's +// OpenAI-compatible model. It must ride after the breakpoint, or that request re-creates the whole +// prefix at full input rate. Driven through the real prompt loop against a mock OpenAI-compatible +// server, so a change to how `prompt.ts` assembles the request is what this test sees. +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" -import { MessageID, SessionID } from "../../src/session/schema" -import { SystemPrompt } from "../../src/session/system" -import { tmpdir } from "../fixture/fixture" +import { SessionSummary } from "../../src/session/summary" +import { MessageID } from "../../src/session/schema" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { testEffect } from "../lib/effect" +import { TestLLMServer } from "../lib/llm-server" +import { provideTmpdirServerLegacy } from "./legacy-instance" +import { LSP } from "@/lsp/lsp" +import { MCP } from "../../src/mcp" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { RuntimeFlags } from "@/effect/runtime-flags" -const PIN = - "\nOriginal task — authoritative over any summary. Optimise the marts.\n" +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + remove: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + entry: () => Effect.succeed(undefined), + listMeta: () => Effect.succeed(undefined), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), + }), +) -const gatewayModel: Provider.Model = { - id: ModelID.make("altimate-default"), - providerID: ProviderID.make("altimate-backend"), - api: { id: "altimate-default", url: "https://example.invalid", npm: "@ai-sdk/openai-compatible" }, - name: "Altimate LLM Gateway", - family: "openai", - capabilities: { - temperature: true, - reasoning: false, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 200_000, output: 4096 }, - status: "active", - options: {}, - headers: {}, - release_date: "2026-01-01", -} +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) -describe("system prefix stability", () => { - test("precondition: the gateway model hoists reminders and uses explicit cache breakpoints", () => { - expect(SessionPrompt.isAnthropicLikeModel(gatewayModel)).toBe(false) - expect(ProviderTransform.usesExplicitCacheBreakpoints(gatewayModel)).toBe(true) - }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + SessionSummary.node, + Database.node, + CrossSpawnSpawner.node, + LayerNode.make(TestLLMServer.layer, []), +]) +const it = testEffect( + LayerNode.buildLayer(root, { + replacements: [ + LayerNode.replace(MCP.node, mcp), + LayerNode.replace(LSP.node, lsp), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), + ], + }), +) - test("a hoisted task pin rides in the trailing system message; the marked prefix is unchanged", async () => { - const language = new MockLanguageModelV3({ - provider: "altimate-backend", - modelId: gatewayModel.api.id, - doStream: async () => ({ - stream: simulateReadableStream({ - chunks: [ - { - type: "finish" as const, - finishReason: { unified: "stop" as const, raw: "stop" }, - usage: { - inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, - outputTokens: { total: 0, text: 0, reasoning: 0 }, - }, - }, - ], - initialDelayInMs: null, - chunkDelayInMs: null, - }), - }), - }) - const mocks = [ - spyOn(Provider, "getLanguage").mockResolvedValue(language), - spyOn(Provider, "getProvider").mockResolvedValue({ - id: gatewayModel.providerID, - name: "Altimate AI", - source: "custom", - env: [], - options: {}, - models: {}, - }), - spyOn(Config, "get").mockResolvedValue({}), - spyOn(Auth, "get").mockResolvedValue(undefined), - spyOn(Plugin, "trigger").mockImplementation(async (_name, _input, output) => output), - ] - try { - await using dir = await tmpdir() - await Instance.restore( - { - directory: dir.path, - worktree: dir.path, - project: { id: ProjectID.global, worktree: dir.path, time: { created: 0, updated: 0 }, sandboxes: [] }, - }, - async () => { - const sessionID = SessionID.make("ses_prefix_stability") - const environment = await SystemPrompt.environment(gatewayModel) - // Before compaction: no reminder. After: the loop passes the pin with the environment. - for (const [index, hoisted] of [[], [PIN]].entries()) { - const result = await LLM.stream({ - sessionID, - model: gatewayModel, - agent: { name: "test", mode: "primary", options: {}, permission: [] }, - user: { - id: MessageID.make(`msg_prefix_${index}`), - sessionID, - role: "user", - agent: "test", - model: { providerID: gatewayModel.providerID, modelID: gatewayModel.id }, - time: { created: 0 }, - }, - system: ["Agent instructions.", "Skill catalogue."], - environment: [...environment, ...hoisted], - messages: [{ role: "user", content: "Continue." }], - tools: { - inspect: tool({ description: "Inspect the project", inputSchema: jsonSchema({ type: "object" }) }), - }, - abort: new AbortController().signal, - }) - await result.consumeStream({ - onError: (error) => { - throw error - }, - }) - } +// Declared under an Altimate provider id: only those receive the trace. +const providerCfg = (url: string) => ({ + provider: { + "altimate-backend": { + name: "Altimate (test)", + id: "altimate-backend", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, }, - ) + }, + options: { apiKey: "test-key", baseURL: url }, + }, + }, + model: "altimate-backend/test-model", +}) - expect(language.doStreamCalls).toHaveLength(2) - const systems = language.doStreamCalls.map((call) => call.prompt.filter((message) => message.role === "system")) - for (const system of systems) expect(system).toHaveLength(2) - // The marked prefix (first system message) is byte-identical, cache marker included. - expect(JSON.stringify(systems[1][0])).toBe(JSON.stringify(systems[0][0])) - expect(JSON.stringify(systems[0][0].providerOptions)).toContain("ephemeral") - expect(String(systems[1][0].content)).not.toContain("Original task") - // The pin is in the trailing message only. - expect(String(systems[0][1].content)).not.toContain("Original task") - expect(String(systems[1][1].content)).toContain("Original task") - } finally { - for (const mock of mocks) mock.mockRestore() - } - }) +const SWITCH_MARK = "Your operational mode has changed from plan to build." - test("the loop passes hoisted reminders with the environment, never inside the system array", async () => { - const src = await fs.readFile(path.join(__dirname, "../../src/session/prompt.ts"), "utf-8") - const systemArray = src.slice(src.indexOf("const system = ["), src.indexOf("const completionInstruction")) - expect(systemArray).not.toContain("...hoistedReminders") - expect(src).toMatch(/environment: \[\.\.\.\(await SystemPrompt\.environment\(model\)\), \.\.\.hoistedReminders\]/) - }) -}) +type ChatMessage = { role: string; content: unknown } +const chatMessages = (body: Record) => (Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : []) +const text = (content: unknown) => (typeof content === "string" ? content : JSON.stringify(content)) + +it.live("a hoisted reminder rides after the breakpoint: the first system message is unchanged", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "prefix stability", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const turn = Effect.fnUntraced(function* (agent: string, said: string) { + yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes(said), "ok") + yield* prompt.prompt({ sessionID: session.id, agent, parts: [{ type: "text", text: said }] }) + }) + // builder, then plan, then builder again: the third turn carries the hoisted + // plan-to-build switch reminder; the first carries no reminder at all. + yield* turn("builder", "first builder turn") + yield* turn("plan", "plan turn") + yield* turn("builder", "second builder turn") + + const hits = (yield* llm.hits).filter((hit) => !JSON.stringify(hit.body).includes("Generate a title")) + const of = (said: string) => hits.find((hit) => JSON.stringify(hit.body).includes(said))! + const before = chatMessages(of("first builder turn").body) + const after = chatMessages(of("second builder turn").body) + const systems = (messages: ChatMessage[]) => messages.filter((m) => m.role === "system").map((m) => text(m.content)) + // Precondition: the reminder was sent on the later turn, as system text. + expect(systems(after).some((s) => s.includes(SWITCH_MARK))).toBe(true) + expect(systems(before).some((s) => s.includes(SWITCH_MARK))).toBe(false) + // The marked prefix is byte-identical; the reminder is not in it. + expect(before[0].role).toBe("system") + expect(after[0].role).toBe("system") + expect(text(after[0].content)).toBe(text(before[0].content)) + expect(text(after[0].content)).not.toContain(SWITCH_MARK) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +)