diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 82890ca9c8353..c46a37b4c7a9b 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1790,6 +1790,143 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports paginated Codex rollouts from their completed user message items", () => + 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 workspace = yield* makeTempDir("t3code-workspace-"); + const responseItem = (role: "user" | "assistant", text: string) => + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role, + content: [{ type: role === "user" ? "input_text" : "output_text", text }], + }, + }); + + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-paged.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "paged-session", cwd: workspace }, + }), + responseItem("user", "\nBe friendly.\n"), + responseItem( + "user", + "\n/tmp/project\n", + ), + responseItem("user", "Add a health check"), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "item_completed", + thread_id: "paged-session", + turn_id: "turn-1", + item: { + type: "UserMessage", + id: "item-1", + content: [ + { type: "text", text: "Add a health check", text_elements: [] }, + { type: "local_image", path: "/tmp/screenshot.png" }, + ], + }, + }, + }), + responseItem("assistant", "Added GET /health."), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads[0]?.title).toBe("Add a health check"); + expect(threads[0]?.messages.map((message) => message.text)).toEqual([ + "Add a health check", + "Added GET /health.", + ]); + }), + ); + + it.effect( + "imports an image-only Codex prompt whose inline image exceeds the history budget", + () => + 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 workspace = yield* makeTempDir("t3code-workspace-"); + const context = "\n/tmp/project\n"; + const image = `data:image/png;base64,${"A".repeat(17 * 1024 * 1024)}`; + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-image.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "image-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: context }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "", images: [image], local_images: [] }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: image }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "A login form." }], + }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads[0]?.messages.map((message) => message.text)).toEqual([ + context, + "A login form.", + ]); + }), + ); + it.effect("suppresses duplicate session copies without reporting a skipped import", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -3041,99 +3178,6 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); }); - it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, - content: [{ type: "input_text", text: "Keep this older prompt" }], - }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Keep this newer prompt" }, - }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, - content: [{ type: "input_text", text: "Keep this newer prompt" }], - }, - }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "Ask again when needed" }], - }, - }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, - content: [{ type: "input_text", text: "Keep this newer prompt" }], - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), - }); - - expect(thread?.messages.map((message) => message.text)).toEqual([ - "Keep this older prompt", - "Keep this newer prompt", - "Ask again when needed", - "Keep this newer prompt", - ]); - }); - - it("preserves response user text when Codex turn metadata is ambiguous", () => { - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: ["unexpected"], - content: [{ type: "input_text", text: "Keep this legacy prompt" }], - }, - }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: { turn_id: " " }, - content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), - }); - - expect(thread?.messages.map((message) => message.text)).toEqual([ - "Keep this legacy prompt", - "Keep this prompt with a blank turn ID", - ]); - }); - it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: [ @@ -3294,6 +3338,7 @@ describe("parseAgentSessionTranscript", () => { "\nGitHub\n", "# AGENTS.md instructions for /tmp/project\n\n\nRules\n", '\nTab\n', + "## My request for Codex:", "Fix the import titles", ].join("\n"); const thread = AgentSessionScanner.parseAgentSessionTranscript({ @@ -3314,6 +3359,338 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); }); + it("imports a Codex turn's user event instead of its response copies", () => { + const responseUser = (text: string) => + encodeTranscriptRecord({ + type: "response_item", + payload: { type: "message", role: "user", content: [{ type: "input_text", text }] }, + }); + const assistant = (text: string) => + encodeTranscriptRecord({ + type: "response_item", + payload: { type: "message", role: "assistant", content: [{ type: "output_text", text }] }, + }); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + responseUser("Older actual prompt"), + assistant("Older answer"), + responseUser("\nBe friendly.\n"), + responseUser("\n/tmp/project\n"), + responseUser("Which database should we use?"), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Which database should we use?" }, + }), + assistant("Pick one?"), + responseUser( + '\n[{"answer":"Use PostgreSQL","question":"Which database?","questionItemId":"q-1"}]\n', + ), + assistant("PostgreSQL it is."), + responseUser("\n"), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "", local_images: ["/tmp/shot.png"] }, + }), + assistant("Nice screenshot."), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Ship it" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "item_completed", + item: { type: "UserMessage", content: [{ type: "text", text: "Ship it" }] }, + }, + }), + assistant("Shipped."), + responseUser("Review the old code"), + encodeTranscriptRecord({ type: "event_msg", payload: { type: "turn_aborted" } }), + encodeTranscriptRecord({ type: "event_msg", payload: { type: "task_started" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Review the new code" }, + }), + responseUser("Review the new code"), + assistant("Done"), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Older actual prompt"); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Older actual prompt", + "Older answer", + "Which database should we use?", + "Pick one?", + "> Which database?\n\nUse PostgreSQL", + "PostgreSQL it is.", + "[Image #1]", + "Nice screenshot.", + "Ship it", + "Shipped.", + "Review the old code", + "Review the new code", + "Done", + ]); + }); + + it("imports Claude slash commands as typed and drops their local output", () => { + const user = (content: string) => + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content }, + }); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + user("Summarise the README"), + user( + "/compact\ncompact\n", + ), + user("Compacted "), + user( + "/model\nmodel\nopus", + ), + user("Unknown model"), + user("Now add a license"), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Summarise the README", + "/compact", + "/model opus", + "Now add a license", + ]); + }); + + it.each([ + { + name: "one reply", + message: + '\n{"questionItemId":"q-1","question":"Which database?","answer":"Use PostgreSQL"}\n', + expected: "> Which database?\n\nUse PostgreSQL", + }, + { + name: "several replies after the IDE context", + message: [ + "# Context from my IDE setup:", + "", + "## Open tabs:", + "- schema.sql", + "", + "## My request for Codex:", + '[{"questionItemId":"q-1","question":"First?","answer":"Yes"},{"questionItemId":"q-2","question":"Second?","answer":"No\\nreally"}]', + ].join("\n"), + expected: "> First?\n\nYes\n\n> Second?\n\nNo\nreally", + }, + { + name: "a malformed envelope", + message: + '[{"questionItemId":"q-1","question":"Which database?"}]', + expected: + '[{"questionItemId":"q-1","question":"Which database?"}]', + }, + { + name: "an empty reply list", + message: "[]", + expected: "[]", + }, + { + name: "a quoted envelope in prose", + message: + 'Why does Codex send {"questionItemId":"q-1","question":"Q?","answer":"A"} here?', + expected: + 'Why does Codex send {"questionItemId":"q-1","question":"Q?","answer":"A"} here?', + }, + ])( + "imports a Codex question reply with $name from every user record", + ({ message, expected }) => { + const userEvents = [ + { type: "user_message", message }, + { + type: "item_completed", + turn_id: "turn-1", + item: { + type: "UserMessage", + id: "item-1", + content: [{ type: "text", text: message, text_elements: [] }], + }, + }, + ]; + const responseOnly = encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: message }], + }, + }); + for (const record of [ + ...userEvents.map((payload) => encodeTranscriptRecord({ type: "event_msg", payload })), + responseOnly, + ]) { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + record, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + expect(thread?.messages.map((entry) => entry.text)).toEqual([expected]); + } + }, + ); + + it.each([ + { + name: "keeps a request that mentions the marker in prose", + prompt: + "Explain why Codex adds ## My request for Codex: to prompts.\nShow where it is defined.", + expected: + "Explain why Codex adds ## My request for Codex: to prompts.\nShow where it is defined.", + }, + { + name: "keeps a request that quotes the marker line in a code block", + prompt: [ + "Fix this template without dropping its introduction:", + "```markdown", + "## My request for Codex:", + "Write the introduction", + "```", + ].join("\n"), + expected: [ + "Fix this template without dropping its introduction:", + "```markdown", + "## My request for Codex:", + "Write the introduction", + "```", + ].join("\n"), + }, + { + name: "keeps IDE context whose request quotes the marker line", + prompt: [ + "# Context from my IDE setup:", + "", + "## Open tabs:", + "- template.md", + "", + "## My request for Codex:", + "Fix this template without dropping its introduction:", + "```markdown", + "## My request for Codex:", + "Write the introduction", + "```", + ].join("\n"), + expected: [ + "# Context from my IDE setup:", + "", + "## Open tabs:", + "- template.md", + "", + "## My request for Codex:", + "Fix this template without dropping its introduction:", + "```markdown", + "## My request for Codex:", + "Write the introduction", + "```", + ].join("\n"), + }, + { + name: "takes the request after the files-mentioned prefix", + prompt: [ + "# Files mentioned by the user:", + "", + "## screenshot.png: /tmp/attachments/screenshot.png", + "Image attachment: true", + "", + '\nTab\n', + "", + "## My request for Codex:", + "Hey, what's up?", + ].join("\n"), + expected: "Hey, what's up?", + }, + { + name: "takes the request after referenced Codex chats", + prompt: [ + "## Referenced chats with Codex:", + "These are live references to Codex tasks, not task contents.", + '[{"threadId":"thread-1"}]', + "## My request for Codex:", + "Compare it with this one", + ].join("\n"), + expected: "Compare it with this one", + }, + { + name: "keeps an attachment sent without a request", + prompt: [ + "# Files mentioned by the user:", + "", + "## shot.png: /tmp/shot.png", + "Image attachment: true", + "", + "## My request for Codex:", + "", + ].join("\n"), + expected: [ + "# Files mentioned by the user:", + "", + "## shot.png: /tmp/shot.png", + "Image attachment: true", + "", + "## My request for Codex:", + "", + ].join("\n"), + }, + ])("$name", ({ prompt, expected }) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: prompt }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(expected.split("\n")[0]); + expect(thread?.messages.map((message) => message.text)).toEqual([expected]); + }); + + it("keeps Claude prose that mentions a command tag", () => { + const prompt = "Why does /compact show up in my transcript?"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: prompt }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + it("skips sessions without a visible user message", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: JSON.stringify({ diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index d9ca41b8f26b1..d9774b06a1005 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -113,10 +113,6 @@ const TranscriptMessage = Schema.Struct({ model: Schema.optional(Schema.String), }); -const CodexTurnMetadata = Schema.Struct({ - turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), -}); - const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), // Pi entries form a tree through id/parentId; session_info entries carry its name. @@ -139,15 +135,27 @@ const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), role: Schema.optional(Schema.String), message: Schema.optional(Schema.String), + local_images: Schema.optional(Schema.Unknown), model: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), history_base: Schema.optional(Schema.Unknown), content: Schema.optional(Schema.Array(TranscriptContentBlock)), - internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + item: Schema.optional( + Schema.Struct({ + type: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + }), + ), }), ), }); +const CodexQuestionReply = Schema.Struct({ + questionItemId: Schema.String, + question: Schema.String, + answer: Schema.String, +}); + const CodexSessionIndexEntry = Schema.Struct({ id: Schema.String, thread_name: Schema.String, @@ -165,7 +173,11 @@ const decodePiEntryLinks = Schema.decodeUnknownOption( }), ); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); -const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); +const decodeCodexQuestionReplies = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Union([CodexQuestionReply, Schema.NonEmptyArray(CodexQuestionReply)]), + ), +); export const decodeImportedTranscriptPayload = Schema.decodeUnknownOption( Schema.Struct({ cwd: Schema.optional(Schema.String), @@ -311,18 +323,6 @@ function normalizeTimestamp(value: string | undefined, fallback: string): string return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; } -function codexTurnId(metadata: unknown): string | null { - const decoded = decodeCodexTurnMetadata(metadata); - if ( - Option.isNone(decoded) || - typeof decoded.value.turn_id !== "string" || - decoded.value.turn_id.trim().length === 0 - ) { - return null; - } - return decoded.value.turn_id; -} - function isHiddenClaudeRecord(record: DecodedTranscriptRecord): boolean { return record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true; } @@ -362,6 +362,122 @@ function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord) const CODEX_TITLE_PREAMBLE = /^(?:\s*(?:<(recommended_plugins|environment_context|user_instructions|INSTRUCTIONS|in-app-browser-context)(?:\s[^>]*)?>[\s\S]*?<\/\1>|# AGENTS\.md instructions\b[^\n]*|## My request for Codex:))+/; +// Codex prompts that open with generated context end it at the request line. +// When that line appears more than once, either side may quote it, so keep the text. +const CODEX_CONTEXT_OPENERS = [ + "# Context from my IDE setup:", + "# Files mentioned by the user:", + " opening.startsWith(opener))) return text; + const markers = Array.from(text.matchAll(CODEX_REQUEST_MARKER)); + const marker = markers.length === 1 ? markers[0] : undefined; + const request = marker === undefined ? "" : text.slice(marker.index + marker[0].length).trim(); + // An attachment sent without text has an empty request; keep the turn as recorded. + return request || text; +} + +const CODEX_QUESTION_REPLY_OPEN = ""; +const CODEX_QUESTION_REPLY_CLOSE = ""; + +// Mirrors Codex's async_question_reply display: answered questions are sent as this envelope. +function codexUserText(text: string): string { + const request = codexRequestText(text); + const envelope = request.trim(); + if ( + !envelope.startsWith(CODEX_QUESTION_REPLY_OPEN) || + !envelope.endsWith(CODEX_QUESTION_REPLY_CLOSE) + ) { + return request; + } + const replies = decodeCodexQuestionReplies( + envelope.slice(CODEX_QUESTION_REPLY_OPEN.length, -CODEX_QUESTION_REPLY_CLOSE.length), + ); + if (Option.isNone(replies)) return request; + return (Array.isArray(replies.value) ? replies.value : [replies.value]) + .map((reply) => `> ${reply.question}\n\n${reply.answer}`) + .join("\n\n"); +} + +// An interrupted turn ends without an assistant message. +function isCodexTurnEnd(record: DecodedTranscriptRecord): boolean { + return ( + record.type === "event_msg" && + (record.payload?.type === "turn_aborted" || record.payload?.type === "task_complete") + ); +} + +// Codex shows a prompt's images as these placeholders; an image-only prompt is only them. +function codexImagePlaceholders(text: string, imageCount: number): string { + if (text.trim().length > 0) return text; + return Array.from({ length: imageCount }, (_, index) => `[Image #${index + 1}]`).join(" "); +} + +// Legacy rollouts record user_message events; paginated ones record completed UserMessage items. +function codexUserEventText(record: DecodedTranscriptRecord): string | null { + if (record.type !== "event_msg") return null; + const payload = record.payload; + if (payload?.type === "user_message") { + // Only local image paths are read: inline `images` hold whole data URLs. + const imageCount = Array.isArray(payload.local_images) ? payload.local_images.length : 0; + return codexImagePlaceholders(payload.message ?? "", imageCount); + } + if (payload?.type === "item_completed" && payload.item?.type === "UserMessage") { + const content = payload.item.content ?? []; + const imageCount = content.filter( + (block) => block.type === "image" || block.type === "local_image", + ).length; + return codexImagePlaceholders(extractText(content), imageCount); + } + return null; +} + +const CLAUDE_LOCAL_COMMAND_TAGS = new Set([ + "command-name", + "command-message", + "command-args", + "local-command-stdout", + "local-command-stderr", +]); + +// Claude stores local slash commands and their output as user text made only of these tags. +function parseClaudeLocalCommandTags(text: string): Map | null { + const tags = new Map(); + const whitespace = /\s*/y; + let position = 0; + while (true) { + whitespace.lastIndex = position; + whitespace.test(text); + position = whitespace.lastIndex; + if (position === text.length) return tags; + if (text[position] !== "<") return null; + const nameEnd = text.indexOf(">", position); + if (nameEnd === -1) return null; + const name = text.slice(position + 1, nameEnd); + if (!CLAUDE_LOCAL_COMMAND_TAGS.has(name)) return null; + const closingTag = ``; + const contentEnd = text.indexOf(closingTag, nameEnd + 1); + if (contentEnd === -1) return null; + tags.set(name, text.slice(nameEnd + 1, contentEnd)); + position = contentEnd + closingTag.length; + } +} + +// Mirrors the Claude transcript view: the command as typed, never its local output. +function claudeUserText(text: string): string | null { + const tags = parseClaudeLocalCommandTags(text); + if (tags === null) return text; + const command = tags.get("command-name")?.trim(); + if (!command) return null; + const args = tags.get("command-args")?.trim(); + return args ? `${command} ${args}` : command; +} + // Codex appends an entry per rename; like Codex, the newest nonempty name wins. function parseCodexThreadNames(contents: string): ReadonlyMap { const names = new Map(); @@ -464,120 +580,67 @@ function parseAgentSessionRecords( let model = claudeFields.model ?? null; let derivedTitle: string | undefined; let hasCodexSessionId = false; - const messages: Array = []; - let firstUserMessage: - | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) - | undefined; - const canonicalCodexResponseUserIndices = new Set(); - let canonicalUserTextsInTurn = new Set(); - let responseUsersInTurn: Array<{ - readonly index: number; - readonly turnId: string; - readonly text: string; - }> = []; - const finishCodexTurn = () => { - const canonicalTurnIds = new Set( - responseUsersInTurn.flatMap((responseUser) => - canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], - ), - ); - for (const responseUser of responseUsersInTurn) { - if (canonicalTurnIds.has(responseUser.turnId)) { - canonicalCodexResponseUserIndices.add(responseUser.index); - } - } - canonicalUserTextsInTurn = new Set(); - responseUsersInTurn = []; - }; + const messages: Array = []; + let firstUserMessage: AgentSessionThreadMessage | undefined; + // Like Codex's own history, a turn with a user event shows only that; its response + // copies add injected context. Turns without one, as in older rollouts, only have responses. + const codexEventBackedResponseUsers = new Set(); if (input.source === "codex") { - let recordIndex = -1; - for (const record of records) { - recordIndex += 1; - if ( - record.type === "response_item" && - record.payload?.type === "message" && - record.payload.role === "assistant" - ) { - finishCodexTurn(); - continue; + let turnResponseUsers: Array = []; + let turnHasUserEvent = false; + const finishTurn = () => { + if (turnHasUserEvent) { + for (const record of turnResponseUsers) codexEventBackedResponseUsers.add(record); } - if (record.type === "event_msg" && record.payload?.type === "user_message") { - const text = record.payload.message?.trim() ?? ""; - if (text.length > 0) canonicalUserTextsInTurn.add(text); - continue; - } - if ( - record.type === "response_item" && - record.payload?.type === "message" && - record.payload.role === "user" - ) { - const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); - const text = extractText(record.payload.content); - if (turnId !== null && text.length > 0) { - responseUsersInTurn.push({ index: recordIndex, turnId, text }); - } + turnResponseUsers = []; + turnHasUserEvent = false; + }; + for (const record of records) { + const eventText = codexUserEventText(record); + if (eventText !== null) { + // An event with nothing to show, like an inline image alone, cannot stand in for its copies. + if (eventText.trim().length > 0) turnHasUserEvent = true; + } else if (isCodexTurnEnd(record)) { + finishTurn(); + } else if (record.type === "response_item" && record.payload?.type === "message") { + if (record.payload.role === "assistant") finishTurn(); + else if (record.payload.role === "user") turnResponseUsers.push(record); } } - finishCodexTurn(); + finishTurn(); } + let codexTurnUserEvents: Array<{ readonly type: string | undefined; readonly text: string }> = []; let conversationMessageCount = 0; const retainMessage = ( - message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + role: AgentSessionThreadMessage["role"], + text: string, + timestamp: string | undefined, ) => { - if (firstUserMessage === undefined && message.role === "user") { - firstUserMessage = message; - } - if (derivedTitle === undefined && message.role === "user") { - derivedTitle = deriveTitle(input.source, message.text); + if (text.trim().length === 0) return; + const message = { role, text, createdAt: normalizeTimestamp(timestamp, fallbackTimestamp) }; + if (role === "user") { + firstUserMessage ??= message; + derivedTitle ??= deriveTitle(input.source, text); } conversationMessageCount += 1; messages.push(message); if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); }; - const hasMatchingCodexEventInTurn = (text: string) => { - const comparisonText = text.trim(); - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index]; - if (message?.role === "assistant") return false; - if ( - message?.role === "user" && - !message.codexResponseUser && - message.text.trim() === comparisonText - ) { - return true; - } - } - return false; - }; - - let recordIndex = -1; for (const record of input.source === "pi" ? activePiBranch(records) : records) { - recordIndex += 1; if (input.source === "pi") { - if (!isPiConversationRecord(record)) continue; - const text = extractText(record.message.content); - if (text.length === 0) continue; - retainMessage({ - role: record.message.role, - text, - createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), - codexResponseUser: false, - }); + if (isPiConversationRecord(record)) { + retainMessage(record.message.role, extractText(record.message.content), record.timestamp); + } continue; } if (input.source === "claudeAgent") { if (isHiddenClaudeRecord(record)) continue; if (record.type !== "user" && record.type !== "assistant") continue; const text = extractText(record.message?.content); - if (text.length === 0) continue; - retainMessage({ - role: record.type, - text, - createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), - codexResponseUser: false, - }); + const visibleText = record.type === "user" ? claudeUserText(text) : text; + if (visibleText !== null) retainMessage(record.type, visibleText, record.timestamp); continue; } @@ -593,61 +656,34 @@ function parseAgentSessionRecords( model = record.payload.model.trim(); continue; } - if (record.type === "event_msg" && record.payload?.type === "user_message") { - const text = record.payload.message ?? ""; - if (text.trim().length === 0) continue; - // Codex can duplicate prompts across formats; remove only matching response copies. - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index]; - if (message?.role === "assistant") break; - if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { - if (firstUserMessage === message) firstUserMessage = undefined; - messages.splice(index, 1); - conversationMessageCount -= 1; - break; - } + const userEventText = codexUserEventText(record); + if (userEventText !== null) { + const eventType = record.payload?.type; + // A rollout that records a prompt in both event forms shows it once. + const copy = codexTurnUserEvents.findIndex( + (event) => event.type !== eventType && event.text === userEventText, + ); + if (copy !== -1) { + codexTurnUserEvents.splice(copy, 1); + continue; } - retainMessage({ - role: "user", - text, - createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), - codexResponseUser: false, - }); + codexTurnUserEvents.push({ type: eventType, text: userEventText }); + retainMessage("user", codexUserText(userEventText), record.timestamp); continue; } - if ( - record.type !== "response_item" || - record.payload?.type !== "message" || - (record.payload.role !== "user" && record.payload.role !== "assistant") - ) { - continue; - } - - const extractedText = extractText(record.payload.content); - if (extractedText.length === 0) continue; - if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { - continue; - } - if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { - continue; + if (record.type !== "response_item" || record.payload?.type !== "message") continue; + if (record.payload.role === "assistant") { + codexTurnUserEvents = []; + retainMessage("assistant", extractText(record.payload.content), record.timestamp); + } else if (record.payload.role === "user" && !codexEventBackedResponseUsers.has(record)) { + retainMessage("user", codexUserText(extractText(record.payload.content)), record.timestamp); } - retainMessage({ - role: record.payload.role, - text: extractedText, - createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), - codexResponseUser: record.payload.role === "user", - }); } - const visibleMessages = messages.map( - ({ codexResponseUser: _codexResponseUser, ...message }) => message, - ); if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; - const firstUserMessageRetained = messages.includes(firstUserMessage); - const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; - const retainedMessages = firstUserMessageRetained - ? visibleMessages - : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const retainedMessages = messages.includes(firstUserMessage) + ? messages + : [firstUserMessage, ...messages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; return { source: input.source, @@ -688,7 +724,8 @@ function shouldRetainDecodedRecord( return ( record.type === "session_meta" || record.type === "turn_context" || - (record.type === "event_msg" && record.payload?.type === "user_message") || + codexUserEventText(record) !== null || + isCodexTurnEnd(record) || (record.type === "response_item" && record.payload?.type === "message" && (record.payload.role === "user" || record.payload.role === "assistant")) diff --git a/docs/user/welcome-page.md b/docs/user/welcome-page.md index 63fa86b52b98d..0fdfdbe832445 100644 --- a/docs/user/welcome-page.md +++ b/docs/user/welcome-page.md @@ -92,10 +92,11 @@ Pi conversation imports its active branch, the one Pi reopens. Conversation import is best effort. T3 Code keeps the first user prompt and the newest remaining visible user and assistant messages, with 200 messages total. -It omits tool activity and attachments. For Codex, it omits generated setup -context only when a canonical user event and a valid shared turn ID identify the -same user turn. Ambiguous legacy or response-only context stays in the imported -conversation so T3 Code does not remove user text. It reads one conversation at +It omits tool activity and attachments. For Codex, it shows the user messages +Codex itself shows, without the generated setup and IDE context, and answered +questions as question and answer. Older Codex conversations that never recorded +those messages keep their setup context. For Claude, slash commands appear as +typed and their local output is omitted. It reads one conversation at a time and skips files larger than 4 GiB. It also skips a conversation when the message text and metadata it keeps need more than 32 MiB of memory. Tool output does not count toward that limit. It skips a conversation with more than 100,000