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
48 changes: 31 additions & 17 deletions apps/server/src/project/AgentSessionImporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, it } from "@effect/vitest";
import {
type AgentSessionSource,
EventId,
ProjectId,
ProviderDriverKind,
Expand Down Expand Up @@ -50,7 +51,7 @@ const persistence = Layer.mergeAll(
function candidate(
modelSelection: typeof CODEX_MODEL_SELECTION,
providerSessionId: string,
provider: "codex" | "claudeAgent" = "codex",
provider: AgentSessionSource = "codex",
) {
const providerInstanceId = modelSelection.instanceId;
return {
Expand Down Expand Up @@ -82,6 +83,12 @@ function candidate(
} satisfies AgentSessionScanner.AgentSessionRecentThread;
}

const PI_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("pi"), model: "default" };

// Pi threads are owned by their session file path, not the session UUID.
const nativeId = ({ source }: ReturnType<typeof candidate>) =>
source.provider === "pi" ? source.filePath : source.providerSessionId;

function importId(source: ReturnType<typeof candidate>) {
return ThreadId.make(
`import:${source.source.providerInstanceId}:${source.source.providerSessionId}`,
Expand All @@ -95,19 +102,17 @@ const seedOwner = Effect.fn(function* (
const sink = yield* EventSink.EventSinkV2;
const ids = yield* IdAllocator.IdAllocatorV2;
const { providerInstanceId, providerSessionId, provider } = source.source;
const nativeThreadId = nativeId(source);
const threadId = ThreadId.make(`owner:${providerInstanceId}:${providerSessionId}`);
const driver = ProviderDriverKind.make(provider);
const providerThread: OrchestrationV2ProviderThread = {
id: ids.derive.providerThread({
driver,
nativeThreadId: providerSessionId,
}),
id: ids.derive.providerThread({ driver, nativeThreadId }),
driver,
providerInstanceId,
providerSessionId: null,
appThreadId: threadId,
ownerNodeId: null,
nativeThreadRef: { driver, nativeId: providerSessionId, strength: "strong" },
nativeThreadRef: { driver, nativeId: nativeThreadId, strength: "strong" },
nativeConversationHeadRef: null,
status: "idle",
firstRunOrdinal: 1,
Expand Down Expand Up @@ -252,10 +257,12 @@ it.effect(
"claudeAgent",
);
const deleted = candidate(CODEX_MODEL_SELECTION, "deleted");
const ownedPi = candidate(PI_MODEL_SELECTION, "owned-pi", "pi");
const owners = [
yield* seedOwner(active),
yield* seedOwner(archived, "archived"),
yield* seedOwner(deleted, "deleted"),
yield* seedOwner(ownedPi),
];
const before = yield* Effect.forEach(owners, (id) =>
projections.getThreadRecords(id, ["providerThreads"]),
Expand All @@ -266,10 +273,19 @@ it.effect(
"claudeAgent",
);
const unowned = candidate(CODEX_MODEL_SELECTION, "unowned");
const importer = yield* makeImporter([active, archived, deleted, otherInstance, unowned]);
const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi");
const importer = yield* makeImporter([
active,
archived,
deleted,
ownedPi,
otherInstance,
unowned,
unownedPi,
]);

expect(yield* importer.run).toEqual({ importedCount: 2, skippedCount: 0 });
for (const source of [active, archived, deleted]) {
expect(yield* importer.run).toEqual({ importedCount: 3, skippedCount: 0 });
for (const source of [active, archived, deleted, ownedPi]) {
const threadId = importId(source);
expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none());
expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]);
Expand All @@ -286,8 +302,8 @@ it.effect(
"event" in stored ? [stored.event.threadId] : [],
),
),
).toEqual(new Set([importId(otherInstance), importId(unowned)]));
for (const source of [otherInstance, unowned]) {
).toEqual(new Set([importId(otherInstance), importId(unowned), importId(unownedPi)]));
for (const source of [otherInstance, unowned, unownedPi]) {
const threadId = importId(source);
const records = yield* projections.getThreadRecords(threadId, [
"messages",
Expand All @@ -296,15 +312,13 @@ it.effect(
expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]);
expect(records.thread.historyOrigin).toBe("v1_import");
expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id);
expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(
source.source.providerSessionId,
);
expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source));
expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({
providerInstanceId: source.source.providerInstanceId,
resumeCursor:
source.source.provider === "codex"
? { threadId: source.source.providerSessionId }
: { threadId, resume: source.source.providerSessionId },
source.source.provider === "claudeAgent"
? { threadId, resume: source.source.providerSessionId }
: { threadId: nativeId(source) },
runtimePayload: { importedTranscripts: [source.source] },
});
}
Expand Down
13 changes: 8 additions & 5 deletions apps/server/src/project/AgentSessionImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ const make = Effect.gen(function* () {
});
}
const driver = ProviderDriverKind.make(thread.source);
// Pi resumes by session file path; its session UUID only names the thread.
const nativeThreadId =
thread.source === "pi" ? source.filePath : thread.providerSessionId;
const persistRuntime = runtimes.upsert(
{
threadId,
Expand All @@ -260,9 +263,9 @@ const make = Effect.gen(function* () {
status: "stopped",
lastSeenAt: thread.updatedAt,
resumeCursor:
thread.source === "codex"
? { threadId: thread.providerSessionId }
: { threadId, resume: thread.providerSessionId },
thread.source === "claudeAgent"
? { threadId, resume: thread.providerSessionId }
: { threadId: nativeThreadId },
runtimePayload: { cwd: project.workspaceRoot },
},
{ onConflict: "ignore" },
Expand All @@ -288,7 +291,7 @@ const make = Effect.gen(function* () {
const providerThreadId = idAllocator.derive.providerThread({
driver,
providerInstanceId: thread.providerInstanceId,
nativeThreadId: thread.providerSessionId,
nativeThreadId,
});
const createdAt = dateTime(thread.createdAt);
const updatedAt = dateTime(thread.updatedAt);
Expand Down Expand Up @@ -337,7 +340,7 @@ const make = Effect.gen(function* () {
ownerNodeId: null,
nativeThreadRef: {
driver,
nativeId: thread.providerSessionId,
nativeId: nativeThreadId,
strength: "strong",
},
nativeConversationHeadRef: null,
Expand Down
143 changes: 138 additions & 5 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const layerProjectStore = (importedWorkspaceRoots: ReadonlyArray<string>) =>
interface ScannerTestInput {
readonly claudeHomePath: string;
readonly codexHomePath: string;
readonly piAgentDir?: string;
readonly importedWorkspaceRoots?: ReadonlyArray<string>;
readonly configBaseDir?: string;
readonly providerInstances?: ContractServerSettings["providerInstances"];
Expand All @@ -65,6 +66,20 @@ const layerScannerTest = (input: ScannerTestInput) =>
driver: ProviderDriverKind.make("codex"),
config: { homePath: input.codexHomePath },
},
// Keeps the default Pi instance off the developer's real ~/.pi.
[ProviderInstanceId.make("pi")]: {
driver: ProviderDriverKind.make("pi"),
enabled: true,
environment: [
{
name: "PI_CODING_AGENT_DIR",
value: input.piAgentDir ?? `${input.claudeHomePath}/pi-agent`,
sensitive: false,
},
{ name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false },
],
config: {},
},
...input.providerInstances,
},
}),
Expand Down Expand Up @@ -561,9 +576,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-");
const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-");
const piAgentDir = yield* makeTempDir("t3code-pi-agent-");
const piSessionDir = yield* makeTempDir("t3code-pi-sessions-");
const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");

const piWorkspace = yield* makeTempDir("t3code-workspace-pi-");
const piFlatWorkspace = yield* makeTempDir("t3code-workspace-pi-flat-");

// Pi nests sessions per cwd by default and keeps a custom session directory flat.
for (const [filePath, cwd, mtime] of [
[
path.join(piAgentDir, "sessions", "--slug--", "2026-01-03_pi.jsonl"),
piWorkspace,
"2026-01-03T00:00:00.000Z",
],
[
path.join(piSessionDir, "2026-01-04_pi-flat.jsonl"),
piFlatWorkspace,
"2026-01-04T00:00:00.000Z",
],
] as const) {
yield* writeTranscript({
filePath,
contents: `${encodeTranscriptRecord({ type: "session", version: 3, id: "pi", cwd })}\n`,
mtimeMs: Date.parse(mtime),
});
}
yield* writeTranscript({
filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"),
contents: claudeSessionLine(claudeWorkspace),
Expand Down Expand Up @@ -598,12 +636,31 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }],
config: {},
},
[ProviderInstanceId.make("pi")]: {
driver: ProviderDriverKind.make("pi"),
enabled: true,
environment: [
{ name: "PI_CODING_AGENT_DIR", value: piAgentDir, sensitive: false },
{ name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false },
],
config: {},
},
[ProviderInstanceId.make("pi-flat")]: {
driver: ProviderDriverKind.make("pi"),
enabled: true,
environment: [
{ name: "PI_CODING_AGENT_SESSION_DIR", value: piSessionDir, sensitive: false },
],
config: {},
},
},
});

expect(result.candidates.map((candidate) => candidate.path)).toEqual([
codexWorkspace,
claudeWorkspace,
expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([
[piFlatWorkspace, ["pi"]],
[piWorkspace, ["pi"]],
[codexWorkspace, ["codex"]],
[claudeWorkspace, ["claudeAgent"]],
]);
}),
);
Expand Down Expand Up @@ -983,13 +1040,14 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
});

describe("recentThreads", () => {
it.effect("imports recent Claude and Codex sessions for the selected project only", () =>
it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
yield* TestClock.setTime(nowMs);
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const piAgentDir = yield* makeTempDir("t3code-pi-agent-");
const workspace = yield* makeTempDir("t3code-workspace-");
const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");

Expand Down Expand Up @@ -1053,21 +1111,96 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
].join("\n"),
mtimeMs: nowMs - 60 * 60 * 1000,
});
const piMessage = (
id: string,
parentId: string | null,
message: { readonly role: string; readonly content: unknown },
) =>
encodeTranscriptRecord({
type: "message",
id,
parentId,
timestamp: "2026-08-24T11:00:00.000Z",
message,
});
// The user rewound to the first prompt; the abandoned branch stays in the file.
yield* writeTranscript({
filePath: path.join(piAgentDir, "sessions", "--selected--", "2026-08-24_pi.jsonl"),
contents: [
encodeTranscriptRecord({
type: "session",
version: 3,
id: "pi-recent",
cwd: workspace,
}),
piMessage("a1", null, { role: "user", content: "Plan the change" }),
piMessage("a2", "a1", {
role: "assistant",
content: [{ type: "text", text: "Abandoned answer" }],
}),
piMessage("a3", "a2", { role: "user", content: "Abandoned follow-up" }),
piMessage("b1", "a1", {
role: "assistant",
content: [
{ type: "thinking", thinking: "Hidden reasoning" },
{ type: "toolCall", id: "call-1", name: "bash", arguments: {} },
],
}),
piMessage("bn", "b1", { role: "toolResult", content: null }),
piMessage("b2", "bn", {
role: "toolResult",
content: [{ type: "text", text: "Tool output" }],
}),
piMessage("b3", "b2", {
role: "assistant",
content: [{ type: "text", text: "Active answer" }],
}),
encodeTranscriptRecord({
type: "session_info",
id: "b4",
parentId: "b3",
name: "Pi refactor",
}),
].join("\n"),
mtimeMs: nowMs - 30 * 60 * 1000,
});
// Version 1 sessions predate the entry tree.
yield* writeTranscript({
filePath: path.join(piAgentDir, "sessions", "--selected--", "2025-01-01_pi-v1.jsonl"),
contents: [
encodeTranscriptRecord({ type: "session", id: "pi-v1", cwd: workspace }),
encodeTranscriptRecord({
type: "message",
message: { role: "user", content: "Legacy prompt" },
}),
encodeTranscriptRecord({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "Legacy answer" }] },
}),
].join("\n"),
mtimeMs: nowMs - 2 * 24 * 60 * 60 * 1000,
});

const threads = yield* runRecentThreads({
claudeHomePath,
codexHomePath,
piAgentDir,
workspaceRoot: workspace,
});

expect(threads.map((thread) => thread.providerSessionId)).toEqual([
"pi-recent",
"codex-recent",
"claude-recent",
"pi-v1",
]);
expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([
["Plan the change", "Active answer"],
["Review this code", "Looks good"],
["Fix the project", "Done"],
["Legacy prompt", "Legacy answer"],
]);
expect(threads[0]).toMatchObject({ source: "pi", title: "Pi refactor" });
}),
);

Expand Down
Loading
Loading