diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 8874e5a7a..afd64ed20 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -436,15 +436,19 @@ export async function atTurnStart(sessionID: string, body: () => Promise): * another session's boundary mid-turn would otherwise be re-catalogued here; * pinning keeps this turn on the engine its boundary read. A call through a * pinned wrapper after a replacement reaches the closed client and fails — it - * never routes to the other workspace. */ -const turnTools = new Map>() + * never routes to the other workspace. + * + * The first catalog's key order is kept too. The provider receives tools in + * record order, and a reorder between steps misses its prompt cache for the + * whole request. */ +const turnTools = new Map; order: string[] }>() export function pinTurnTools(sessionID: string, firstCatalog: boolean, tools: Record): void { if (!isEnabled() || isServe()) return const engine = Object.fromEntries(Object.entries(tools).filter(([key]) => key.startsWith(TOOL_PREFIX))) if (firstCatalog) { turnTools.delete(sessionID) - turnTools.set(sessionID, engine) + turnTools.set(sessionID, { engine, order: Object.keys(tools) }) while (turnTools.size > MAX_TRACKED_SESSIONS) { const oldest = turnTools.keys().next().value if (oldest === undefined) break @@ -454,8 +458,15 @@ export function pinTurnTools(sessionID: string, firstCatalog: boolean, tools: } const pinned = turnTools.get(sessionID) if (!pinned) return - for (const key of Object.keys(engine)) delete tools[key] - for (const [key, tool] of Object.entries(pinned)) tools[key] = tool as T + // Own-key checks only: a tool may be named `constructor` or `toString`. + const next: Record = Object.fromEntries( + Object.entries(tools).filter(([key]) => !Object.hasOwn(engine, key)), + ) + for (const [key, tool] of Object.entries(pinned.engine)) next[key] = tool as T + // Rebuild in first-catalog order; keys new since then go last, in arrival order. + const ordered = new Set([...pinned.order.filter((key) => Object.hasOwn(next, key)), ...Object.keys(next)]) + for (const key of Object.keys(tools)) delete tools[key] + for (const key of ordered) tools[key] = next[key] } async function reconcile(sessionID: string, directory: string, state: DirectoryState): Promise { diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 6da6bae57..3c603b038 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -916,6 +916,65 @@ describe("beforeTurn — what a turn boundary does", () => { expect(step2).toEqual({ datamate_b: { id: "b1" } }) }) + test("pinning keeps the tool order of the first catalog, so the provider's prompt cache still hits", async () => { + install({}) + // The native `datamate_manager` shares the engine prefix and sits between native tools. + const catalog = () => ({ + read: { id: "read" }, + datamate_manager: { id: "native" }, + feedback_submit: { id: "feedback" }, + dbt_pr_review: { id: "review" }, + datamate_add_memories: { id: "add" }, + datamate_search_memory: { id: "search" }, + }) + const first = catalog() + pinTurnTools("s1", true, first) + const later = catalog() + pinTurnTools("s1", false, later) + expect(Object.keys(later)).toEqual(Object.keys(first)) + expect(later.datamate_manager).toBe(first.datamate_manager) + }) + + test("pinning keeps the first catalog's order when the engine's tools change mid-turn", async () => { + install({}) + pinTurnTools("s1", true, { + read: { id: "read" }, + datamate_a: { id: "a1" }, + datamate_b: { id: "b1" }, + sql: { id: "sql" }, + }) + // `datamate_a` vanished, the survivor and a native tool arrive reversed, and a new engine tool appeared. + const later: Record = { + sql: { id: "sql" }, + datamate_c: { id: "c2" }, + datamate_b: { id: "b2" }, + read: { id: "read" }, + write: { id: "write" }, + } + pinTurnTools("s1", false, later) + expect(Object.keys(later)).toEqual(["read", "datamate_a", "datamate_b", "sql", "write"]) + expect(later.datamate_a).toEqual({ id: "a1" }) + expect(later.datamate_b).toEqual({ id: "b1" }) + }) + + test("pinning keeps tools whose names are Object.prototype properties", async () => { + install({}) + const catalog = (): Record => + Object.fromEntries([ + ["constructor", { id: "ctor" }], + ["datamate_a", { id: "a1" }], + ["toString", { id: "str" }], + ]) + pinTurnTools("s1", true, catalog()) + const later = catalog() + pinTurnTools("s1", false, later) + expect(Object.entries(later)).toEqual([ + ["constructor", { id: "ctor" }], + ["datamate_a", { id: "a1" }], + ["toString", { id: "str" }], + ]) + }) + test("pinning is a no-op with the flag off and for a session with no step-1 snapshot", async () => { install({ flag: false }) const tools: Record = { datamate_a: { id: "a1" } }