From 5f6201af5e1e43f74093c352ac118c44c50457b7 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 5 Nov 2026 17:44:03 +0800 Subject: [PATCH 1/3] fix: a pinned session's telemetry names the picked workspace The turn's rows read only the on-disk link, which the pin is never written to. Resolve it the way routing does: the pin first, then the link. A pin that cannot be honoured names nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- .../opencode/src/altimate/workspace/state.ts | 29 ++ packages/opencode/src/session/prompt.ts | 12 +- .../altimate/workspace/routing-pin.test.ts | 43 ++- .../session/pinned-workspace-loop.test.ts | 288 ++++++++++++++++++ 4 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/session/pinned-workspace-loop.test.ts diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 75c7a30b5d..dce56af659 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -751,6 +751,35 @@ export async function resolvePinnedBindingForRouting( return resolvePinnedBinding(directory, pin) } +/** How long a turn waits for the pin's verdict before its rows go without a workspace. Past the + * validation TTL the verdict is a round trip, and the routing read later in the same turn joins that + * request, so giving up here costs routing nothing. Waiting out the request instead would make a + * failed validation run twice, once here and once for routing. */ +export const TURN_PIN_WAIT_MS = 3_000 + +/** The workspace a turn's telemetry rows name: the pin when there is one, else the project's link + * from the local cache. + * + * The pin's verdict comes from `resolvePinnedBindingForRouting`, so a row and the turn's routing + * cannot disagree. Unpinned turns stay on the cache read, with no credentials and no network. A pin + * that cannot be honoured in time names nothing, for the reason routing fails closed: falling + * through would credit the turn to a workspace the user did not pick. */ +export async function resolveTurnBinding( + directory: string, + waitMs = TURN_PIN_WAIT_MS, +): Promise { + let timer: ReturnType | undefined + const deadline = new Promise((done) => { + timer = setTimeout(() => done({ status: "unknown" }), waitMs) + timer.unref?.() + }) + const pinned = await Promise.race([resolvePinnedBindingForRouting(directory), deadline]).finally(() => + clearTimeout(timer), + ) + if (pinned) return pinned.status === "bound" ? pinned.binding : null + return readLocalBinding(directory) +} + export async function resolveBindingOutcome(directory: string): Promise { // altimate_change — the IDE extension's selection outranks whatever binding this project carries. // Checked before the local cache and before any server lookup: the whole point is that the panel, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bbc390bb9e..cd3d89227c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,7 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload // altimate_change start — the session's bound workspace rides on every telemetry row; workspace rules canonicalize bound // directories like the bindings cache does -import { canonicalDirectory, readLocalBinding } from "@/altimate/workspace/state" +import { canonicalDirectory, resolveTurnBinding } from "@/altimate/workspace/state" /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the @@ -812,9 +812,9 @@ export namespace SessionPrompt { throw e } // altimate_change end - // altimate_change start — the workspace this project is linked to (local binding cache, no - // network), named by the rows of this turn that carry workspace_id. - const boundWorkspace = await readLocalBinding(Instance.directory).catch(() => null) + // altimate_change start — the workspace this turn works against (the IDE extension's pin, else + // the project's link), named by the rows of this turn that carry workspace_id. + const boundWorkspace = await resolveTurnBinding(Instance.directory).catch(() => null) const telemetryTurn = Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "", @@ -3381,8 +3381,8 @@ export namespace SessionPrompt { const model = await Provider.getModel(info.model.providerID, info.model.modelID) const readCtx: Tool.Context = { sessionID: input.sessionID, - // altimate_change — the project's binding, so the attachment read's tool rows name the workspace - workspaceId: (await readLocalBinding(Instance.directory).catch(() => null))?.datamateId, + // altimate_change — the turn's workspace, so the attachment read's tool rows name it + workspaceId: (await resolveTurnBinding(Instance.directory).catch(() => null))?.datamateId, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, diff --git a/packages/opencode/test/altimate/workspace/routing-pin.test.ts b/packages/opencode/test/altimate/workspace/routing-pin.test.ts index 530c73307b..221b5cdbca 100644 --- a/packages/opencode/test/altimate/workspace/routing-pin.test.ts +++ b/packages/opencode/test/altimate/workspace/routing-pin.test.ts @@ -17,7 +17,7 @@ const SANDBOX = path.join(os.tmpdir(), `altimate-routing-pin-test-${process.pid} mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") -const { resolvePinnedBindingForRouting, recordApprovedBinding, __resetPinValidation, cachePath } = +const { resolvePinnedBindingForRouting, resolveTurnBinding, recordApprovedBinding, __resetPinValidation, cachePath } = await import("../../../src/altimate/workspace/state") const { refresh, precedenceInternals } = await import("../../../src/altimate/workspace/precedence") const { Instance } = await import("../../../src/project/instance") @@ -172,6 +172,47 @@ describe("resolvePinnedBindingForRouting", () => { }) }) +describe("resolveTurnBinding", () => { + test("the pin outranks the project's link", async () => { + await seedLocalLink(7, "project-link") + setPin() + expect((await resolveTurnBinding(ROOT))?.datamateId).toBe(42) + }) + + test("with no pin it is the project's link, and the account is not consulted", async () => { + await seedLocalLink(7, "project-link") + expect((await resolveTurnBinding(ROOT))?.datamateId).toBe(7) + expect(listCalls).toBe(0) + }) + + test("a malformed pin names nothing, not the project's link", async () => { + await seedLocalLink(7, "project-link") + setPin({ ALTIMATE_PINNED_WORKSPACE_ID: "not-a-number" }) + expect(await resolveTurnBinding(ROOT)).toBeNull() + }) + + test("a directory outside the pinned root names nothing, not the project's link", async () => { + await seedLocalLink(7, "project-link") + setPin({ ALTIMATE_PINNED_WORKSPACE_ROOT: path.join(SANDBOX, "elsewhere") }) + expect(await resolveTurnBinding(ROOT)).toBeNull() + }) + + test("a validation that outlasts the wait names nothing, and routing joins that same request", async () => { + await seedLocalLink(7, "project-link") + const answer = Promise.withResolvers<{ id: number; name: string }[]>() + ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = () => { + listCalls += 1 + return answer.promise + } + setPin() + expect(await resolveTurnBinding(ROOT, 5)).toBeNull() + const routing = resolvePinnedBindingForRouting(ROOT) + answer.resolve([{ id: 42, name: "pinned-workspace" }]) + expect((await routing)?.status).toBe("bound") + expect(listCalls).toBe(1) + }) +}) + describe("precedence.derive — the routing read", () => { const SESSION = "ses_routing_pin" diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts new file mode 100644 index 0000000000..8f7dd409c1 --- /dev/null +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -0,0 +1,288 @@ +// altimate_change - new file +/** + * The workspace a pinned session's rows name, through the real prompt loop. The IDE extension's pin + * outranks the project's own link everywhere else, so a turn's rows have to name the pin too: the + * loop's `session_start` and the tool rows of a model call and of an attached file. The project's + * own link (id 7) differs from the pin (id 42) in every test, so a row that read the wrong source + * cannot pass by accident. + */ +import { afterEach, beforeEach, expect, spyOn } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { pathToFileURL } from "node:url" +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 { SessionSummary } from "../../src/session/summary" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Telemetry } from "../../src/altimate/telemetry" +import { AltimateApi } from "../../src/altimate/api/client" +import { WorkspaceApi } from "../../src/altimate/workspace/api-client" +import { __resetPinValidation, readLocalBinding, recordApprovedBinding } from "../../src/altimate/workspace/state" +import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" +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 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 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([]), + }), +) + +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 })), + ], + }), +) + +const providerCfg = (url: string) => ({ + provider: { + test: { + name: "Test", + id: "test", + 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: "test/test-model", +}) + +const PINNED = 42 +const LINKED = 7 +const PIN_VARS = [ + "ALTIMATE_CODE_SERVE", + "ALTIMATE_PINNED_WORKSPACE_ID", + "ALTIMATE_PINNED_WORKSPACE_NAME", + "ALTIMATE_PINNED_WORKSPACE_ROOT", +] + +type Row = { type: string; workspace_id?: number; tool_name?: string } +type Creds = Awaited> + +let rows: Row[] = [] +let restore: (() => void)[] = [] +let stateHome = "" +const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + +beforeEach(() => { + rows = [] + stateHome = mkdtempSync(path.join(os.tmpdir(), "pinned-loop-state-")) + process.env.OPENCODE_TEST_STATE_HOME = stateHome + __resetPinValidation() + const track = spyOn(Telemetry, "track").mockImplementation((event) => void rows.push(event as unknown as Row)) + const creds = spyOn(AltimateApi, "getCredentials").mockImplementation( + async () => ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds, + ) + const configured = spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) + restore = [ + () => track.mockRestore(), + () => creds.mockRestore(), + () => configured.mockRestore(), + stubEmptySkillList("api.test"), + ] +}) + +afterEach(() => { + for (const undo of restore) undo() + for (const name of PIN_VARS) delete process.env[name] + __resetPinValidation() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + rmSync(stateHome, { recursive: true, force: true }) +}) + +/** What the extension sets on the `serve` it spawns. */ +const pinTo = (root: string) => { + process.env.ALTIMATE_CODE_SERVE = "1" + process.env.ALTIMATE_PINNED_WORKSPACE_ID = String(PINNED) + process.env.ALTIMATE_PINNED_WORKSPACE_NAME = "pinned-workspace" + process.env.ALTIMATE_PINNED_WORKSPACE_ROOT = root +} + +const accountSees = (visible: { id: number; name: string }[]) => { + const list = spyOn(WorkspaceApi, "listDatamates").mockImplementation(async () => visible as never) + restore.push(() => list.mockRestore()) +} + +/** The project's own link, naming a different workspace than the pin. Checked, so a seed that did not + * land cannot make the "never the project's link" assertions pass on nothing. */ +const linkProject = (dir: string) => + Effect.promise(async () => { + await recordApprovedBinding( + dir, + { datamateId: LINKED, datamateName: "project-link", linkedAt: Date.now(), repoRemote: null, projectPath: dir } as never, + { awaitBackfill: true }, + ) + expect((await readLocalBinding(dir))?.datamateId).toBe(LINKED) + }) + +/** One turn with an attached file and a model read, so both ways a turn reaches a tool are covered. + * The file is missing on purpose: a successful `read` is a file tool and sends no `tool_call` row. */ +const runTurn = (dir: string, llm: TestLLMServer["Service"]) => + Effect.gen(function* () { + const file = path.join(dir, "missing.txt") + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "pinned session", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("read", { filePath: file }) + yield* llm.text("done") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: pathToFileURL(file).href, filename: "missing.txt", mime: "text/plain" }, + ], + }) + }) + +const named = (type: string) => rows.filter((row) => row.type === type) +const reads = () => named("tool_call").filter((row) => row.tool_name === "read") + +it.live("a pinned session with a different on-disk link names the pinned workspace", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + accountSees([ + { id: PINNED, name: "pinned-workspace" }, + { id: LINKED, name: "project-link" }, + ]) + yield* linkProject(dir) + pinTo(dir) + yield* runTurn(dir, llm) + + expect(named("session_start").map((row) => row.workspace_id)).toEqual([PINNED]) + // The attached file's read and the model's read. + expect(reads().map((row) => row.workspace_id)).toEqual([PINNED, PINNED]) + expect(rows.filter((row) => row.workspace_id === LINKED)).toEqual([]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + +it.live("a pinned session with no on-disk link still names the pinned workspace", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + accountSees([{ id: PINNED, name: "pinned-workspace" }]) + pinTo(dir) + yield* runTurn(dir, llm) + + expect(named("session_start").map((row) => row.workspace_id)).toEqual([PINNED]) + expect(reads().map((row) => row.workspace_id)).toEqual([PINNED, PINNED]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + +it.live("a pin this account cannot see names no workspace, not the project's link", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + accountSees([{ id: LINKED, name: "project-link" }]) + yield* linkProject(dir) + pinTo(dir) + yield* runTurn(dir, llm) + + expect(named("session_start").map((row) => row.workspace_id)).toEqual([undefined]) + expect(reads().map((row) => row.workspace_id)).toEqual([undefined, undefined]) + expect(rows.filter((row) => row.workspace_id === LINKED)).toEqual([]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + +it.live("an unpinned session keeps naming the project's link", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + accountSees([{ id: LINKED, name: "project-link" }]) + yield* linkProject(dir) + yield* runTurn(dir, llm) + + expect(named("session_start").map((row) => row.workspace_id)).toEqual([LINKED]) + expect(reads().map((row) => row.workspace_id)).toEqual([LINKED, LINKED]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) From d26b147887d77f164b2c6c5243093754783eb940 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Mon, 9 Nov 2026 18:22:12 +0800 Subject: [PATCH 2/3] fix: a turn resolves its workspace once, for its attachments and its loop The attachment reads and the loop each resolved the pin, so a slow validation could cost a wait per read and leave rows of one turn on different workspaces. The tests also restore inherited pin variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- .../opencode/src/altimate/workspace/state.ts | 33 ++++++++++- packages/opencode/src/session/prompt.ts | 12 +++- .../altimate/workspace/routing-pin.test.ts | 55 ++++++++++++++++++- .../session/pinned-workspace-loop.test.ts | 44 +++++++++++++-- 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index dce56af659..6b6e84f2fd 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -761,7 +761,7 @@ export const TURN_PIN_WAIT_MS = 3_000 * from the local cache. * * The pin's verdict comes from `resolvePinnedBindingForRouting`, so a row and the turn's routing - * cannot disagree. Unpinned turns stay on the cache read, with no credentials and no network. A pin + * cannot disagree. Unpinned turns stay on the local cache read, which makes no network call. A pin * that cannot be honoured in time names nothing, for the reason routing fails closed: falling * through would credit the turn to a workspace the user did not pick. */ export async function resolveTurnBinding( @@ -780,6 +780,37 @@ export async function resolveTurnBinding( return readLocalBinding(directory) } +/** Each session's verdict for its current turn, from the first read in the turn until the loop + * starts. Bounded: a turn that fails before its loop leaves its entry until the session's next + * turn clears it. */ +const turnVerdicts = new Map>() +const TURN_VERDICTS_MAX = 256 + +/** The workspace a session's current turn names. The first read in a turn resolves it and every + * later read in the turn gets the same answer: the attachment reads in the user message and the + * loop's start. So a turn waits for the pin at most once, adds at most one validation request, and + * names one workspace on all its rows. */ +export function turnBinding( + sessionID: string, + directory: string, + waitMs = TURN_PIN_WAIT_MS, +): Promise { + const known = turnVerdicts.get(sessionID) + if (known) return known + const verdict = resolveTurnBinding(directory, waitMs).catch(() => null) + turnVerdicts.set(sessionID, verdict) + if (turnVerdicts.size > TURN_VERDICTS_MAX) { + const oldest = turnVerdicts.keys().next() + if (!oldest.done) turnVerdicts.delete(oldest.value) + } + return verdict +} + +/** Ends the session's turn for `turnBinding`: the next read resolves afresh. */ +export function endTurnBinding(sessionID: string): void { + turnVerdicts.delete(sessionID) +} + export async function resolveBindingOutcome(directory: string): Promise { // altimate_change — the IDE extension's selection outranks whatever binding this project carries. // Checked before the local cache and before any server lookup: the whole point is that the panel, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f4ac53c768..5c24134b03 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,7 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload // altimate_change start — the session's bound workspace rides on every telemetry row; workspace rules canonicalize bound // directories like the bindings cache does -import { canonicalDirectory, resolveTurnBinding } from "@/altimate/workspace/state" +import { canonicalDirectory, endTurnBinding, turnBinding } from "@/altimate/workspace/state" /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the @@ -429,6 +429,8 @@ export namespace SessionPrompt { export const prompt = fn(PromptInput, async (input) => { const session = await Session.get(input.sessionID) + // altimate_change — a new turn: its rows name the workspace it resolves, not one an earlier turn left + endTurnBinding(input.sessionID) // altimate_change start — fork Session.Info (index zod) ≡ core Session.Info (session.ts) at the SessionRevert boundary await SessionRevert.cleanup(session as unknown as Parameters[0]) // altimate_change end @@ -576,6 +578,7 @@ export namespace SessionPrompt { } if (input.noReply === true) { + endTurnBinding(input.sessionID) // altimate_change — no loop takes this turn's workspace return message } @@ -814,7 +817,10 @@ export namespace SessionPrompt { // altimate_change end // altimate_change start — the workspace this turn works against (the IDE extension's pin, else // the project's link), named by the rows of this turn that carry workspace_id. - const boundWorkspace = await resolveTurnBinding(Instance.directory).catch(() => null) + // The verdict the user message's attachment reads used, when it has any, so every row of the turn + // names one workspace. Ended here: the next turn resolves its own. + const boundWorkspace = await turnBinding(sessionID, Instance.directory) + endTurnBinding(sessionID) const telemetryTurn = Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "", @@ -3383,7 +3389,7 @@ export namespace SessionPrompt { const readCtx: Tool.Context = { sessionID: input.sessionID, // altimate_change — the turn's workspace, so the attachment read's tool rows name it - workspaceId: (await resolveTurnBinding(Instance.directory).catch(() => null))?.datamateId, + workspaceId: (await turnBinding(input.sessionID, Instance.directory))?.datamateId, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, diff --git a/packages/opencode/test/altimate/workspace/routing-pin.test.ts b/packages/opencode/test/altimate/workspace/routing-pin.test.ts index 221b5cdbca..79480655dd 100644 --- a/packages/opencode/test/altimate/workspace/routing-pin.test.ts +++ b/packages/opencode/test/altimate/workspace/routing-pin.test.ts @@ -17,8 +17,15 @@ const SANDBOX = path.join(os.tmpdir(), `altimate-routing-pin-test-${process.pid} mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") -const { resolvePinnedBindingForRouting, resolveTurnBinding, recordApprovedBinding, __resetPinValidation, cachePath } = - await import("../../../src/altimate/workspace/state") +const { + resolvePinnedBindingForRouting, + resolveTurnBinding, + turnBinding, + endTurnBinding, + recordApprovedBinding, + __resetPinValidation, + cachePath, +} = await import("../../../src/altimate/workspace/state") const { refresh, precedenceInternals } = await import("../../../src/altimate/workspace/precedence") const { Instance } = await import("../../../src/project/instance") const { SNOWFLAKE_TOOLS } = await import("./precedence-fixture") @@ -179,7 +186,7 @@ describe("resolveTurnBinding", () => { expect((await resolveTurnBinding(ROOT))?.datamateId).toBe(42) }) - test("with no pin it is the project's link, and the account is not consulted", async () => { + test("with no pin it is the project's link, with no network call", async () => { await seedLocalLink(7, "project-link") expect((await resolveTurnBinding(ROOT))?.datamateId).toBe(7) expect(listCalls).toBe(0) @@ -213,6 +220,48 @@ describe("resolveTurnBinding", () => { }) }) +describe("turnBinding", () => { + test("every read in a turn gets the first verdict, even once validation has finished", async () => { + const answer = Promise.withResolvers<{ id: number; name: string }[]>() + ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = () => { + listCalls += 1 + return answer.promise + } + setPin() + expect(await turnBinding("ses_turn_slow", ROOT, 5)).toBeNull() + answer.resolve([{ id: 42, name: "pinned-workspace" }]) + expect(await resolvePinnedBindingForRouting(ROOT)).toMatchObject({ status: "bound" }) + expect(await turnBinding("ses_turn_slow", ROOT, 5)).toBeNull() + + endTurnBinding("ses_turn_slow") + expect((await turnBinding("ses_turn_slow", ROOT, 5))?.datamateId).toBe(42) + expect(listCalls).toBe(1) + endTurnBinding("ses_turn_slow") + }) + + test("a refused pin is checked once per turn, however many reads the turn makes", async () => { + stubList([{ id: 7, name: "project-link" }]) + setPin() + for (let read = 0; read < 3; read++) expect(await turnBinding("ses_turn_refused", ROOT)).toBeNull() + expect(listCalls).toBe(1) + + endTurnBinding("ses_turn_refused") + expect(await turnBinding("ses_turn_refused", ROOT)).toBeNull() + expect(listCalls).toBe(2) + endTurnBinding("ses_turn_refused") + }) + + test("each session has its own turn", async () => { + await seedLocalLink(7, "project-link") + expect((await turnBinding("ses_turn_a", ROOT))?.datamateId).toBe(7) + setPin() + expect((await turnBinding("ses_turn_b", ROOT))?.datamateId).toBe(42) + expect((await turnBinding("ses_turn_a", ROOT))?.datamateId).toBe(7) + endTurnBinding("ses_turn_a") + endTurnBinding("ses_turn_b") + }) +}) + describe("precedence.derive — the routing read", () => { const SESSION = "ses_routing_pin" diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts index 8f7dd409c1..15c2a37065 100644 --- a/packages/opencode/test/session/pinned-workspace-loop.test.ts +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -140,9 +140,13 @@ let rows: Row[] = [] let restore: (() => void)[] = [] let stateHome = "" const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME +let inheritedPin: Record = {} beforeEach(() => { rows = [] + // Cleared, so a pin in the runner's own environment can't decide the unpinned case; restored after. + inheritedPin = Object.fromEntries(PIN_VARS.map((name) => [name, process.env[name]])) + for (const name of PIN_VARS) delete process.env[name] stateHome = mkdtempSync(path.join(os.tmpdir(), "pinned-loop-state-")) process.env.OPENCODE_TEST_STATE_HOME = stateHome __resetPinValidation() @@ -161,7 +165,11 @@ beforeEach(() => { afterEach(() => { for (const undo of restore) undo() - for (const name of PIN_VARS) delete process.env[name] + for (const name of PIN_VARS) { + const value = inheritedPin[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + } __resetPinValidation() if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome @@ -176,9 +184,11 @@ const pinTo = (root: string) => { process.env.ALTIMATE_PINNED_WORKSPACE_ROOT = root } +/** The account's workspace list. Returns the number of times it has been asked so far. */ const accountSees = (visible: { id: number; name: string }[]) => { const list = spyOn(WorkspaceApi, "listDatamates").mockImplementation(async () => visible as never) restore.push(() => list.mockRestore()) + return () => list.mock.calls.length } /** The project's own link, naming a different workspace than the pin. Checked, so a seed that did not @@ -193,9 +203,10 @@ const linkProject = (dir: string) => expect((await readLocalBinding(dir))?.datamateId).toBe(LINKED) }) -/** One turn with an attached file and a model read, so both ways a turn reaches a tool are covered. - * The file is missing on purpose: a successful `read` is a file tool and sends no `tool_call` row. */ -const runTurn = (dir: string, llm: TestLLMServer["Service"]) => +/** One turn with attached files (one by default) and a model read, so both ways a turn reaches a tool + * are covered. The files are missing on purpose: a successful `read` is a file tool and sends no + * `tool_call` row. */ +const runTurn = (dir: string, llm: TestLLMServer["Service"], attached = 1) => Effect.gen(function* () { const file = path.join(dir, "missing.txt") const prompt = yield* SessionPrompt.Service @@ -211,7 +222,12 @@ const runTurn = (dir: string, llm: TestLLMServer["Service"]) => agent: "build", parts: [ { type: "text", text: "read this" }, - { type: "file", url: pathToFileURL(file).href, filename: "missing.txt", mime: "text/plain" }, + ...Array.from({ length: attached }, (_, i) => ({ + type: "file" as const, + url: pathToFileURL(path.join(dir, `missing-${i}.txt`)).href, + filename: `missing-${i}.txt`, + mime: "text/plain", + })), ], }) }) @@ -272,6 +288,24 @@ it.live("a pin this account cannot see names no workspace, not the project's lin { timeout: 30_000 }, ) +it.live("attached files add no check of a refused pin: the turn resolves its workspace once", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + // A refusal is never memoized, so every resolution that isn't shared asks the account again. + const asked = accountSees([{ id: LINKED, name: "project-link" }]) + pinTo(dir) + yield* runTurn(dir, llm, 0) + const withoutFiles = asked() + yield* runTurn(dir, llm, 3) + + expect(asked() - withoutFiles).toBe(withoutFiles) + expect(reads().map((row) => row.workspace_id)).toEqual([undefined, undefined, undefined, undefined, undefined]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + it.live("an unpinned session keeps naming the project's link", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) { From 965a33149561d8949c9c25cb9083237d995f2dbd Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Mon, 9 Nov 2026 18:38:17 +0800 Subject: [PATCH 3/3] test: a later turn of one session resolves its own workspace Also check that the attachment test's baseline asked the account at all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- .../session/pinned-workspace-loop.test.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts index 15c2a37065..8fef319a3c 100644 --- a/packages/opencode/test/session/pinned-workspace-loop.test.ts +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -126,6 +126,7 @@ const providerCfg = (url: string) => ({ const PINNED = 42 const LINKED = 7 +const RELINKED = 9 const PIN_VARS = [ "ALTIMATE_CODE_SERVE", "ALTIMATE_PINNED_WORKSPACE_ID", @@ -205,13 +206,13 @@ const linkProject = (dir: string) => /** One turn with attached files (one by default) and a model read, so both ways a turn reaches a tool * are covered. The files are missing on purpose: a successful `read` is a file tool and sends no - * `tool_call` row. */ -const runTurn = (dir: string, llm: TestLLMServer["Service"], attached = 1) => + * `tool_call` row. A new session unless `session` is given; returns the session it ran in. */ +const runTurn = (dir: string, llm: TestLLMServer["Service"], attached = 1, session?: { id: Session.Info["id"] }) => Effect.gen(function* () { const file = path.join(dir, "missing.txt") const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service - const session = yield* sessions.create({ + session ??= yield* sessions.create({ title: "pinned session", permission: [{ permission: "*", pattern: "*", action: "allow" }], }) @@ -230,6 +231,7 @@ const runTurn = (dir: string, llm: TestLLMServer["Service"], attached = 1) => })), ], }) + return session }) const named = (type: string) => rows.filter((row) => row.type === type) @@ -296,6 +298,7 @@ it.live("attached files add no check of a refused pin: the turn resolves its wor pinTo(dir) yield* runTurn(dir, llm, 0) const withoutFiles = asked() + expect(withoutFiles).toBeGreaterThan(0) yield* runTurn(dir, llm, 3) expect(asked() - withoutFiles).toBe(withoutFiles) @@ -306,6 +309,30 @@ it.live("attached files add no check of a refused pin: the turn resolves its wor { timeout: 30_000 }, ) +it.live("a later turn of the same session names the workspace it resolves, not the first turn's", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + accountSees([{ id: LINKED, name: "project-link" }]) + yield* linkProject(dir) + const session = yield* runTurn(dir, llm) + yield* Effect.promise(() => + recordApprovedBinding( + dir, + { datamateId: RELINKED, datamateName: "relinked", linkedAt: Date.now(), repoRemote: null, projectPath: dir } as never, + { awaitBackfill: true }, + ), + ) + expect((yield* Effect.promise(() => readLocalBinding(dir)))?.datamateId).toBe(RELINKED) + yield* runTurn(dir, llm, 1, session) + + expect(named("task_outcome_signal").map((row) => row.workspace_id)).toEqual([LINKED, RELINKED]) + expect(reads().map((row) => row.workspace_id)).toEqual([LINKED, LINKED, RELINKED, RELINKED]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 40_000 }, +) + it.live("an unpinned session keeps naming the project's link", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) {