From e98e46200074e32f9a01acda1395ca28c94716ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:54:34 +0000 Subject: [PATCH 01/11] test(server): reproduce #24067 imported Codex and Claude sessions show injected context These tests reproduce pingdotgg/t3code#24067: Codex response_item context blocks and question-reply envelopes, the Codex files-mentioned request prefix, and Claude local slash-command wrappers and output leak into imported user messages. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index baf0f27f9edb3..197c445758e00 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2180,6 +2180,110 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); }); + it("imports only Codex user events when the rollout records them", () => { + 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("\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."), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Which database should we use?", + "Pick one?", + "PostgreSQL it is.", + ]); + }); + + it("imports a Codex request after the files-mentioned prefix", () => { + const 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"); + 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("Hey, what's up?"); + expect(thread?.messages.map((message) => message.text)).toEqual(["Hey, what's up?"]); + }); + + 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("skips sessions without a visible user message", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: JSON.stringify({ From 755ae6a1b09d60779b18caacdc196961f6a0baba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:01:35 +0000 Subject: [PATCH 02/11] fix(server): imported sessions no longer show provider-internal context Codex rollouts that record user events now import only those events, as Codex's own history does, instead of deduping response copies by turn ID. The typed request after "## My request for Codex:" is imported and titled. Claude local slash commands import as typed and their local output is dropped. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 93 ------- .../server/src/project/AgentSessionScanner.ts | 233 ++++++------------ 2 files changed, 81 insertions(+), 245 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 197c445758e00..c4c02c94d30de 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1907,99 +1907,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: [ diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 9c26b187a55bf..48928620e64b1 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -72,10 +72,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), timestamp: Schema.optional(Schema.String), @@ -97,7 +93,6 @@ const TranscriptRecord = Schema.Struct({ model: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), content: Schema.optional(Schema.Array(TranscriptContentBlock)), - internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), }), ), }); @@ -107,7 +102,6 @@ const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); -const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); type DecodedTranscriptRecord = typeof TranscriptRecord.Type; @@ -231,18 +225,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; } @@ -280,7 +262,56 @@ function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord) // 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 = - /^(?:\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:))+/; + /^(?:\s*(?:<(recommended_plugins|environment_context|user_instructions|INSTRUCTIONS|in-app-browser-context)(?:\s[^>]*)?>[\s\S]*?<\/\1>|# AGENTS\.md instructions\b[^\n]*))+/; + +// Codex's USER_MESSAGE_BEGIN: attachments and context precede it, the typed request follows. +const CODEX_REQUEST_MARKER = "## My request for Codex:"; + +function codexRequestText(text: string): string { + const index = text.indexOf(CODEX_REQUEST_MARKER); + return index === -1 ? text : text.slice(index + CODEX_REQUEST_MARKER.length).trim(); +} + +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; +} function deriveTitle(source: AgentSessionSource, text: string): string | undefined { const request = source === "codex" ? text.replace(CODEX_TITLE_PREAMBLE, "") : text; @@ -316,106 +347,38 @@ 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] : [], - ), + const messages: Array = []; + let firstUserMessage: AgentSessionThreadMessage | undefined; + // Like Codex's own history, rollouts with user events show only those; their + // response copies add injected context. Older rollouts only have responses. + const hasCodexUserEvents = + input.source === "codex" && + records.some( + (record) => record.type === "event_msg" && record.payload?.type === "user_message", ); - for (const responseUser of responseUsersInTurn) { - if (canonicalTurnIds.has(responseUser.turnId)) { - canonicalCodexResponseUserIndices.add(responseUser.index); - } - } - canonicalUserTextsInTurn = new Set(); - responseUsersInTurn = []; - }; - 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; - } - 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 }); - } - } - } - finishCodexTurn(); - } 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); } 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 records) { - recordIndex += 1; 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; } @@ -432,59 +395,25 @@ function parseAgentSessionRecords( 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); - break; - } - } - retainMessage({ - role: "user", - text, - createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), - codexResponseUser: false, - }); - continue; - } - if ( - record.type !== "response_item" || - record.payload?.type !== "message" || - (record.payload.role !== "user" && record.payload.role !== "assistant") - ) { + retainMessage("user", codexRequestText(record.payload.message ?? ""), record.timestamp); 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") { + retainMessage("assistant", extractText(record.payload.content), record.timestamp); + } else if (record.payload.role === "user" && !hasCodexUserEvents) { + retainMessage( + "user", + codexRequestText(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, From bda3d22f5d48e8fc1f54b19036c7a1273645207e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:23:38 +0000 Subject: [PATCH 03/11] fix(server): import paginated Codex rollouts and question replies readably Paginated Codex rollouts have no user_message events, so onboarding import fell back to response items and showed AGENTS.md and environment context. Completed UserMessage items now count as user events. Answered-question envelopes import as quoted Q&A like Codex shows them, and the request marker only strips a prefix when it occupies a whole line. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 185 ++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 77 ++++++-- 2 files changed, 245 insertions(+), 17 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index c4c02c94d30de..d8fe2d947ebe5 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1173,6 +1173,72 @@ 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("suppresses duplicate session copies without reporting a skipped import", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -2191,6 +2257,125 @@ describe("parseAgentSessionTranscript", () => { ]); }); + 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("keeps a Codex request that mentions the request marker in prose", () => { + const prompt = + "Explain why Codex adds ## My request for Codex: to prompts.\nShow where it is defined."; + 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("Explain why Codex adds ## My request for Codex: to prompts."); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + 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 48928620e64b1..1d41a72dbcf48 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -93,15 +93,32 @@ const TranscriptRecord = Schema.Struct({ model: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), content: Schema.optional(Schema.Array(TranscriptContentBlock)), + 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 decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); +const decodeCodexQuestionReplies = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Union([CodexQuestionReply, Schema.NonEmptyArray(CodexQuestionReply)]), + ), +); type DecodedTranscriptRecord = typeof TranscriptRecord.Type; @@ -264,12 +281,44 @@ 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]*))+/; -// Codex's USER_MESSAGE_BEGIN: attachments and context precede it, the typed request follows. -const CODEX_REQUEST_MARKER = "## My request for Codex:"; +// Codex's USER_MESSAGE_BEGIN line: attachments and context precede it, the typed request follows. +const CODEX_REQUEST_MARKER = /(?:^|\n)## My request for Codex:(?=\n|$)/; function codexRequestText(text: string): string { - const index = text.indexOf(CODEX_REQUEST_MARKER); - return index === -1 ? text : text.slice(index + CODEX_REQUEST_MARKER.length).trim(); + const marker = CODEX_REQUEST_MARKER.exec(text); + return marker === null ? text : text.slice(marker.index + marker[0].length).trim(); +} + +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"); +} + +// 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; + if (record.payload?.type === "user_message") return record.payload.message ?? ""; + if (record.payload?.type === "item_completed" && record.payload.item?.type === "UserMessage") { + return extractText(record.payload.item.content); + } + return null; } const CLAUDE_LOCAL_COMMAND_TAGS = new Set([ @@ -350,12 +399,9 @@ function parseAgentSessionRecords( const messages: Array = []; let firstUserMessage: AgentSessionThreadMessage | undefined; // Like Codex's own history, rollouts with user events show only those; their - // response copies add injected context. Older rollouts only have responses. + // response copies add injected context. Rollouts without them only have responses. const hasCodexUserEvents = - input.source === "codex" && - records.some( - (record) => record.type === "event_msg" && record.payload?.type === "user_message", - ); + input.source === "codex" && records.some((record) => codexUserEventText(record) !== null); const retainMessage = ( role: AgentSessionThreadMessage["role"], @@ -394,19 +440,16 @@ function parseAgentSessionRecords( model = record.payload.model.trim(); continue; } - if (record.type === "event_msg" && record.payload?.type === "user_message") { - retainMessage("user", codexRequestText(record.payload.message ?? ""), record.timestamp); + const userEventText = codexUserEventText(record); + if (userEventText !== null) { + retainMessage("user", codexUserText(userEventText), record.timestamp); continue; } if (record.type !== "response_item" || record.payload?.type !== "message") continue; if (record.payload.role === "assistant") { retainMessage("assistant", extractText(record.payload.content), record.timestamp); } else if (record.payload.role === "user" && !hasCodexUserEvents) { - retainMessage( - "user", - codexRequestText(extractText(record.payload.content)), - record.timestamp, - ); + retainMessage("user", codexUserText(extractText(record.payload.content)), record.timestamp); } } @@ -451,7 +494,7 @@ function shouldRetainDecodedRecord( return ( record.type === "session_meta" || record.type === "turn_context" || - (record.type === "event_msg" && record.payload?.type === "user_message") || + codexUserEventText(record) !== null || (record.type === "response_item" && record.payload?.type === "message" && (record.payload.role === "user" || record.payload.role === "assistant")) From f651bd79c4f5b28b9824f89f9ba9bbfec8c02911 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:38:56 +0000 Subject: [PATCH 04/11] fix(server): strip the Codex request prefix only after generated context Codex prompts that open with generated IDE, file or browser context end it at the last request line; unwrapped prompts that quote the marker keep their text. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 47 +++++++++++++++++-- .../server/src/project/AgentSessionScanner.ts | 16 +++++-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index d8fe2d947ebe5..a81c513c9fe67 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2338,9 +2338,46 @@ describe("parseAgentSessionTranscript", () => { }, ); - it("keeps a Codex request that mentions the request marker in prose", () => { - const prompt = - "Explain why Codex adds ## My request for Codex: to prompts.\nShow where it is defined."; + 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: "takes the request after the last marker when IDE context selects one", + prompt: [ + "# Context from my IDE setup:", + "", + "## Active selection of the file:", + "## My request for Codex:", + "Old selected template text", + "", + "## My request for Codex:", + "Please fix this parser", + ].join("\n"), + expected: "Please fix this parser", + }, + ])("$name", ({ prompt, expected }) => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: [ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), @@ -2355,8 +2392,8 @@ describe("parseAgentSessionTranscript", () => { lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), }); - expect(thread?.title).toBe("Explain why Codex adds ## My request for Codex: to prompts."); - expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + 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", () => { diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1d41a72dbcf48..885cf8b5239a7 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -281,12 +281,20 @@ 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]*))+/; -// Codex's USER_MESSAGE_BEGIN line: attachments and context precede it, the typed request follows. -const CODEX_REQUEST_MARKER = /(?:^|\n)## My request for Codex:(?=\n|$)/; +// Codex prompts that open with generated context end it at the last request line +// (its extract_prompt_request); unwrapped prompts are the user's own text. +const CODEX_CONTEXT_OPENERS = [ + "# Context from my IDE setup:", + "# Files mentioned by the user:", + " opening.startsWith(opener))) return text; + const marker = Array.from(text.matchAll(CODEX_REQUEST_MARKER)).at(-1); + return marker === undefined ? text : text.slice(marker.index + marker[0].length).trim(); } const CODEX_QUESTION_REPLY_OPEN = ""; From a3409ecee671a54b6390e40b13fdebbcb2bf9caa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 11:04:33 +0000 Subject: [PATCH 05/11] fix(server): keep attachment-only Codex turns and strip referenced-chat context Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 32 +++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 5 ++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index a81c513c9fe67..459071d949ab9 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2377,6 +2377,38 @@ describe("parseAgentSessionTranscript", () => { ].join("\n"), expected: "Please fix this parser", }, + { + 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: [ diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 885cf8b5239a7..9a26a8cd73315 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -287,6 +287,7 @@ const CODEX_CONTEXT_OPENERS = [ "# Context from my IDE setup:", "# Files mentioned by the user:", " opening.startsWith(opener))) return text; const marker = Array.from(text.matchAll(CODEX_REQUEST_MARKER)).at(-1); - return marker === undefined ? text : text.slice(marker.index + marker[0].length).trim(); + 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 = ""; From 8dc1db70a364844018ded3888377a4696517a380 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 11:10:37 +0000 Subject: [PATCH 06/11] docs: describe how imported Codex and Claude history is cleaned Co-Authored-By: Claude Opus 5.5 --- docs/user/welcome-wizard.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index d4604fa7e6668..3a7bee2d7efeb 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -79,10 +79,11 @@ Imported projects include Codex and Claude conversations active within the last 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 ignores malformed records and skips From f9c9214c11856289329c9089b627f7c5ed7654cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:02:13 +0000 Subject: [PATCH 07/11] test(server): fold the files-mentioned Codex prefix case into the request table AI-assisted; verified with the tests and tools listed above. --- .../src/project/AgentSessionScanner.test.ts | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 9bce37577d965..71e6077744aef 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2874,36 +2874,6 @@ describe("parseAgentSessionTranscript", () => { ]); }); - it("imports a Codex request after the files-mentioned prefix", () => { - const 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"); - 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("Hey, what's up?"); - expect(thread?.messages.map((message) => message.text)).toEqual(["Hey, what's up?"]); - }); - it("imports Claude slash commands as typed and drops their local output", () => { const user = (content: string) => JSON.stringify({ @@ -3058,6 +3028,21 @@ describe("parseAgentSessionTranscript", () => { ].join("\n"), expected: "Please fix this parser", }, + { + 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: [ From c4fec1f49a8b23a024df4077d5cc3d20e6bc3ab1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:15:26 +0000 Subject: [PATCH 08/11] fix(server): keep Codex prompts whose request line appears more than once When the request marker repeats, either the generated context or the user's request may quote it, so taking the last one could drop the user's text. AI-assisted; verified with the tests and tools listed above. --- .../src/project/AgentSessionScanner.test.ts | 25 +++++++++++++++---- .../server/src/project/AgentSessionScanner.ts | 7 +++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 71e6077744aef..0c25cf8c08f4f 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -3015,18 +3015,33 @@ describe("parseAgentSessionTranscript", () => { ].join("\n"), }, { - name: "takes the request after the last marker when IDE context selects one", + name: "keeps IDE context whose request quotes the marker line", prompt: [ "# Context from my IDE setup:", "", - "## Active selection of the file:", + "## Open tabs:", + "- template.md", + "", + "## My request for Codex:", + "Fix this template without dropping its introduction:", + "```markdown", "## My request for Codex:", - "Old selected template text", + "Write the introduction", + "```", + ].join("\n"), + expected: [ + "# Context from my IDE setup:", + "", + "## Open tabs:", + "- template.md", "", "## My request for Codex:", - "Please fix this parser", + "Fix this template without dropping its introduction:", + "```markdown", + "## My request for Codex:", + "Write the introduction", + "```", ].join("\n"), - expected: "Please fix this parser", }, { name: "takes the request after the files-mentioned prefix", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 2a71b909d7f03..1c0197aaab0c0 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -339,8 +339,8 @@ 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]*))+/; -// Codex prompts that open with generated context end it at the last request line -// (its extract_prompt_request); unwrapped prompts are the user's own text. +// 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:", @@ -352,7 +352,8 @@ const CODEX_REQUEST_MARKER = /\n## My request for Codex:(?=\n|$)/g; function codexRequestText(text: string): string { const opening = text.trimStart(); if (!CODEX_CONTEXT_OPENERS.some((opener) => opening.startsWith(opener))) return text; - const marker = Array.from(text.matchAll(CODEX_REQUEST_MARKER)).at(-1); + 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; From e846b469e7237d5ed28b5c9c61eb71b0b7aa4a90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:01:03 +0000 Subject: [PATCH 09/11] fix(server): keep image-only Codex prompts and request-based titles An image-only Codex prompt has no text, so with response copies suppressed the turn vanished; it now imports as Codex's own [Image #n] placeholders. Response-only prompts whose setup context is followed by the request line are titled after the request again, and a prompt recorded as both a user_message and a completed UserMessage imports once. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 21 ++++++++++ .../server/src/project/AgentSessionScanner.ts | 38 +++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index ccc5915d4c481..586c4134c35a0 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -3267,6 +3267,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({ @@ -3313,6 +3314,23 @@ describe("parseAgentSessionTranscript", () => { '\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" }] }, + }, + }), ].join("\n"), source: "codex", providerInstanceId: ProviderInstanceId.make("codex"), @@ -3324,6 +3342,9 @@ describe("parseAgentSessionTranscript", () => { "Which database should we use?", "Pick one?", "PostgreSQL it is.", + "[Image #1]", + "Nice screenshot.", + "Ship it", ]); }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 818f0e640ece6..81843b5d0073f 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -135,6 +135,8 @@ const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), role: Schema.optional(Schema.String), message: Schema.optional(Schema.String), + images: Schema.optional(Schema.Unknown), + local_images: Schema.optional(Schema.Unknown), model: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), history_base: Schema.optional(Schema.Unknown), @@ -359,7 +361,7 @@ function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord) // 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 = - /^(?:\s*(?:<(recommended_plugins|environment_context|user_instructions|INSTRUCTIONS|in-app-browser-context)(?:\s[^>]*)?>[\s\S]*?<\/\1>|# AGENTS\.md instructions\b[^\n]*))+/; + /^(?:\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. @@ -403,12 +405,28 @@ function codexUserText(text: string): string { .join("\n\n"); } +// 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; - if (record.payload?.type === "user_message") return record.payload.message ?? ""; - if (record.payload?.type === "item_completed" && record.payload.item?.type === "UserMessage") { - return extractText(record.payload.item.content); + const payload = record.payload; + if (payload?.type === "user_message") { + const imageCount = + (Array.isArray(payload.images) ? payload.images.length : 0) + + (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; } @@ -562,6 +580,7 @@ function parseAgentSessionRecords( // response copies add injected context. Rollouts without them only have responses. const hasCodexUserEvents = input.source === "codex" && records.some((record) => codexUserEventText(record) !== null); + let codexTurnUserEvents: Array<{ readonly type: string | undefined; readonly text: string }> = []; let conversationMessageCount = 0; const retainMessage = ( @@ -610,11 +629,22 @@ function parseAgentSessionRecords( } 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; + } + codexTurnUserEvents.push({ type: eventType, text: userEventText }); retainMessage("user", codexUserText(userEventText), record.timestamp); 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" && !hasCodexUserEvents) { retainMessage("user", codexUserText(extractText(record.payload.content)), record.timestamp); From 111bfe8c852bf93a7fe26324fb305620e1a006ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:13:18 +0000 Subject: [PATCH 10/11] fix(server): keep unmatched Codex response turns and skip inline images A rollout with any user event dropped every response-only user turn, so legacy prompts resumed in a newer Codex lost their text and question replies vanished. Response copies are now skipped only in turns that have a user event. Counting inline `images` selected their whole data URLs, which spent the history budget and skipped screenshot-heavy rollouts; image-only prompts now count local image paths only. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 56 ++++++++++++++++++- .../server/src/project/AgentSessionScanner.ts | 35 +++++++++--- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 586c4134c35a0..4dbe32a2849d8 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1856,6 +1856,54 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports a 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-"); + 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: "event_msg", + payload: { + type: "user_message", + message: "Describe this screenshot", + images: [`data:image/png;base64,${"A".repeat(17 * 1024 * 1024)}`], + }, + }), + 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([ + "Describe this screenshot", + "A login form.", + ]); + }), + ); + it.effect("suppresses duplicate session copies without reporting a skipped import", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -3288,7 +3336,7 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); }); - it("imports only Codex user events when the rollout records them", () => { + it("imports a Codex turn's user event instead of its response copies", () => { const responseUser = (text: string) => encodeTranscriptRecord({ type: "response_item", @@ -3302,6 +3350,8 @@ describe("parseAgentSessionTranscript", () => { 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?"), @@ -3338,9 +3388,13 @@ describe("parseAgentSessionTranscript", () => { 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.", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 81843b5d0073f..277273f2a7984 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -135,7 +135,6 @@ const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), role: Schema.optional(Schema.String), message: Schema.optional(Schema.String), - images: Schema.optional(Schema.Unknown), local_images: Schema.optional(Schema.Unknown), model: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), @@ -416,9 +415,8 @@ function codexUserEventText(record: DecodedTranscriptRecord): string | null { if (record.type !== "event_msg") return null; const payload = record.payload; if (payload?.type === "user_message") { - const imageCount = - (Array.isArray(payload.images) ? payload.images.length : 0) + - (Array.isArray(payload.local_images) ? payload.local_images.length : 0); + // 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") { @@ -576,10 +574,29 @@ function parseAgentSessionRecords( let hasCodexSessionId = false; const messages: Array = []; let firstUserMessage: AgentSessionThreadMessage | undefined; - // Like Codex's own history, rollouts with user events show only those; their - // response copies add injected context. Rollouts without them only have responses. - const hasCodexUserEvents = - input.source === "codex" && records.some((record) => codexUserEventText(record) !== null); + // 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 turnResponseUsers: Array = []; + let turnHasUserEvent = false; + const finishTurn = () => { + if (turnHasUserEvent) { + for (const record of turnResponseUsers) codexEventBackedResponseUsers.add(record); + } + turnResponseUsers = []; + turnHasUserEvent = false; + }; + for (const record of records) { + if (codexUserEventText(record) !== null) { + turnHasUserEvent = true; + } 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); + } + } + finishTurn(); + } let codexTurnUserEvents: Array<{ readonly type: string | undefined; readonly text: string }> = []; let conversationMessageCount = 0; @@ -646,7 +663,7 @@ function parseAgentSessionRecords( if (record.payload.role === "assistant") { codexTurnUserEvents = []; retainMessage("assistant", extractText(record.payload.content), record.timestamp); - } else if (record.payload.role === "user" && !hasCodexUserEvents) { + } else if (record.payload.role === "user" && !codexEventBackedResponseUsers.has(record)) { retainMessage("user", codexUserText(extractText(record.payload.content)), record.timestamp); } } From fad07945952ac4596c9c7710e2761dc22a4a6762 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:25:24 +0000 Subject: [PATCH 11/11] fix(server): keep Codex response turns that a user event cannot replace A user event with no text, such as an inline image alone, hid every response copy in its turn, so an image-only conversation imported nothing. Such events no longer stand in for their copies. An interrupted turn ends without an assistant message, so its prompt merged into the next event-backed turn and was hidden; turn_aborted and task_complete now end a turn too. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 125 ++++++++++++------ .../server/src/project/AgentSessionScanner.ts | 17 ++- 2 files changed, 96 insertions(+), 46 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 4dbe32a2849d8..c46a37b4c7a9b 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1856,52 +1856,75 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("imports a 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-"); - 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: "event_msg", - payload: { - type: "user_message", - message: "Describe this screenshot", - images: [`data:image/png;base64,${"A".repeat(17 * 1024 * 1024)}`], - }, - }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "A login form." }], - }, - }), - ].join("\n"), - mtimeMs: nowMs, - }); + 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, - }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); - expect(threads[0]?.messages.map((message) => message.text)).toEqual([ - "Describe this screenshot", - "A login form.", - ]); - }), + expect(threads[0]?.messages.map((message) => message.text)).toEqual([ + context, + "A login form.", + ]); + }), ); it.effect("suppresses duplicate session copies without reporting a skipped import", () => @@ -3381,6 +3404,16 @@ describe("parseAgentSessionTranscript", () => { 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"), @@ -3399,6 +3432,10 @@ describe("parseAgentSessionTranscript", () => { "[Image #1]", "Nice screenshot.", "Ship it", + "Shipped.", + "Review the old code", + "Review the new code", + "Done", ]); }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 277273f2a7984..d9774b06a1005 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -404,6 +404,14 @@ function codexUserText(text: string): string { .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; @@ -588,8 +596,12 @@ function parseAgentSessionRecords( turnHasUserEvent = false; }; for (const record of records) { - if (codexUserEventText(record) !== null) { - turnHasUserEvent = true; + 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); @@ -713,6 +725,7 @@ function shouldRetainDecodedRecord( record.type === "session_meta" || record.type === "turn_context" || codexUserEventText(record) !== null || + isCodexTurnEnd(record) || (record.type === "response_item" && record.payload?.type === "message" && (record.payload.role === "user" || record.payload.role === "assistant"))