Skip to content
125 changes: 125 additions & 0 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2115,6 +2115,131 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
}),
);

it.effect("imports long Claude tool history with metadata from discarded records", () =>
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-long-claude-");
const codexHomePath = yield* makeTempDir("t3code-long-codex-");
const workspace = yield* makeTempDir("t3code-long-workspace-");
const record = (fields: Record<string, unknown>) =>
encodeTranscriptRecord({ sessionId: "first-session", ...fields });
const toolResult = record({
type: "user",
cwd: path.join(workspace, "later-cwd"),
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }],
},
});
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug", "long-session.jsonl"),
contents: [
record({ type: "attachment", cwd: workspace, aiTitle: "Old title" }),
record({ type: "user", message: { role: "user", content: "First prompt" } }),
record({
type: "assistant",
message: { role: "assistant", model: "claude-opus-5-5", content: "First reply" },
}),
// Small tool records exceed the real 32 MiB selected-history budget in aggregate.
...Array.from({ length: 20_000 }, () => toolResult),
record({ type: "user", message: { role: "user", content: "Latest prompt" } }),
record({
type: "assistant",
message: {
role: "assistant",
model: "claude-sonnet-5-5",
content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }],
},
}),
record({ type: "custom-title", customTitle: "Renamed conversation" }),
record({ type: "ai-title", aiTitle: "Newest title" }),
record({ type: "attachment", sessionId: "latest-session" }),
].join("\n"),
mtimeMs: nowMs,
});

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

expect(threads).toMatchObject([
{
providerSessionId: "latest-session",
title: "Renamed conversation",
model: "claude-sonnet-5-5",
messages: [
{ role: "user", text: "First prompt" },
{ role: "assistant", text: "First reply" },
{ role: "user", text: "Latest prompt" },
],
},
]);
}),
);

it.effect("excludes hidden Claude records from the history budget and session metadata", () =>
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-hidden-claude-");
const codexHomePath = yield* makeTempDir("t3code-hidden-codex-");
const workspace = yield* makeTempDir("t3code-hidden-workspace-");
const message = (role: "user" | "assistant", text: string) =>
encodeTranscriptRecord({
sessionId: "visible-session",
cwd: workspace,
type: role,
message: { role, content: text },
});
const flags = ["isSidechain", "isMeta", "isCompactSummary"] as const;
// Each hidden reply selects about 2 MiB; together they exceed the real 32 MiB budget.
const hiddenReply = (flag: (typeof flags)[number]) =>
encodeTranscriptRecord({
sessionId: "hidden-session",
aiTitle: "Hidden title",
customTitle: "Hidden rename",
type: "assistant",
[flag]: true,
message: { role: "assistant", model: "hidden-model", content: "x".repeat(1024 * 1024) },
});
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug", "hidden-session.jsonl"),
contents: [
message("user", "First prompt"),
message("assistant", "First reply"),
...Array.from({ length: 20 }, (_, index) => hiddenReply(flags[index % flags.length]!)),
message("user", "Latest prompt"),
hiddenReply("isSidechain"),
].join("\n"),
mtimeMs: nowMs,
});

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

expect(threads).toMatchObject([
{
providerSessionId: "visible-session",
title: "First prompt",
model: null,
messages: [
{ role: "user", text: "First prompt" },
{ role: "assistant", text: "First reply" },
{ role: "user", text: "Latest prompt" },
],
},
]);
}),
);

it.effect("reports stat, read, and parse failures as skipped", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down
128 changes: 77 additions & 51 deletions apps/server/src/project/AgentSessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,16 +215,17 @@ function splitTranscriptRecords(contents: string, limit: number): string[] {
return records.split("\n", limit);
}

function isTextContentBlock(block: typeof TranscriptContentBlock.Type): boolean {
return block.type === "text" || block.type === "input_text" || block.type === "output_text";
}

function extractText(
content: string | ReadonlyArray<typeof TranscriptContentBlock.Type> | undefined,
): string {
if (typeof content === "string") return content.trim();
if (content === undefined) return "";
return content
.filter(
(block) =>
block.type === "text" || block.type === "input_text" || block.type === "output_text",
)
.filter(isTextContentBlock)
.map((block) => block.text?.trim() ?? "")
.filter((text) => text.length > 0)
.join("\n");
Expand All @@ -248,6 +249,40 @@ function codexTurnId(metadata: unknown): string | null {
return decoded.value.turn_id;
}

function isHiddenClaudeRecord(record: DecodedTranscriptRecord): boolean {
return record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true;
}

// Claude repeats session fields on records that need not be retained.
interface ClaudeFields {
readonly sessionId: string | undefined;
readonly title: string | undefined;
readonly customTitle: string | null;
readonly model: string | undefined;
}

const emptyClaudeFields: ClaudeFields = {
sessionId: undefined,
title: undefined,
customTitle: null,
model: undefined,
};

function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord): ClaudeFields {
if (isHiddenClaudeRecord(record)) return fields;
const model = record.message?.model?.trim();
return {
sessionId: record.sessionId?.trim() || fields.sessionId,
title: record.aiTitle?.trim() || fields.title,
// Claude flushes the AI title after the user's rename; an empty rename clears it.
customTitle:
record.customTitle === undefined ? fields.customTitle : record.customTitle.trim() || null,
// Claude uses this sentinel for local error responses. It is not a
// model ID that can be selected when the imported session resumes.
model: model && model !== "<synthetic>" ? model : fields.model,
};
}

// Codex records setup context as user text ahead of the real request. Skip
// these leading blocks when deriving a title; imported messages keep them.
const CODEX_TITLE_PREAMBLE =
Expand All @@ -266,21 +301,26 @@ export function parseAgentSessionTranscript(
): AgentSessionThread | null {
if (lines.length > MAX_IMPORT_RECORDS) return null;
const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line)));
return parseAgentSessionRecords(input, records);
const claudeFields =
input.source === "claudeAgent"
? records.reduce(foldClaudeFields, emptyClaudeFields)
: emptyClaudeFields;
return parseAgentSessionRecords(input, records, claudeFields);
}

function parseAgentSessionRecords(
input: AgentSessionTranscriptMetadata,
records: ReadonlyArray<DecodedTranscriptRecord>,
claudeFields: ClaudeFields,
): AgentSessionThread | null {
const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs));
// Claude filenames are session IDs. Codex rollout filenames include extra
// timestamp text, so only transcript metadata can provide a resumable ID.
let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId;
let title: string | null = null;
let customTitle: string | null = null;
let providerSessionId =
input.source === "codex" ? "" : (claudeFields.sessionId ?? input.fallbackSessionId);
const title = claudeFields.title ?? null;
let model = claudeFields.model ?? null;
let derivedTitle: string | undefined;
let model: string | null = null;
let hasCodexSessionId = false;
const messages: Array<AgentSessionThreadMessage & { readonly codexResponseUser: boolean }> = [];
let firstUserMessage:
Expand Down Expand Up @@ -372,27 +412,8 @@ function parseAgentSessionRecords(
for (const record of records) {
recordIndex += 1;
if (input.source === "claudeAgent") {
if (
record.isSidechain === true ||
record.isMeta === true ||
record.isCompactSummary === true
) {
continue;
}
if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim();
if (record.aiTitle?.trim()) title = record.aiTitle.trim();
// Claude rewrites both title records on every flush, so the user's
// rename is tracked separately instead of racing the later ai-title.
// An empty customTitle is how Claude clears a rename.
if (record.customTitle !== undefined) customTitle = record.customTitle.trim() || null;
const messageModel = record.message?.model?.trim();
// Claude uses this sentinel for local error responses. It is not a
// model ID that can be selected when the imported session resumes.
if (messageModel && messageModel !== "<synthetic>") model = messageModel;
if (record.type !== "user" && record.type !== "assistant") {
continue;
}

if (isHiddenClaudeRecord(record)) continue;
if (record.type !== "user" && record.type !== "assistant") continue;
const text = extractText(record.message?.content);
if (text.length === 0) continue;
retainMessage({
Expand Down Expand Up @@ -477,7 +498,7 @@ function parseAgentSessionRecords(
source: input.source,
providerInstanceId: input.providerInstanceId,
providerSessionId,
title: customTitle ?? title ?? derivedTitle ?? "Imported thread",
title: claudeFields.customTitle ?? title ?? derivedTitle ?? "Imported thread",
model,
createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp,
updatedAt: fallbackTimestamp,
Expand All @@ -494,17 +515,18 @@ function shouldRetainDecodedRecord(
source: AgentSessionSource,
record: DecodedTranscriptRecord,
): boolean {
if (extractDecodedCwd(record) !== null) return true;
if (source === "claudeAgent") {
return (
record.type === "user" ||
record.type === "assistant" ||
record.sessionId !== undefined ||
record.aiTitle !== undefined ||
record.customTitle !== undefined ||
record.message?.model !== undefined
);
if (isHiddenClaudeRecord(record) || (record.type !== "user" && record.type !== "assistant")) {
return false;
}
const content = record.message?.content;
return typeof content === "string"
? content.trim().length > 0
: (content?.some(
(block) => isTextContentBlock(block) && (block.text?.trim().length ?? 0) > 0,
) ?? false);
}
if (extractDecodedCwd(record) !== null) return true;
return (
record.type === "session_meta" ||
record.type === "turn_context" ||
Expand Down Expand Up @@ -802,6 +824,8 @@ export const make = Effect.gen(function* () {
return null;
}
const records: Array<DecodedTranscriptRecord> = [];
let cwd = Option.none<string>();
let claudeFields = emptyClaudeFields;
let historyBytes = 0;
let recordBytes = 0;
let recordCount = 0;
Expand All @@ -823,9 +847,15 @@ export const make = Effect.gen(function* () {
recordCount += 1;
if (recordCount > recordLimit) return false;
const decoded = decodeTranscriptValue(reader.finish());
if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) {
records.push(decoded.value);
historyBytes += recordBytes;
if (Option.isSome(decoded)) {
if (Option.isNone(cwd)) cwd = Option.fromNullOr(extractDecodedCwd(decoded.value));
if (source === "claudeAgent") {
claudeFields = foldClaudeFields(claudeFields, decoded.value);
}
if (shouldRetainDecodedRecord(source, decoded.value)) {
records.push(decoded.value);
historyBytes += recordBytes;
}
}
recordBytes = 0;
reader = createTranscriptJsonReader(reserve, selectTranscriptPath);
Expand Down Expand Up @@ -861,7 +891,7 @@ export const make = Effect.gen(function* () {

if (recordStarted && !(yield* Effect.try(finishRecord))) return null;
return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))
? { records, recordCount }
? { records, recordCount, cwd, claudeFields }
: null;
}),
),
Expand Down Expand Up @@ -1382,15 +1412,10 @@ export const make = Effect.gen(function* () {
}
recordsRemaining -= snapshot.recordCount;

let snapshotCwd: string | null = null;
for (const record of snapshot.records) {
snapshotCwd = extractDecodedCwd(record);
if (snapshotCwd !== null) break;
}
if (snapshotCwd === null) {
if (Option.isNone(snapshot.cwd)) {
return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
}
const expandedCwd = expandHomePath(snapshotCwd.trim(), homeDir);
const expandedCwd = expandHomePath(snapshot.cwd.value.trim(), homeDir);
if (
!path.isAbsolute(expandedCwd) ||
(yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity
Expand All @@ -1406,6 +1431,7 @@ export const make = Effect.gen(function* () {
lastActiveAtMs: transcript.mtimeMs,
},
snapshot.records,
snapshot.claudeFields,
);
if (parsedThread === null) {
return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
Expand Down
Loading