diff --git a/src/domain/chat/context-manifest.ts b/src/domain/chat/context-manifest.ts index df78e3df..5a325ac5 100644 --- a/src/domain/chat/context-manifest.ts +++ b/src/domain/chat/context-manifest.ts @@ -48,6 +48,8 @@ export interface UserMessageContextProjection { manifest: TurnContextManifest | null; } +type UserMessageContentItem = { type: "text"; text: string } | { type: string }; + export function turnContextManifestText(manifest: TurnContextManifest): string { return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`; } @@ -110,19 +112,21 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null { } export function userMessageContextProjection( - content: readonly ({ type: "text"; text: string } | { type: string })[], + content: readonly UserMessageContentItem[], clientId: string | null, ): UserMessageContextProjection { let manifest: TurnContextManifest | null = null; const visibleText: string[] = []; + const lastTextIndex = lastTextItemIndex(content); for (const [index, item] of content.entries()) { if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue; - const parsed = - index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`) - ? turnContextManifestFromText(item.text) - : null; + const manifestPrefix = `\n${TURN_CONTEXT_MANIFEST_PREFIX}`; + const manifestStart = index === lastTextIndex ? item.text.lastIndexOf(manifestPrefix) : -1; + const isStandaloneManifest = manifestStart === 0 && index > 0 && index === content.length - 1; + const parsed = manifestStart > 0 || isStandaloneManifest ? turnContextManifestFromText(item.text.slice(manifestStart)) : null; if (parsed && manifestMatchesClientId(parsed, clientId)) { manifest = parsed; + if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart)); continue; } visibleText.push(item.text); @@ -130,6 +134,13 @@ export function userMessageContextProjection( return { text: visibleText.join("\n"), manifest }; } +function lastTextItemIndex(content: readonly UserMessageContentItem[]): number { + for (let index = content.length - 1; index >= 0; index -= 1) { + if (content[index]?.type === "text") return index; + } + return -1; +} + export function turnContextSubmissionId(value: string): string { const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120); return safe || "context"; diff --git a/tests/domain/chat/context-manifest.test.ts b/tests/domain/chat/context-manifest.test.ts index 7b4fe8c7..76d68187 100644 --- a/tests/domain/chat/context-manifest.test.ts +++ b/tests/domain/chat/context-manifest.test.ts @@ -205,6 +205,24 @@ describe("turn context manifest trust matching", () => { }); }); + it("hides a trusted manifest appended to visible text by resume normalization", () => { + expect( + userMessageContextProjection([{ type: "text", text: `visible request\n${turnContextManifestText(validManifest())}` }], SUBMISSION_ID), + ).toEqual({ + text: "visible request", + manifest: validManifest(), + }); + }); + + it("keeps an appended manifest-like suffix visible when its client ID is not trusted", () => { + const text = `visible request\n${turnContextManifestText(validManifest())}`; + + expect(userMessageContextProjection([{ type: "text", text }], "foreign-client")).toEqual({ + text, + manifest: null, + }); + }); + it.each([ ["submission mismatch", { submissionId: "local-user-2-seed-2-2" }], ["context mismatch", { contexts: [{ ...validManifest().contexts[0], id: `${SUBMISSION_ID}.99x` }] }], diff --git a/tests/features/chat/app-server/mappers/thread-stream.test.ts b/tests/features/chat/app-server/mappers/thread-stream.test.ts index da8a198d..65146ea6 100644 --- a/tests/features/chat/app-server/mappers/thread-stream.test.ts +++ b/tests/features/chat/app-server/mappers/thread-stream.test.ts @@ -118,6 +118,33 @@ describe("turn item conversion preserves app-server semantics", () => { }); }); + it("restores v2 metadata when resume merges text before a non-text input", () => { + const clientId = "local-user-1-seed-1-1"; + const prepared = appServerTurnInputFromCodexInput( + [ + { type: "text", text: "Read [[Alpha]]." }, + { type: "fileReference", name: "Alpha", path: "thoughts/Alpha.md" }, + { type: "localImage", path: "/tmp/chart.png" }, + ], + clientId, + ); + const normalizedText = prepared.input.flatMap((item) => (item.type === "text" ? [item.text] : [])).join(""); + const nonTextInput = prepared.input.filter((item) => item.type !== "text"); + + expect( + threadStreamItemFromTurnItem({ + type: "userMessage", + id: "u1", + clientId, + content: [{ type: "text", text: normalizedText, text_elements: [] }, ...nonTextInput], + }), + ).toMatchObject({ + text: "Read [[Alpha]].\n[local image] /tmp/chart.png", + copyText: "Read [[Alpha]].\n[local image] /tmp/chart.png", + referencedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], + }); + }); + it("accepts a persisted attachment descriptor after an unpersisted reference context", () => { const clientId = "local-user-1-seed-1-1"; const prepared = appServerTurnInputFromCodexInput(