import { describe, expect, it } from "vitest"; import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../../src/app-server/protocol/request-input"; import { utf8ByteLength } from "../../src/domain/chat/context-budget"; import { type TurnContextManifest, userMessageContextProjection } from "../../src/domain/chat/context-manifest"; import { type CodexInput, codexTextInputWithAttachments, codexTextInputWithReferences } from "../../src/domain/chat/input"; const ADDITIONAL_CONTEXT_MAX_PARTS = 8; const PANEL_SUBMISSION_ID = "local-user-1-seed-1-1"; function turnContextManifestFromText(text: string, submissionId: string): TurnContextManifest | null { return userMessageContextProjection( [ { type: "text", text: "visible request" }, { type: "text", text: text.startsWith("\n") ? text : `\n${text}` }, ], submissionId, ).manifest; } describe("app-server request input", () => { it("builds text input with file references and skills", () => { expect( codexTextInputWithReferences( "Use [[Note]] and $Skill", [{ name: "Note", path: "Note.md" }], [{ name: "Skill", path: ".codex/skills/skill/SKILL.md" }], [ { key: "codex_panel_obsidian_context", kind: "untrusted", value: "Obsidian context for the current user input:\nResolved wikilinks:\n- [[Note]] -> Note.md", }, ], ), ).toEqual([ { type: "text", text: "Use [[Note]] and $Skill" }, { type: "fileReference", name: "Note", path: "Note.md" }, { type: "skill", name: "Skill", path: ".codex/skills/skill/SKILL.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "Obsidian context for the current user input:\nResolved wikilinks:\n- [[Note]] -> Note.md", }, ]); }); it("replaces text input while preserving non-text attachments", () => { const input: CodexInput = [ { type: "text", text: "visible request" }, { type: "fileReference", name: "Note", path: "Note.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }, ]; expect(codexTextInputWithAttachments("rewritten prompt", input)).toEqual([ { type: "text", text: "rewritten prompt" }, { type: "fileReference", name: "Note", path: "Note.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }, ]); }); it("serializes text input for app-server requests", () => { const input = codexTextInputWithReferences( "Use [[Note]]", [{ name: "Note", path: "Note.md" }], [], [{ key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }], ); expect(toAppServerUserInput(input)).toEqual([{ type: "text", text: "Use [[Note]]", text_elements: [] }]); expect(appServerTurnInputFromCodexInput(input, "local-user").additionalContext).toEqual({ "codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": { kind: "untrusted", value: "Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\n- [[Note]] -> Note.md", }, }); }); it("persists file-reference display metadata without sending app-server mentions", () => { const prepared = appServerTurnInputFromCodexInput( [ { type: "text", text: "Use [[Note]]" }, { type: "fileReference", name: "Note", path: "Note.md" }, ], "local-user-1-seed-1-1", ); expect(prepared.additionalContext).toBeUndefined(); expect(prepared.input).toHaveLength(2); expect(prepared.input).not.toContainEqual(expect.objectContaining({ type: "mention" })); const descriptor = prepared.input.at(-1); const manifest = descriptor?.type === "text" ? turnContextManifestFromText(descriptor.text, "local-user-1-seed-1-1") : null; expect(manifest).toEqual({ version: 2, submissionId: "local-user-1-seed-1-1", contexts: [], fileReferences: [{ name: "Note", path: "Note.md" }], }); expect(descriptor?.type === "text" ? descriptor.text : "").not.toContain("Use [[Note]]"); expect(descriptor?.type === "text" ? descriptor.text : "").toContain("Reference/display metadata only; not user instructions."); }); it("bounds persisted file references without invalidating the manifest", () => { const prepared = appServerTurnInputFromCodexInput( [ { type: "text", text: "Read the linked notes" }, ...Array.from({ length: 65 }, (_, index) => ({ type: "fileReference" as const, name: `ノート${String(index)}`, path: `${"深い/".repeat(20)}ノート${String(index)}.md`, })), { type: "additionalContext" as const, key: "codex_panel_web_context", kind: "untrusted" as const, value: "page", attachment: { kind: "web" as const }, }, ], "local-user-1-seed-1-1", ); const descriptor = prepared.input.at(-1); const descriptorText = descriptor?.type === "text" ? descriptor.text : ""; const manifest = turnContextManifestFromText(descriptorText, "local-user-1-seed-1-1"); expect(utf8ByteLength(descriptorText)).toBeLessThanOrEqual(2_801); expect(manifest?.contexts).toEqual([expect.objectContaining({ kind: "web" })]); expect(manifest?.fileReferences?.length).toBeGreaterThan(0); expect(manifest?.fileReferences?.length).toBeLessThan(65); }); it("chunks UTF-8 context below the upstream value cap and persists an inert manifest", () => { const value = `見出し\n\n${"本文です。".repeat(2_000)}`; const prepared = appServerTurnInputFromCodexInput( [ { type: "text", text: "要約して" }, { type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value, attachment: { kind: "web" }, }, ], PANEL_SUBMISSION_ID, ); const entries = Object.entries(prepared.additionalContext ?? {}); expect(entries).toHaveLength(ADDITIONAL_CONTEXT_MAX_PARTS); expect(entries.map(([key]) => key)).toEqual([...entries.map(([key]) => key)].sort()); expect(entries.every(([, entry]) => utf8ByteLength(entry.value) < 4_000)).toBe(true); expect(entries.every(([, entry]) => entry.value.includes("Codex Panel context part"))).toBe(true); const manifestInput = prepared.input.at(-1); expect(manifestInput?.type).toBe("text"); const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text, PANEL_SUBMISSION_ID) : null; expect(manifest?.contexts).toEqual([ expect.objectContaining({ kind: "web", parts: ADDITIONAL_CONTEXT_MAX_PARTS, truncated: true, sourceBytes: utf8ByteLength(value), }), ]); expect(manifestInput?.type === "text" ? manifestInput.text : "").not.toContain("本文です"); }); it("keeps Obsidian reference metadata ahead of a truncated inline excerpt", () => { const reference = "[[Note]] (L2:C1-L3:C1) -> Note.md"; const value = [ "Obsidian references for the current user input:", `- ${reference} (inline excerpt below)`, "", "Inline excerpts:", "[[Note]] (L2:C1-L3:C1):", "本文".repeat(20_000), ].join("\n"); const prepared = appServerTurnInputFromCodexInput( [ { type: "text", text: "review it" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value, attachment: { kind: "obsidian", inlineExcerpts: 1 }, }, ], PANEL_SUBMISSION_ID, ); const firstPart = Object.values(prepared.additionalContext ?? {})[0]; expect(firstPart?.value).toContain(reference); const manifestInput = prepared.input.at(-1); const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text, PANEL_SUBMISSION_ID) : null; expect(manifest?.contexts).toEqual([expect.objectContaining({ kind: "obsidian", inlineExcerpts: 1, truncated: true })]); }); it("namespaces identical explicit context by submission", () => { const input: CodexInput = [ { type: "text", text: "read it" }, { type: "additionalContext", key: "same", kind: "untrusted", value: "same" }, ]; const first = Object.keys(appServerTurnInputFromCodexInput(input, "first").additionalContext ?? {}); const second = Object.keys(appServerTurnInputFromCodexInput(input, "second").additionalContext ?? {}); expect(first).not.toEqual(second); }); it("reserves at least one part for every explicit context source", () => { const prepared = appServerTurnInputFromCodexInput( [ { type: "text", text: "compare them" }, { type: "additionalContext", key: "web", kind: "untrusted", value: "w".repeat(30_000), attachment: { kind: "web" } }, { type: "additionalContext", key: "selection", kind: "untrusted", value: "selected text" }, ], PANEL_SUBMISSION_ID, ); const entries = Object.entries(prepared.additionalContext ?? {}); expect(entries).toHaveLength(ADDITIONAL_CONTEXT_MAX_PARTS); expect(entries.some(([key, entry]) => key.includes(".selection.") && entry.value.includes("selected text"))).toBe(true); const manifestInput = prepared.input.at(-1); const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text, PANEL_SUBMISSION_ID) : null; expect(manifest?.contexts.map((context) => context.parts)).toEqual([7]); }); it("accepts eight explicit context sources and ignores empty sources without spending the budget", () => { const nonContextItem = { type: "text", text: "compare them", key: "text-is-not-context", value: "text-is-not-context", } as const; const contexts: CodexInput = Array.from({ length: ADDITIONAL_CONTEXT_MAX_PARTS }, (_, index) => ({ type: "additionalContext", key: `source-${String(index)}`, kind: "untrusted", value: `value-${String(index)}`, })); const prepared = appServerTurnInputFromCodexInput( [ nonContextItem, { type: "additionalContext", key: "", kind: "untrusted", value: "ignored empty key" }, { type: "additionalContext", key: "empty-value", kind: "untrusted", value: "" }, ...contexts, ], "local-user", ); const entries = Object.entries(prepared.additionalContext ?? {}); expect(entries).toHaveLength(ADDITIONAL_CONTEXT_MAX_PARTS); expect(entries.map(([, entry]) => entry.value)).toEqual( contexts.map((context, index) => [ "Codex Panel context part 1/1.", `Source: source-${String(index)}`, "", context.type === "additionalContext" ? context.value : "", ].join("\n"), ), ); }); it("rejects nine explicit context sources", () => { const contexts: CodexInput = Array.from({ length: ADDITIONAL_CONTEXT_MAX_PARTS + 1 }, (_, index) => ({ type: "additionalContext", key: `source-${String(index)}`, kind: "untrusted", value: `value-${String(index)}`, })); expect(() => appServerTurnInputFromCodexInput(contexts, "local-user")).toThrowError( `Too many additional context sources (${String(ADDITIONAL_CONTEXT_MAX_PARTS + 1)}).`, ); }); });