fix(context): hide normalized resume manifests

This commit is contained in:
murashit 2026-07-21 14:04:14 +09:00
parent d74747fbf0
commit 03305a47ef
3 changed files with 61 additions and 5 deletions

View file

@ -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";

View file

@ -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` }] }],

View file

@ -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(