Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 91 additions & 38 deletions packages/agent-runtime/src/native-pi-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,66 @@ describe("NativePiSessionService", () => {

it("collapses copied session files onto one entry per native id (#1359)", async () => {
const f = fixture();

// Control scan without the copy: the group file is the canonical session.
// A byte-identical copy ties on the content-derived recency, so this is
// the assertion that fails when the winner follows enumeration order
// (#1383).
const control = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot });
const [original] = await control.list();

// A backup copy of the session under a subdirectory of the scan root:
// same header id, different path. It must not become a second session.
const backupDir = join(f.sessionRoot, "backup");
mkdirSync(backupDir, { recursive: true });
writeFileSync(join(backupDir, "fixture-copy.jsonl"), f.text);

// Control: without the copy, list() names the original file's id.
const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot });
const sessions = await service.list();
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(original.id);
});

it("breaks copy ties by path order instead of directory enumeration (#1383)", async () => {
const f = fixture();
const control = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot });
const [original] = await control.list();

// A copy that sorts before the original's group wins by path order.
// The guarantee under test is determinism: whichever file wins, every
// platform picks the same one, so the id cannot flip between scans.
const backupDir = join(f.sessionRoot, "--backup--");
mkdirSync(backupDir, { recursive: true });
writeFileSync(join(backupDir, "fixture-copy.jsonl"), f.text);

const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot });
const sessions = await service.list();
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(original.id);
expect(sessions[0].id).not.toBe(original.id);
});

it("keeps the open runtime's file when a copy ties on recency (#1383)", async () => {
const f = fixture();
const service = new NativePiSessionService({
agentDir: f.agentDir,
sessionRoot: f.sessionRoot,
modelRuntimeFactory: async () => fauxModelRuntime() as any,
});
const [summary] = await service.list();
await service.prompt(summary.id, "desktop prompt", () => undefined);
await expect.poll(() => service.status(summary.id).status.isRunning).toBe(false);

// The copy is byte-identical to the file the open runtime just wrote,
// so recency ties, and it sorts before the original's group. Only the
// runtime preference keeps the live session's file canonical.
const backupDir = join(f.sessionRoot, "--backup--");
mkdirSync(backupDir, { recursive: true });
writeFileSync(join(backupDir, "fixture-copy.jsonl"), readFileSync(f.file, "utf8"));

const sessions = await service.list();
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(summary.id);
service.disposeAll();
});

it("searches native metadata and active-branch message text without rewriting JSONL", async () => {
Expand Down Expand Up @@ -160,45 +206,10 @@ describe("NativePiSessionService", () => {

it("continues through AgentSession and appends the faux-model turn to the original file", async () => {
const f = fixture();
const model = {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider: "test-provider",
baseUrl: "http://127.0.0.1/unused",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_000,
maxTokens: 1_000,
};
const response: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "fixture response" }],
api: "openai-completions",
provider: "test-provider",
model: "test-model",
stopReason: "stop",
timestamp: Date.now(),
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
const fauxRuntime = {
getModel: () => model,
hasConfiguredAuth: () => true,
streamSimple: () => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: response });
stream.push({ type: "done", reason: "stop", message: response });
stream.end(response);
});
return stream;
},
};
const service = new NativePiSessionService({
agentDir: f.agentDir,
sessionRoot: f.sessionRoot,
modelRuntimeFactory: async () => fauxRuntime as any,
modelRuntimeFactory: async () => fauxModelRuntime() as any,
});
const [summary] = await service.list();
const before = readFileSync(f.file, "utf8");
Expand Down Expand Up @@ -269,6 +280,48 @@ function fauxStream(message = response()) {
return stream;
}

/**
* A minimal offline model runtime for the native continuation tests: one
* catalog entry, auth always present, one canned assistant turn per call.
*/
function fauxModelRuntime() {
const model = {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider: "test-provider",
baseUrl: "http://127.0.0.1/unused",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_000,
maxTokens: 1_000,
};
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "fixture response" }],
api: "openai-completions",
provider: "test-provider",
model: "test-model",
stopReason: "stop",
timestamp: Date.now(),
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
return {
getModel: () => model,
hasConfiguredAuth: () => true,
streamSimple: () => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
});
return stream;
},
};
}

describe("native continuation review regressions", () => {
it("uses the real offline credential/catalog and reports missing exact auth/model", async () => {
const f = await configuredFixture();
Expand Down
30 changes: 27 additions & 3 deletions packages/agent-runtime/src/native-pi-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,11 +552,35 @@ export class NativePiSessionService {
// path. Keep the newest write per native id and drop the stale copies
// from the records map so lookups cannot route to a dead duplicate
// (#1359).
//
// Byte-identical copies share the content-derived `updatedAt`, so the
// recency order ties for exactly the manual-copy case this dedup is
// for. A tie used to fall through to `walkJsonl` order, which follows
// directory enumeration and differs per platform: on NTFS the copy
// under `backup/` won while ext4 kept the original, so the copy could
// take the session over and strand the original's id (#1383). Order
// ties deterministically instead: an id with an open runtime keeps
// the file it is already writing, and the last tie is settled by
// path order, which every platform computes the same way.
const seenNativeIds = new Set<string>();
const deduped: Array<SessionSummary | undefined> = [];
const sortedByRecency = [...summaries].sort((a, b) =>
(b?.updatedAt ?? "").localeCompare(a?.updatedAt ?? ""),
);
// Tie-break inside a recency group: the file an open runtime is already
// writing wins, then the smallest record path. Both keys are total orders
// over the group, so the winner cannot follow enumeration order. The id
// fallback only keeps the comparator total for a summary without a record
// (every summary above writes one, so it is not reachable in practice).
const rankTie = (a: SessionSummary, b: SessionSummary): number => {
const aLive = this.runtimes.has(a.id) ? 1 : 0;
const bLive = this.runtimes.has(b.id) ? 1 : 0;
if (aLive !== bLive) return bLive - aLive;
const aPath = this.records.get(a.id)?.path ?? a.id;
const bPath = this.records.get(b.id)?.path ?? b.id;
return aPath < bPath ? -1 : aPath > bPath ? 1 : 0;
};
const sortedByRecency = [...summaries].sort((a, b) => {
const byRecency = (b?.updatedAt ?? "").localeCompare(a?.updatedAt ?? "");
return byRecency !== 0 || !a || !b ? byRecency : rankTie(a, b);
});
const winnerIds = new Set<string>();
for (const summary of sortedByRecency) {
if (!summary) continue;
Expand Down
Loading