源码
packages/desktop/test/side-chat.test.ts
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { SessionManager } from "@zpi-dev/coding-agent";
import { expect, it } from "vitest";
import { chunk, deferred, done, send } from "../../../tests/fake-server.ts";
import { SessionHost } from "../src/main/session-host.ts";
import { isolatedHistory } from "./helpers/history-reader.ts";
import { cleanup, fixture } from "./helpers/host-fixture.ts";
for (const boundaryType of ["message", "run", "streaming-tools"] as const) {
it(`inherits compacted context with a ${boundaryType} boundary without inheriting the timeline`, async () => {
const f = await fixture((_, response) => {
send(response, chunk({ content: "retained answer" }));
done(response);
});
const parent = await f.host.createSession(f.a.id);
await f.host.startRun({ sessionId: parent.id, text: "retained question" });
await f.host.activeRuns.get(parent.id)?.done;
await f.host.close();
const path = join(f.dir, "agent", "sessions", f.a.id, `${parent.id}.jsonl`);
const index = isolatedHistory(path);
await index.load();
const manager = SessionManager.fromStore(index);
const boundary = [...manager.iterateEntries()].find((entry) =>
boundaryType === "message"
? entry.type === "message" && entry.message.role === "user"
: entry.type === "custom" && entry.customType === "zpi.run",
);
if (!boundary) throw new Error("Missing boundary fixture");
// Streaming journals can also be the first kept entry in real desktop transcripts.
if (boundaryType === "streaming-tools") {
await manager.replaceEntriesAsync(
[...manager.iterateEntries()].map((entry) =>
entry.id === boundary.id
? {
...entry,
type: "custom",
customType: "zpi.streaming_tools",
data: { id: "fixture-journal", committed: true },
}
: entry,
),
);
}
const firstKept =
boundaryType === "streaming-tools"
? [...manager.iterateEntries()].find(
(entry) => entry.type === "custom" && entry.customType === "zpi.streaming_tools",
)
: boundary;
if (!firstKept) throw new Error("Missing kept entry");
await manager.appendCompactionAsync("earlier decisions", firstKept.id);
await manager.appendCompactionAsync("updated earlier decisions", firstKept.id);
const expected = manager.buildSessionContext().messages;
const persisted = f.settings.state.getTask(parent.id);
if (!persisted) throw new Error("Missing task fixture");
f.settings.state.upsertTask({ ...persisted, sessionFile: index.key });
const original = await readFile(index.path, "utf8");
const host = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined);
await host.init();
cleanup.push(() => host.close());
const child = await host.createSideChat(parent.id);
const childIndex = isolatedHistory(host.sessionPath(child.id));
await childIndex.load();
expect(SessionManager.fromStore(childIndex).buildSessionContext().messages.slice(0, -1)).toEqual(
expected,
);
expect(host.getSessionSnapshot(child.id).view.runs).toHaveLength(0);
expect(host.getHistoryPage(child.id, 0).view.runs).toHaveLength(0);
await host.startRun({ sessionId: child.id, text: "explain decisions" });
await host.activeRuns.get(child.id)?.done;
expect(JSON.stringify(f.server.requests.at(-1)?.messages)).toContain("updated earlier decisions");
expect(JSON.stringify(f.server.requests.at(-1)?.messages)).toContain("retained question");
expect(host.getSessionSnapshot(child.id).view.runs.map((run) => run.userMessage)).toEqual([
"explain decisions",
]);
expect(await readFile(index.path, "utf8")).toBe(original);
});
}
it("opens independent hidden chats with inherited context and an empty timeline, retaining isolation after restart", async () => {
const f = await fixture((_, response) => {
send(response, chunk({ content: "parent answer" }));
done(response);
});
const parent = await f.host.createSession(f.a.id);
await f.host.startRun({ sessionId: parent.id, text: "parent question" });
await f.host.activeRuns.get(parent.id)?.done;
await f.host.setSessionSelection(parent.id, { provider: "custom", modelId: "fake", reasoning: "high" });
const path = join(f.dir, "agent", "sessions", f.a.id, `${parent.id}.jsonl`);
const original = await readFile(path, "utf8");
const child = await f.host.createSideChat(parent.id);
const sibling = await f.host.createSideChat(parent.id);
expect(child.id).not.toBe(sibling.id);
expect(child.parentSessionId).toBe(parent.id);
expect(f.host.getSessionSnapshot(child.id).view.controls?.selection?.reasoning).toBe("high");
expect(f.host.getSessionSnapshot(parent.id).view.controls?.selection?.reasoning).toBe("high");
expect(f.host.listRecentSessions().map((s) => s.id)).toEqual([parent.id]);
expect(f.host.getSessionSnapshot(child.id).view.runs).toHaveLength(0);
expect(f.host.getHistoryPage(child.id, 0).view.runs).toHaveLength(0);
await f.host.startRun({ sessionId: child.id, text: "explain" });
await f.host.activeRuns.get(child.id)?.done;
const messages = JSON.stringify(f.server.requests.at(-1)?.messages);
expect(messages).toContain("parent question");
expect(messages).toContain("parent answer");
expect(messages).toContain("Do not continue the parent's active work");
expect(f.host.getSessionSnapshot(child.id).view.runs.map((r) => r.userMessage)).toEqual(["explain"]);
expect(await readFile(path, "utf8")).toBe(original);
await expect(
f.host.forkSession(child.id, f.host.getSessionSnapshot(child.id).view.runs[0].runId),
).rejects.toThrow("不支持分叉");
await f.host.close();
const restored = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined);
await restored.init();
cleanup.push(() => restored.close());
expect(restored.listRecentSessions().map((s) => s.id)).toEqual([parent.id]);
expect(restored.getSessionSnapshot(child.id).view.runs.map((r) => r.userMessage)).toEqual(["explain"]);
});
it.each(["idle", "running", "deleted historical provider"])(
"inherits the parent's current model rather than its historical model (%s)",
async (state) => {
const started = deferred();
const release = deferred();
const f = await fixture(async (_, response, index) => {
send(response, chunk({ content: "answer" }));
if (state === "running" && index === 0) {
started.resolve();
await release.promise;
}
done(response);
});
const parent = await f.host.createSession(f.a.id);
await f.host.startRun({ sessionId: parent.id, text: "parent question" });
if (state === "running") await started.promise;
else await f.host.activeRuns.get(parent.id)?.done;
f.settings.saveProvider({
id: "next-provider",
name: "Next provider",
baseUrl: f.server.url,
apiKey: "next-key",
models: [{ id: "next-model", input: ["text"], reasoning: true, contextWindow: 32768, maxTokens: 4096 }],
});
const selection = { provider: "next-provider", modelId: "next-model", reasoning: "high" } as const;
await f.host.setSessionSelection(parent.id, selection);
// Another task's last-used model must not affect this child.
f.settings.rememberSelection({ provider: "custom", modelId: "fake", reasoning: "none" });
if (state === "deleted historical provider") f.settings.deleteProvider("custom");
try {
const child = await f.host.createSideChat(parent.id);
expect(f.host.getSessionSnapshot(child.id).controls).toMatchObject({
selection,
selectionValid: true,
lastThinkingLevel: "high",
});
await f.host.startRun({ sessionId: child.id, text: "child question" });
await f.host.activeRuns.get(child.id)?.done;
expect(f.server.requests.at(-1)?.model).toBe("next-model");
if (state === "running") expect(f.host.sessions.get(parent.id)?.model.id).toBe("fake");
release.resolve();
await f.host.activeRuns.get(parent.id)?.done;
await f.host.close();
const restored = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined);
cleanup.push(() => restored.close());
await restored.init();
expect(restored.getSessionSnapshot(child.id).controls).toMatchObject({
selection,
selectionValid: true,
});
} finally {
release.resolve();
}
},
);
it("copies the committed active user input without partial assistant output and stops only the closed side chat", async () => {
const parentStarted = deferred();
const parentRelease = deferred();
const childStarted = deferred();
const childRelease = deferred();
const f = await fixture(async (_, response, index) => {
if (index === 0) {
send(response, chunk({ content: "partial parent" }));
parentStarted.resolve();
await parentRelease.promise;
} else {
send(response, chunk({ content: "partial child" }));
childStarted.resolve();
await childRelease.promise;
}
done(response);
});
const parent = await f.host.createSession(f.a.id);
await f.host.startRun({ sessionId: parent.id, text: "active parent input" });
await parentStarted.promise;
const child = await f.host.createSideChat(parent.id);
expect(f.settings.state.getTask(child.id)?.modelPerformanceTotals).toMatchObject({ turns: 0, steps: 0 });
expect(f.host.getSessionSnapshot(child.id).historyCursor).toBeNull();
await f.host.startRun({ sessionId: child.id, text: "child input" });
await childStarted.promise;
const messages = JSON.stringify(f.server.requests[1].messages);
expect(messages).toContain("active parent input");
expect(messages).not.toContain("partial parent");
await f.host.closeSideChat(child.id);
expect(f.host.activeRuns.has(child.id)).toBe(false);
expect(f.host.activeRuns.has(parent.id)).toBe(true);
parentRelease.resolve();
childRelease.resolve();
await f.host.activeRuns.get(parent.id)?.done;
await f.host.deleteSession(parent.id);
expect(() => f.host.getSessionSnapshot(child.id)).toThrow("不存在");
});
it("inherits 2/29 instead of seven internal prompts, rebuilds totals, and pages only local history", async () => {
const f = await fixture((_, response) => {
send(response, chunk({ content: "answer" }));
done(response);
});
const parent = await f.host.createSession(f.a.id);
await f.host.startRun({ sessionId: parent.id, text: "first" });
await f.host.activeRuns.get(parent.id)?.done;
const manager = f.host["manager"](parent.id);
const assistant = [...manager.iterateEntries()].find(
(entry) => entry.type === "message" && entry.message.role === "assistant",
);
if (assistant?.type !== "message" || assistant.message.role !== "assistant") throw new Error("fixture");
await manager.appendCustomEntryAsync("zpi.run", { phase: "start", runId: "second", startedAt: Date.now() });
for (let i = 0; i < 27; i++) {
if (i % 5 === 0)
await manager.appendMessageAsync({ role: "user", content: "internal prompt", timestamp: Date.now() });
await manager.appendMessageAsync(assistant.message);
}
await manager.appendCompactionAsync("retained decisions", assistant.id, undefined, undefined, {
input: 10,
output: 2,
cacheRead: 3,
cacheWrite: 0,
totalTokens: 15,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
});
await manager.appendCustomEntryAsync("zpi.run", { phase: "end", runId: "second" });
const child = await f.host.createSideChat(parent.id);
const index = f.host["requiredIndex"](child.id);
const reads = index.readBytes;
const initial = f.host.getSessionSnapshot(child.id);
expect(initial.view.modelPerformance).toMatchObject({ turns: 2, steps: 29 });
expect(initial.view.runs).toEqual([]);
expect(initial.historyCursor).toBeNull();
expect(f.host.getHistoryPage(child.id, index.snapshot().endSeq + 1).cursor).toBeNull();
expect(index.readBytes).toBe(reads);
const saved = f.settings.state.getTask(child.id);
if (!saved?.modelPerformanceTotals) throw new Error("Missing totals");
f.settings.state.upsertTask({ ...saved, modelPerformanceTotals: undefined });
expect(f.host.getSessionSnapshot(child.id).view.modelPerformance).toMatchObject({ turns: 2, steps: 29 });
await f.host.startRun({ sessionId: child.id, text: "local question" });
await f.host.activeRuns.get(child.id)?.done;
expect(f.host.getSessionSnapshot(child.id).view.modelPerformance).toMatchObject({ turns: 3, steps: 30 });
await f.host.close();
const restored = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined);
await restored.init();
cleanup.push(() => restored.close());
expect(restored.getSessionSnapshot(child.id).view.modelPerformance).toMatchObject({ turns: 3, steps: 30 });
expect(restored.getSessionSnapshot(child.id).view.runs).toHaveLength(1);
expect(restored.getSessionSnapshot(child.id).historyCursor).toBeNull();
// A transcript generation rewrite must validate, rather than blindly trust, its old sequence boundary.
const rewritten = restored["manager"](child.id);
await rewritten.replaceEntriesAsync(rewritten.iterateEntries());
expect(restored.getSessionSnapshot(child.id).view.modelPerformance).toMatchObject({ turns: 3, steps: 30 });
expect(f.settings.state.getTask(child.id)?.sideChatInheritance?.childGeneration).toBe(
restored["requiredIndex"](child.id).snapshot().generation,
);
});
it("freezes an active parent's completed prefix rather than its later totals", async () => {
const started = deferred();
const release = deferred();
const f = await fixture(async (_, response, i) => {
send(response, chunk({ content: i ? "pending answer" : "completed answer" }));
if (i === 1) {
started.resolve();
await release.promise;
}
done(response);
});
const parent = await f.host.createSession(f.a.id);
await f.host.startRun({ sessionId: parent.id, text: "completed input" });
await f.host.activeRuns.get(parent.id)?.done;
await f.host.startRun({ sessionId: parent.id, text: "active input" });
await started.promise;
const child = await f.host.createSideChat(parent.id);
expect(f.host.getSessionSnapshot(child.id).view.modelPerformance).toMatchObject({ turns: 1, steps: 1 });
const messages = f.host["manager"](child.id).buildSessionContext().messages;
expect(JSON.stringify(messages)).toContain("active input");
expect(JSON.stringify(messages)).not.toContain("pending answer");
release.resolve();
await f.host.activeRuns.get(parent.id)?.done;
expect(f.host.getSessionSnapshot(parent.id).view.modelPerformance).toMatchObject({ turns: 2, steps: 2 });
const saved = f.settings.state.getTask(child.id);
if (!saved) throw new Error("Missing child");
f.settings.state.upsertTask({ ...saved, modelPerformanceTotals: undefined });
expect(f.host.getSessionSnapshot(child.id).view.modelPerformance).toMatchObject({ turns: 1, steps: 1 });
});