2026-06-09 22:47:29 +00:00
|
|
|
import { describe, expect, it } from "vitest";
|
2026-07-20 09:09:42 +00:00
|
|
|
import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../../src/app-server/protocol/request-input";
|
2026-07-18 01:20:46 +00:00
|
|
|
import { utf8ByteLength } from "../../src/domain/chat/context-budget";
|
2026-07-18 02:10:07 +00:00
|
|
|
import { type CodexInput, codexTextInputWithAttachments, codexTextInputWithReferences } from "../../src/domain/chat/input";
|
2026-06-09 22:47:29 +00:00
|
|
|
|
2026-07-20 09:09:42 +00:00
|
|
|
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
|
|
|
|
|
const PANEL_SUBMISSION_ID = "local-user-1-seed-1-1";
|
|
|
|
|
|
2026-06-09 22:47:29 +00:00
|
|
|
describe("app-server request input", () => {
|
2026-07-18 02:10:07 +00:00
|
|
|
it("builds text input with file references and skills", () => {
|
2026-06-09 22:47:29 +00:00
|
|
|
expect(
|
2026-07-18 02:10:07 +00:00
|
|
|
codexTextInputWithReferences(
|
2026-06-09 22:47:29 +00:00
|
|
|
"Use [[Note]] and $Skill",
|
|
|
|
|
[{ name: "Note", path: "Note.md" }],
|
|
|
|
|
[{ name: "Skill", path: ".codex/skills/skill/SKILL.md" }],
|
2026-07-04 23:59:50 +00:00
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
key: "codex_panel_obsidian_context",
|
|
|
|
|
kind: "untrusted",
|
|
|
|
|
value: "Obsidian context for the current user input:\nResolved wikilinks:\n- [[Note]] -> Note.md",
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-06-09 22:47:29 +00:00
|
|
|
),
|
|
|
|
|
).toEqual([
|
2026-06-10 02:49:43 +00:00
|
|
|
{ type: "text", text: "Use [[Note]] and $Skill" },
|
2026-07-18 02:10:07 +00:00
|
|
|
{ type: "fileReference", name: "Note", path: "Note.md" },
|
2026-06-09 22:47:29 +00:00
|
|
|
{ type: "skill", name: "Skill", path: ".codex/skills/skill/SKILL.md" },
|
2026-06-16 02:01:21 +00:00
|
|
|
{
|
|
|
|
|
type: "additionalContext",
|
2026-07-04 23:59:50 +00:00
|
|
|
key: "codex_panel_obsidian_context",
|
2026-06-16 02:01:21 +00:00
|
|
|
kind: "untrusted",
|
2026-07-04 23:59:50 +00:00
|
|
|
value: "Obsidian context for the current user input:\nResolved wikilinks:\n- [[Note]] -> Note.md",
|
2026-06-16 02:01:21 +00:00
|
|
|
},
|
2026-06-09 22:47:29 +00:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("replaces text input while preserving non-text attachments", () => {
|
2026-06-10 02:49:43 +00:00
|
|
|
const input: CodexInput = [
|
|
|
|
|
{ type: "text", text: "visible request" },
|
2026-07-18 02:10:07 +00:00
|
|
|
{ type: "fileReference", name: "Note", path: "Note.md" },
|
2026-07-04 23:59:50 +00:00
|
|
|
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" },
|
2026-06-09 22:47:29 +00:00
|
|
|
];
|
|
|
|
|
|
2026-06-10 02:49:43 +00:00
|
|
|
expect(codexTextInputWithAttachments("rewritten prompt", input)).toEqual([
|
|
|
|
|
{ type: "text", text: "rewritten prompt" },
|
2026-07-18 02:10:07 +00:00
|
|
|
{ type: "fileReference", name: "Note", path: "Note.md" },
|
2026-07-04 23:59:50 +00:00
|
|
|
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" },
|
2026-06-10 02:49:43 +00:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("serializes text input for app-server requests", () => {
|
2026-07-18 02:10:07 +00:00
|
|
|
const input = codexTextInputWithReferences(
|
2026-06-16 02:01:21 +00:00
|
|
|
"Use [[Note]]",
|
|
|
|
|
[{ name: "Note", path: "Note.md" }],
|
|
|
|
|
[],
|
2026-07-04 23:59:50 +00:00
|
|
|
[{ key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }],
|
2026-06-16 02:01:21 +00:00
|
|
|
);
|
|
|
|
|
|
2026-07-18 02:10:07 +00:00
|
|
|
expect(toAppServerUserInput(input)).toEqual([{ type: "text", text: "Use [[Note]]", text_elements: [] }]);
|
2026-07-21 06:37:26 +00:00
|
|
|
const prepared = appServerTurnInputFromCodexInput(input, "local-user");
|
|
|
|
|
expect(prepared.input).toEqual([{ type: "text", text: "Use [[Note]]", text_elements: [] }]);
|
|
|
|
|
expect(prepared.additionalContext).toEqual({
|
2026-07-18 01:20:46 +00:00
|
|
|
"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",
|
|
|
|
|
},
|
2026-06-16 02:01:21 +00:00
|
|
|
});
|
2026-06-09 22:47:29 +00:00
|
|
|
});
|
2026-07-18 01:20:46 +00:00
|
|
|
|
2026-07-21 06:37:26 +00:00
|
|
|
it("does not persist Vault file-reference display metadata in app-server history", () => {
|
2026-07-18 02:10:07 +00:00
|
|
|
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();
|
2026-07-21 06:37:26 +00:00
|
|
|
expect(prepared.input).toEqual([{ type: "text", text: "Use [[Note]]", text_elements: [] }]);
|
2026-07-18 02:10:07 +00:00
|
|
|
expect(prepared.input).not.toContainEqual(expect.objectContaining({ type: "mention" }));
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-21 06:37:26 +00:00
|
|
|
it("does not persist metadata for Vault files or explicit context", () => {
|
2026-07-18 02:10:07 +00:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"local-user-1-seed-1-1",
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-21 06:37:26 +00:00
|
|
|
expect(prepared.input).toEqual([{ type: "text", text: "Read the linked notes", text_elements: [] }]);
|
|
|
|
|
expect(prepared.additionalContext).toMatchObject({
|
|
|
|
|
"codex_panel.local-user-1-seed-1-1.00.codex_panel_web_context.part_01_of_01": {
|
|
|
|
|
kind: "untrusted",
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-07-18 02:10:07 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-21 06:37:26 +00:00
|
|
|
it("chunks UTF-8 context below the upstream value cap without adding visible metadata", () => {
|
2026-07-18 01:20:46 +00:00
|
|
|
const value = `見出し\n\n${"本文です。".repeat(2_000)}`;
|
|
|
|
|
const prepared = appServerTurnInputFromCodexInput(
|
|
|
|
|
[
|
|
|
|
|
{ type: "text", text: "要約して" },
|
|
|
|
|
{
|
|
|
|
|
type: "additionalContext",
|
|
|
|
|
key: "codex_panel_web_context",
|
|
|
|
|
kind: "untrusted",
|
|
|
|
|
value,
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-07-20 09:09:42 +00:00
|
|
|
PANEL_SUBMISSION_ID,
|
2026-07-18 01:20:46 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-07-21 06:37:26 +00:00
|
|
|
expect(prepared.input).toEqual([{ type: "text", text: "要約して", text_elements: [] }]);
|
|
|
|
|
expect(prepared.input.some((item) => item.type === "text" && item.text.includes("[Codex Panel context v2]"))).toBe(false);
|
2026-07-18 01:20:46 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-18 01:52:25 +00:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-07-20 09:09:42 +00:00
|
|
|
PANEL_SUBMISSION_ID,
|
2026-07-18 01:52:25 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const firstPart = Object.values(prepared.additionalContext ?? {})[0];
|
|
|
|
|
expect(firstPart?.value).toContain(reference);
|
2026-07-21 06:37:26 +00:00
|
|
|
expect(prepared.input).toEqual([{ type: "text", text: "review it", text_elements: [] }]);
|
2026-07-18 01:52:25 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-18 01:20:46 +00:00
|
|
|
it("namespaces identical explicit context by submission", () => {
|
|
|
|
|
const input: CodexInput = [
|
|
|
|
|
{ type: "text", text: "read it" },
|
|
|
|
|
{ type: "additionalContext", key: "same", kind: "untrusted", value: "same" },
|
|
|
|
|
];
|
2026-07-20 09:09:42 +00:00
|
|
|
const first = Object.keys(appServerTurnInputFromCodexInput(input, "first").additionalContext ?? {});
|
|
|
|
|
const second = Object.keys(appServerTurnInputFromCodexInput(input, "second").additionalContext ?? {});
|
2026-07-18 01:20:46 +00:00
|
|
|
expect(first).not.toEqual(second);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reserves at least one part for every explicit context source", () => {
|
|
|
|
|
const prepared = appServerTurnInputFromCodexInput(
|
|
|
|
|
[
|
|
|
|
|
{ type: "text", text: "compare them" },
|
2026-07-21 06:37:26 +00:00
|
|
|
{ type: "additionalContext", key: "web", kind: "untrusted", value: "w".repeat(30_000) },
|
2026-07-18 01:20:46 +00:00
|
|
|
{ type: "additionalContext", key: "selection", kind: "untrusted", value: "selected text" },
|
|
|
|
|
],
|
2026-07-20 09:09:42 +00:00
|
|
|
PANEL_SUBMISSION_ID,
|
2026-07-18 01:20:46 +00:00
|
|
|
);
|
|
|
|
|
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);
|
2026-07-21 06:37:26 +00:00
|
|
|
expect(entries.filter(([key]) => key.includes(".web."))).toHaveLength(7);
|
|
|
|
|
expect(prepared.input).toEqual([{ type: "text", text: "compare them", text_elements: [] }]);
|
2026-07-18 01:20:46 +00:00
|
|
|
});
|
2026-07-18 10:21:13 +00:00
|
|
|
|
|
|
|
|
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)}).`,
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-09 22:47:29 +00:00
|
|
|
});
|