feat(agent): threaded intentTracing option through append-only context

- Exported `normalizeTools` so `AppendOnlyContext` uses the same tool normalization as the agent loop.
- Added `BuildOptions.intentTracing` to `build()`/`reset()`/`takeSnapshot()` so intent injection is consistent and included in the prefix fingerprint.
- Improved `#computeDigest` to cover tool_calls, tool_call_id, name, and id fields to catch in-place mutations.
- Fixed `#unsubscribeAppendOnly` leak and added no-op guard in `#syncAppendOnlyContext`.
This commit is contained in:
can1357
2026-05-25 14:03:52 +02:00
parent 2c75bb07f4
commit 80186e341c
4 changed files with 205 additions and 103 deletions
+2 -2
View File
@@ -363,7 +363,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
};
}
function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"] {
export function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"] {
injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
return tools?.map(t => {
const intentMode = resolveIntentMode(t.intent);
@@ -652,7 +652,7 @@ async function streamAssistantResponse(
let llmContext: Context;
if (config.appendOnlyContext) {
config.appendOnlyContext.syncMessages(normalizedMessages);
llmContext = config.appendOnlyContext.build(context);
llmContext = config.appendOnlyContext.build(context, { intentTracing: !!config.intentTracing });
} else {
llmContext = {
systemPrompt: context.systemPrompt,
+38 -32
View File
@@ -15,7 +15,8 @@
*/
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
import type { AgentContext, AgentTool } from "./types";
import { normalizeTools } from "./agent-loop";
import type { AgentContext } from "./types";
// ---------------------------------------------------------------------------
// StablePrefix (formerly ImmutablePrefix)
@@ -28,6 +29,12 @@ export interface StablePrefixSnapshot {
fingerprint: string;
}
/** Options threaded through `build()` so the snapshot reflects loop-time settings. */
export interface BuildOptions {
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
intentTracing: boolean;
}
/**
* A frozen prefix (system prompt + tools) that produces stable byte
* sequences across `build()` calls.
@@ -54,8 +61,8 @@ export class StablePrefix {
* Build or rebuild from live context.
* Returns `true` if the prefix actually changed (cache miss imminent).
*/
build(context: AgentContext): boolean {
const snapshot = takeSnapshot(context);
build(context: AgentContext, options: BuildOptions): boolean {
const snapshot = takeSnapshot(context, options);
if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
return false;
}
@@ -154,8 +161,8 @@ export class AppendOnlyContextManager {
/** Rolling digest of synced message content — detects in-place rewrites. */
#syncedDigest = 0;
build(context: AgentContext): Context {
this.prefix.build(context);
build(context: AgentContext, options: BuildOptions): Context {
this.prefix.build(context, options);
const { systemPrompt, tools } = this.prefix.toContext();
return { systemPrompt, messages: this.log.toMessages(), tools };
}
@@ -219,25 +226,36 @@ export class AppendOnlyContextManager {
this.prefix.invalidate();
}
reset(context: AgentContext): void {
reset(context: AgentContext, options: BuildOptions): void {
this.prefix.invalidate();
this.log.clear();
this.#lastSyncCount = 0;
this.#syncedDigest = 0;
this.prefix.build(context);
this.prefix.build(context, options);
}
/** Fast rolling digest of message content. */
#computeDigest(messages: any[]): number {
/**
* Deterministic digest over every field the provider may serialize — role,
* content, tool calls (both `toolCalls` and OpenAI-wire `tool_calls`),
* `tool_call_id`, `name`, `id`. Hashed with the same FNV-style rolling
* accumulator so in-place rewrites of *any* of these fields are visible.
*/
#computeDigest(messages: readonly unknown[]): number {
let hash = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg && typeof msg === "object") {
const payload =
String(msg.role) + (typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? ""));
for (let j = 0; j < payload.length; j++) {
hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
}
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const payload = JSON.stringify({
r: m.role ?? null,
c: m.content ?? null,
tc: m.toolCalls ?? m.tool_calls ?? null,
tcid: m.tool_call_id ?? null,
n: m.name ?? null,
id: m.id ?? null,
});
for (let j = 0; j < payload.length; j++) {
hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
}
}
return hash >>> 0;
@@ -248,30 +266,17 @@ export class AppendOnlyContextManager {
// Snapshot helpers
// ---------------------------------------------------------------------------
/**
* Produce a stable serialization of tools that matches what
* `normalizeTools(tools, false)` outputs (no intent injection).
*
* The spread `{ ...agentTool }` preserves all own enumerable properties
* that survive JSON.stringify — functions are dropped, but strings,
* booleans, objects are included.
*/
function normalizeTool(t: AgentTool): Tool {
const description = t.description ?? "";
return { ...t, parameters: t.parameters, description };
}
function takeSnapshot(context: AgentContext): StablePrefixSnapshot {
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
const systemPrompt = [...context.systemPrompt];
const tools = (context.tools ?? []).map(normalizeTool);
const tools = normalizeTools(context.tools, options.intentTracing) ?? [];
return {
systemPrompt,
tools,
fingerprint: computeFingerprint(systemPrompt, tools),
fingerprint: computeFingerprint(systemPrompt, tools, options),
};
}
function computeFingerprint(systemPrompt: string[], tools: Tool[]): string {
function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
const payload = JSON.stringify({
s: systemPrompt,
t: tools.map(t => ({
@@ -282,6 +287,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[]): string {
cf: t.customFormat,
cw: t.customWireName,
})),
i: options.intentTracing,
});
let hash = 0;
for (let i = 0; i < payload.length; i++) {
+151 -67
View File
@@ -26,6 +26,8 @@ function makeTool(name: string, description?: string, parameters?: Record<string
} as AgentTool;
}
const BUILD_OPTS = { intentTracing: false } as const;
// ---------------------------------------------------------------------------
// StablePrefix
// ---------------------------------------------------------------------------
@@ -38,7 +40,7 @@ describe("StablePrefix", () => {
tools: [makeTool("read")],
});
const changed = p.build(ctx);
const changed = p.build(ctx, BUILD_OPTS);
expect(changed).toBe(true);
expect(p.built).toBe(true);
@@ -52,45 +54,45 @@ describe("StablePrefix", () => {
const p = new StablePrefix();
const ctx = makeContext({ systemPrompt: ["Hello"] });
p.build(ctx);
const changed = p.build(ctx);
p.build(ctx, BUILD_OPTS);
const changed = p.build(ctx, BUILD_OPTS);
expect(changed).toBe(false);
});
it("returns true when system prompt changes", () => {
const p = new StablePrefix();
const ctx = makeContext({ systemPrompt: ["Old prompt"] });
p.build(ctx);
p.build(ctx, BUILD_OPTS);
const changed = p.build(makeContext({ systemPrompt: ["New prompt"] }));
const changed = p.build(makeContext({ systemPrompt: ["New prompt"] }), BUILD_OPTS);
expect(changed).toBe(true);
});
it("returns true when tools change", () => {
const p = new StablePrefix();
p.build(makeContext({ tools: [makeTool("read")] }));
p.build(makeContext({ tools: [makeTool("read")] }), BUILD_OPTS);
const changed = p.build(makeContext({ tools: [makeTool("read"), makeTool("write")] }));
const changed = p.build(makeContext({ tools: [makeTool("read"), makeTool("write")] }), BUILD_OPTS);
expect(changed).toBe(true);
});
it("returns true when tool description changes", () => {
const p = new StablePrefix();
p.build(makeContext({ tools: [makeTool("read", "Original desc")] }));
p.build(makeContext({ tools: [makeTool("read", "Original desc")] }), BUILD_OPTS);
const changed = p.build(makeContext({ tools: [makeTool("read", "Updated desc")] }));
const changed = p.build(makeContext({ tools: [makeTool("read", "Updated desc")] }), BUILD_OPTS);
expect(changed).toBe(true);
});
it("invalidate forces rebuild", () => {
const p = new StablePrefix();
const ctx = makeContext({ systemPrompt: ["Stable"] });
p.build(ctx);
p.build(ctx, BUILD_OPTS);
p.invalidate();
expect(p.built).toBe(false);
const changed = p.build(ctx);
const changed = p.build(ctx, BUILD_OPTS);
expect(changed).toBe(true);
});
@@ -102,11 +104,11 @@ describe("StablePrefix", () => {
it("fingerprint changes across rebuilds", () => {
const p = new StablePrefix();
const ctx1 = makeContext({ systemPrompt: ["Prompt A"] });
p.build(ctx1);
p.build(ctx1, BUILD_OPTS);
const fp1 = p.fingerprint;
const ctx2 = makeContext({ systemPrompt: ["Prompt B"] });
p.build(ctx2);
p.build(ctx2, BUILD_OPTS);
const fp2 = p.fingerprint;
expect(fp1).not.toBe(fp2);
@@ -114,10 +116,10 @@ describe("StablePrefix", () => {
it("fingerprint stable for identical context", () => {
const p = new StablePrefix();
p.build(makeContext({ systemPrompt: ["Stable"], tools: [makeTool("foo")] }));
p.build(makeContext({ systemPrompt: ["Stable"], tools: [makeTool("foo")] }), BUILD_OPTS);
const fp1 = p.fingerprint;
p.build(makeContext({ systemPrompt: ["Stable"], tools: [makeTool("foo")] }));
p.build(makeContext({ systemPrompt: ["Stable"], tools: [makeTool("foo")] }), BUILD_OPTS);
const fp2 = p.fingerprint;
expect(fp1).toBe(fp2);
@@ -127,13 +129,13 @@ describe("StablePrefix", () => {
const p = new StablePrefix();
expect(p.version).toBe(0);
p.build(makeContext({ systemPrompt: ["V1"] }));
p.build(makeContext({ systemPrompt: ["V1"] }), BUILD_OPTS);
expect(p.version).toBe(1);
p.build(makeContext({ systemPrompt: ["V2"] }));
p.build(makeContext({ systemPrompt: ["V2"] }), BUILD_OPTS);
expect(p.version).toBe(2);
p.build(makeContext({ systemPrompt: ["V2"] }));
p.build(makeContext({ systemPrompt: ["V2"] }), BUILD_OPTS);
expect(p.version).toBe(2); // unchanged = no increment
});
});
@@ -216,7 +218,7 @@ describe("AppendOnlyContextManager", () => {
tools: [makeTool("read")],
});
const result = mgr.build(ctx);
const result = mgr.build(ctx, BUILD_OPTS);
expect(result.systemPrompt).toEqual(["You are a bot."]);
expect(result.tools).toHaveLength(1);
@@ -230,29 +232,29 @@ describe("AppendOnlyContextManager", () => {
tools: [makeTool("read")],
});
mgr.build(ctx);
mgr.build(ctx, BUILD_OPTS);
// Same context — should reuse cached prefix
const result = mgr.build(ctx);
const result = mgr.build(ctx, BUILD_OPTS);
expect(result.systemPrompt).toEqual(["Original prompt"]);
expect(result.tools).toHaveLength(1);
});
it("build() detects changed system prompt and rebuilds", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext({ systemPrompt: ["Old"] }));
mgr.build(makeContext({ systemPrompt: ["Old"] }), BUILD_OPTS);
const result = mgr.build(makeContext({ systemPrompt: ["New"] }));
const result = mgr.build(makeContext({ systemPrompt: ["New"] }), BUILD_OPTS);
expect(result.systemPrompt).toEqual(["New"]);
});
it("prefix.fingerprint changes when tools change", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext({ tools: [makeTool("read")] }));
mgr.build(makeContext({ tools: [makeTool("read")] }), BUILD_OPTS);
const fp1 = mgr.prefix.fingerprint;
mgr.build(makeContext({ tools: [makeTool("read"), makeTool("write")] }));
mgr.build(makeContext({ tools: [makeTool("read"), makeTool("write")] }), BUILD_OPTS);
const fp2 = mgr.prefix.fingerprint;
expect(fp1).not.toBe(fp2);
@@ -260,12 +262,12 @@ describe("AppendOnlyContextManager", () => {
it("appendMessage grows the log", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.appendMessage({ role: "user", content: "hello" } as any);
mgr.appendMessage({ role: "assistant", content: "world" } as any);
const result = mgr.build(makeContext());
const result = mgr.build(makeContext(), BUILD_OPTS);
expect(result.messages).toHaveLength(2);
expect(result.messages[0]!.role).toBe("user");
expect(result.messages[1]!.role).toBe("assistant");
@@ -273,47 +275,47 @@ describe("AppendOnlyContextManager", () => {
it("appendMessage messages appear in every subsequent build()", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.appendMessage({ role: "user", content: "q1" });
const r1 = mgr.build(makeContext());
const r1 = mgr.build(makeContext(), BUILD_OPTS);
expect(r1.messages).toHaveLength(1);
mgr.appendMessage({ role: "assistant", content: "a1" });
const r2 = mgr.build(makeContext());
const r2 = mgr.build(makeContext(), BUILD_OPTS);
expect(r2.messages).toHaveLength(2);
expect(r2.messages[1]!.content).toBe("a1");
});
it("invalidate forces prefix rebuild", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext({ systemPrompt: ["V1"] }));
mgr.build(makeContext({ systemPrompt: ["V1"] }), BUILD_OPTS);
mgr.invalidate();
const result = mgr.build(makeContext({ systemPrompt: ["V2"] }));
const result = mgr.build(makeContext({ systemPrompt: ["V2"] }), BUILD_OPTS);
expect(result.systemPrompt).toEqual(["V2"]);
});
it("reset clears log and prefix", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext({ systemPrompt: ["Original"] }));
mgr.build(makeContext({ systemPrompt: ["Original"] }), BUILD_OPTS);
mgr.appendMessage({ role: "user", content: "hello" });
const freshCtx = makeContext({ systemPrompt: ["Fresh start"] });
mgr.reset(freshCtx);
mgr.reset(freshCtx, BUILD_OPTS);
const result = mgr.build(freshCtx);
const result = mgr.build(freshCtx, BUILD_OPTS);
expect(result.systemPrompt).toEqual(["Fresh start"]);
expect(result.messages).toHaveLength(0);
});
it("replaceTailMessage updates last log entry", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.appendMessage({ role: "user", content: "old" });
mgr.replaceTailMessage({ role: "user", content: "new" });
const result = mgr.build(makeContext());
const result = mgr.build(makeContext(), BUILD_OPTS);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]!.content).toBe("new");
});
@@ -324,7 +326,7 @@ describe("AppendOnlyContextManager", () => {
delete (toolWithNoDesc as any).description;
const ctx = makeContext({ tools: [toolWithNoDesc] });
const result = mgr.build(ctx);
const result = mgr.build(ctx, BUILD_OPTS);
const tool: Tool | undefined = result.tools?.[0];
expect(tool).toBeDefined();
@@ -335,8 +337,8 @@ describe("AppendOnlyContextManager", () => {
const mgr = new AppendOnlyContextManager();
const ctx = makeContext({ tools: [makeTool("read")] });
const r1 = mgr.build(ctx);
const r2 = mgr.build(ctx);
const r1 = mgr.build(ctx, BUILD_OPTS);
const r2 = mgr.build(ctx, BUILD_OPTS);
expect(r1.tools).toHaveLength(1);
expect(r2.tools).toHaveLength(1);
@@ -348,7 +350,7 @@ describe("AppendOnlyContextManager", () => {
const mgr = new AppendOnlyContextManager();
const ctx = makeContext({ tools: undefined as any });
const result = mgr.build(ctx);
const result = mgr.build(ctx, BUILD_OPTS);
expect(result.tools).toEqual([]);
});
});
@@ -367,8 +369,8 @@ describe("fingerprint determinism", () => {
tools: [makeTool("read", "Read files"), makeTool("edit", "Edit files")],
});
p1.build(ctx);
p2.build(ctx);
p1.build(ctx, BUILD_OPTS);
p2.build(ctx, BUILD_OPTS);
expect(p1.fingerprint).toBe(p2.fingerprint);
});
@@ -378,12 +380,12 @@ describe("fingerprint determinism", () => {
const p2 = new StablePrefix();
const tools = [makeTool("a", "Tool A"), makeTool("b", "Tool B")];
p1.build(makeContext({ tools }));
p1.build(makeContext({ tools }), BUILD_OPTS);
// Create a context where tool b has "Tool B" too
// so the fingerprint changes with name order
const otherTools = [makeTool("b", "Tool B"), makeTool("a", "Tool A")];
p2.build(makeContext({ tools: otherTools }));
p2.build(makeContext({ tools: otherTools }), BUILD_OPTS);
expect(p1.fingerprint).not.toBe(p2.fingerprint);
});
@@ -394,8 +396,8 @@ describe("fingerprint determinism", () => {
// ["A", "B"] and ["A\nB"] have the same joined text but different
// array structure — must produce different fingerprints.
p1.build(makeContext({ systemPrompt: ["A", "B"] }));
p2.build(makeContext({ systemPrompt: ["A\nB"] }));
p1.build(makeContext({ systemPrompt: ["A", "B"] }), BUILD_OPTS);
p2.build(makeContext({ systemPrompt: ["A\nB"] }), BUILD_OPTS);
expect(p1.fingerprint).not.toBe(p2.fingerprint);
});
@@ -408,7 +410,7 @@ describe("fingerprint determinism", () => {
describe("message sync", () => {
it("syncMessages on first call appends all messages", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
const msgs: Message[] = [
{ role: "user", content: "Hello" },
@@ -416,7 +418,7 @@ describe("message sync", () => {
] as any;
mgr.syncMessages(msgs);
const result = mgr.build(makeContext());
const result = mgr.build(makeContext(), BUILD_OPTS);
expect(result.messages).toHaveLength(2);
expect(result.messages[0]!.content).toBe("Hello");
expect(result.messages[1]!.content).toBe("Hi");
@@ -424,24 +426,24 @@ describe("message sync", () => {
it("syncMessages on subsequent calls only appends delta", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([{ role: "user", content: "q1" }]);
const r1 = mgr.build(makeContext());
const r1 = mgr.build(makeContext(), BUILD_OPTS);
expect(r1.messages).toHaveLength(1);
mgr.syncMessages([
{ role: "user", content: "q1" },
{ role: "assistant", content: "a1" },
]);
const r2 = mgr.build(makeContext());
const r2 = mgr.build(makeContext(), BUILD_OPTS);
expect(r2.messages).toHaveLength(2);
expect(r2.messages[1]!.content).toBe("a1");
});
it("syncMessages with unchanged messages is a no-op (same length, no new entries)", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([{ role: "user", content: "q1" }]);
const before = mgr.log.length;
@@ -453,7 +455,7 @@ describe("message sync", () => {
it("syncMessages resets log when array shrinks (compaction)", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([
{ role: "user", content: "q1" },
@@ -472,9 +474,9 @@ describe("message sync", () => {
const mgr = new AppendOnlyContextManager();
// First turn: build with empty context, sync first message
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([{ role: "user", content: "turn1" }]);
const r1 = mgr.build(makeContext());
const r1 = mgr.build(makeContext(), BUILD_OPTS);
expect(r1.messages).toHaveLength(1);
expect(r1.messages[0]!.content).toBe("turn1");
@@ -483,27 +485,27 @@ describe("message sync", () => {
{ role: "user", content: "turn1" },
{ role: "assistant", content: "resp1" },
]);
const r2 = mgr.build(makeContext());
const r2 = mgr.build(makeContext(), BUILD_OPTS);
expect(r2.messages).toHaveLength(2);
expect(r2.messages[1]!.content).toBe("resp1");
});
it("resetSyncCursor forces full re-sync on next call", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([{ role: "user", content: "old" }]);
mgr.resetSyncCursor();
mgr.syncMessages([{ role: "user", content: "fresh" }]);
const result = mgr.build(makeContext());
const result = mgr.build(makeContext(), BUILD_OPTS);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]!.content).toBe("fresh");
});
it("detects in-place rewrite of already-synced messages", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
// Sync two messages
mgr.syncMessages([
@@ -519,13 +521,13 @@ describe("message sync", () => {
]);
// Log should have been reset and re-synced with the new content
expect(mgr.log.length).toBe(2);
const msgs = mgr.build(makeContext()).messages;
const msgs = mgr.build(makeContext(), BUILD_OPTS).messages;
expect(msgs[1]!.content).toBe("[pruned]");
});
it("detects in-place rewrite via digest mismatch", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([{ role: "user", content: "hello" }]);
expect(mgr.log.length).toBe(1);
@@ -533,14 +535,14 @@ describe("message sync", () => {
// Content changed but length same
mgr.syncMessages([{ role: "user", content: "world" }]);
const msgs = mgr.build(makeContext()).messages;
const msgs = mgr.build(makeContext(), BUILD_OPTS).messages;
expect(msgs).toHaveLength(1);
expect(msgs[0]!.content).toBe("world");
});
it("no-op when content unchanged", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext());
mgr.build(makeContext(), BUILD_OPTS);
mgr.syncMessages([
{ role: "user", content: "q1" },
@@ -558,21 +560,103 @@ describe("message sync", () => {
it("invalidateForModelChange resets prefix and log", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext({ systemPrompt: ["Before"] }));
mgr.build(makeContext({ systemPrompt: ["Before"] }), BUILD_OPTS);
mgr.syncMessages([{ role: "user", content: "hello" }]);
mgr.invalidateForModelChange();
// Should need a fresh build — prefix was invalidated
const ctx = makeContext({ systemPrompt: ["After"] });
const result = mgr.build(ctx);
const result = mgr.build(ctx, BUILD_OPTS);
expect(result.systemPrompt).toEqual(["After"]);
expect(result.messages).toHaveLength(0);
// Re-sync should work cleanly
mgr.syncMessages([{ role: "user", content: "new turn" }]);
const r2 = mgr.build(ctx);
const r2 = mgr.build(ctx, BUILD_OPTS);
expect(r2.messages).toHaveLength(1);
expect(r2.messages[0]!.content).toBe("new turn");
});
});
// ---------------------------------------------------------------------------
// Intent injection
// ---------------------------------------------------------------------------
describe("intent injection through build()", () => {
it("injects required `_i` into tool schemas when intentTracing is true", () => {
const mgr = new AppendOnlyContextManager();
const tool = makeTool("read", "Read", {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
});
const ctx = makeContext({ tools: [tool] });
const result = mgr.build(ctx, { intentTracing: true });
const params = result.tools?.[0]?.parameters as { properties?: Record<string, unknown>; required?: string[] };
expect(params?.properties).toBeDefined();
expect(params!.properties!._i).toBeDefined();
expect(params!.required).toContain("_i");
});
it("omits `_i` when intentTracing is false", () => {
const mgr = new AppendOnlyContextManager();
const tool = makeTool("read", "Read", {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
});
const ctx = makeContext({ tools: [tool] });
const result = mgr.build(ctx, { intentTracing: false });
const params = result.tools?.[0]?.parameters as { properties?: Record<string, unknown>; required?: string[] };
expect(params?.properties?._i).toBeUndefined();
expect(params?.required ?? []).not.toContain("_i");
});
it("intentTracing flip invalidates the fingerprint cache", () => {
const mgr = new AppendOnlyContextManager();
const ctx = makeContext({ tools: [makeTool("read")] });
mgr.build(ctx, { intentTracing: false });
const fpNoIntent = mgr.prefix.fingerprint;
mgr.build(ctx, { intentTracing: true });
const fpWithIntent = mgr.prefix.fingerprint;
expect(fpNoIntent).not.toBe(fpWithIntent);
});
});
// ---------------------------------------------------------------------------
// Tool-call mutation detection
// ---------------------------------------------------------------------------
describe("syncMessages detects tool_calls mutation", () => {
it("rebuilds the log when tool_calls is mutated in place", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext(), BUILD_OPTS);
const assistant: Record<string, unknown> = {
role: "assistant",
content: null,
tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"/a"}' } }],
};
const msgs = [{ role: "user", content: "q" }, assistant] as unknown as Message[];
mgr.syncMessages(msgs);
expect(mgr.log.length).toBe(2);
// Mutate tool_calls in place — role+content unchanged, so the old
// (role+content-only) digest would miss this. The full digest must catch it.
const tcs = assistant.tool_calls as Array<{ function: { arguments: string } }>;
tcs[0].function.arguments = '{"path":"/b"}';
mgr.syncMessages(msgs);
// Log was rebuilt → length resets to the new normalized message list length.
expect(mgr.log.length).toBe(2);
const rebuilt = mgr.log.toMessages()[1] as unknown as Record<string, unknown>;
const rebuiltTc = (rebuilt.tool_calls as Array<{ function: { arguments: string } }>)[0];
expect(rebuiltTc.function.arguments).toBe('{"path":"/b"}');
});
});
@@ -751,6 +751,9 @@ export class AgentSession {
// Event subscription state
#unsubscribeAgent?: () => void;
#unsubscribeAppendOnly?: () => void;
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
#eventListeners: AgentSessionEventListener[] = [];
/** Tracks pending steering messages for UI display. Removed when delivered.
@@ -1141,7 +1144,7 @@ export class AgentSession {
// (session persistence, hooks, auto-compaction, retry logic)
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
// Re-evaluate append-only context mode when the setting changes at runtime.
onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
}
/** Model registry for API key resolution and model discovery */
@@ -2785,6 +2788,10 @@ export class AgentSession {
await hindsightState?.flushRetainQueue();
hindsightState?.dispose();
this.#disconnectFromAgent();
if (this.#unsubscribeAppendOnly) {
this.#unsubscribeAppendOnly();
this.#unsubscribeAppendOnly = undefined;
}
this.#eventListeners = [];
}
@@ -5980,7 +5987,12 @@ export class AgentSession {
*/
#syncAppendOnlyContext(model: Model | null | undefined): void {
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
const enable = setting === "on" || (setting === "auto" && model?.provider === "deepseek");
const providerId = model?.provider;
const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
const prev = this.#lastAppendOnlyResolution;
if (prev && prev.enable === enable && prev.providerId === providerId) return;
this.#lastAppendOnlyResolution = { enable, providerId };
if (enable && !this.agent.appendOnlyContext) {
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
} else if (enable && this.agent.appendOnlyContext) {