diff --git a/.github/pr-assets/364-prompt-layer-unmapped.png b/.github/pr-assets/364-prompt-layer-unmapped.png new file mode 100644 index 00000000000..ff1b5ee15b5 Binary files /dev/null and b/.github/pr-assets/364-prompt-layer-unmapped.png differ diff --git a/gui/src/components/codex-set/PromptLayerDialog.tsx b/gui/src/components/codex-set/PromptLayerDialog.tsx index 3b8ae51920a..98d2bc1d779 100644 --- a/gui/src/components/codex-set/PromptLayerDialog.tsx +++ b/gui/src/components/codex-set/PromptLayerDialog.tsx @@ -154,9 +154,11 @@ export default function PromptLayerDialog({ ? t("codexSet.dialog.emptySource", { path: text.sourcePath ?? "" }) : text?.reason === "not-rendered" ? t("codexSet.dialog.notRendered") - : text?.reason === "not-exposed" - ? t("codexSet.dialog.notExposed") - : t("codexSet.dialog.textUnavailable")} + : text?.reason === "unmapped" + ? t("codexSet.dialog.unmapped") + : text?.reason === "not-exposed" + ? t("codexSet.dialog.notExposed") + : t("codexSet.dialog.textUnavailable")}

)} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8a48248ba7e..3c12b24a2d8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1084,6 +1084,7 @@ export const de: Record = { "codexSet.dialog.notRendered": "In der gelesenen Runde hat diese Ebene nichts gesendet. Abschnitte werden nur bei Änderungen erneut gesendet, daher kann eine einzelne Stichprobe sie nicht enthalten.", "codexSet.dialog.emptySource": "Die Datei unter {path} existiert, ist aber leer, daher sendet diese Ebene nichts.", "codexSet.dialog.notExposed": "Der Basis-Prompt läuft außerhalb der Nachrichtenliste, die Codex ausgeben kann, und lässt sich hier nicht anzeigen. Ersetzen ist über model_instructions_file möglich.", + "codexSet.dialog.unmapped": "Für diese Ebene gibt es noch keine bestätigte Zuordnung in der Prompt-Ausgabe, daher kann ihr Text hier noch nicht angezeigt werden.", "codexSet.dialog.textUnavailable": "Der Codex-Prompt konnte auf diesem Rechner nicht gelesen werden, daher ist der Text nicht verfügbar.", "codexSet.class.base": "Basisanweisungen", "codexSet.class.config-toggle": "Hier umschaltbar", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9be7b42263b..c66a5e993be 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1631,6 +1631,7 @@ export const en = { "codexSet.dialog.notRendered": "This layer sent nothing on the turn we read. Sections are only re-sent when they change, so an unchanged layer is absent from a single sample.", "codexSet.dialog.emptySource": "The file at {path} exists but is empty, so this layer sends nothing.", "codexSet.dialog.notExposed": "The base prompt travels outside the message list Codex can print, so it cannot be shown here. Replacing it is possible through model_instructions_file.", + "codexSet.dialog.unmapped": "This layer has no confirmed mapping in the prompt output, so its text cannot be shown here yet.", "codexSet.dialog.textUnavailable": "The Codex prompt could not be read on this machine, so the text is unavailable.", "codexSet.class.base": "Base instructions", "codexSet.class.config-toggle": "Switchable here", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9c119d17d3b..6af22a0e9dc 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1604,6 +1604,7 @@ export const fr: Record = { "codexSet.dialog.notRendered": "Lors du tour que nous avons lu, cette couche n'a rien envoyé. Les sections ne sont renvoyées qu'en cas de changement : un seul échantillon peut donc ne pas la contenir.", "codexSet.dialog.emptySource": "Le fichier {path} existe mais il est vide : cette couche n'envoie donc rien.", "codexSet.dialog.notExposed": "L'invite de base circule en dehors de la liste de messages que Codex peut afficher ; elle ne peut donc pas être montrée ici. On peut la remplacer via model_instructions_file.", + "codexSet.dialog.unmapped": "Cette couche n'a pas encore de correspondance confirmée dans la sortie de l'invite ; son texte ne peut donc pas encore être affiché ici.", "codexSet.dialog.textUnavailable": "L'invite Codex n'a pas pu être lue sur cette machine, le texte est donc indisponible.", "codexSet.class.base": "Instructions de base", "codexSet.class.config-toggle": "Modifiable ici", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dd283a9299c..19ee0ddc00d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1529,6 +1529,7 @@ export const ja: Record = { "codexSet.dialog.notRendered": "確認したターンでは、このレイヤーは何も送信していません。各セクションは内容が変わったときだけ再送されるため、一度の確認では欠けて見えることがあります。", "codexSet.dialog.emptySource": "{path} のファイルは存在しますが空のため、このレイヤーは何も送信しません。", "codexSet.dialog.notExposed": "基本プロンプトは Codex が出力するメッセージ一覧の外を通るため、ここには表示できません。model_instructions_file で置き換えることは可能です。", + "codexSet.dialog.unmapped": "このレイヤーにはプロンプト出力内で確認済みの対応付けがまだないため、ここにはテキストを表示できません。", "codexSet.dialog.textUnavailable": "このマシンでは Codex のプロンプトを読み取れず、原文を表示できません。", "codexSet.class.base": "基本指示", "codexSet.class.config-toggle": "ここで切り替え可能", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1a1909a74f1..f71338a352a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1122,6 +1122,7 @@ export const ko: Record = { "codexSet.dialog.notRendered": "확인한 턴에서는 이 레이어가 아무것도 보내지 않았습니다. 각 섹션은 내용이 바뀔 때만 다시 전송되므로, 한 번 확인한 것만으로는 빠져 보일 수 있습니다.", "codexSet.dialog.emptySource": "{path} 파일은 있지만 비어 있어서 이 레이어는 아무것도 보내지 않습니다.", "codexSet.dialog.notExposed": "기본 프롬프트는 Codex가 출력하는 메시지 목록 밖으로 전달되어 여기서 보여줄 수 없습니다. 대신 model_instructions_file로 교체할 수 있습니다.", + "codexSet.dialog.unmapped": "이 레이어는 프롬프트 출력에서 확인된 매핑이 아직 없어 여기에서 텍스트를 표시할 수 없습니다.", "codexSet.dialog.textUnavailable": "이 컴퓨터에서 Codex 프롬프트를 읽지 못해 원문을 표시할 수 없습니다.", "codexSet.class.base": "기본 지침", "codexSet.class.config-toggle": "여기서 전환 가능", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 485c0b302a7..a7e402cdf0d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1599,6 +1599,7 @@ export const ru: Record = { "codexSet.dialog.notRendered": "На прочитанном нами шаге этот слой ничего не отправил. Разделы пересылаются только при изменении, поэтому в одной выборке слой может отсутствовать.", "codexSet.dialog.emptySource": "Файл {path} существует, но пуст, поэтому этот слой ничего не отправляет.", "codexSet.dialog.notExposed": "Базовый промпт передаётся вне списка сообщений, который Codex может напечатать, поэтому показать его здесь нельзя. Заменить его можно через model_instructions_file.", + "codexSet.dialog.unmapped": "Для этого слоя пока нет подтверждённого сопоставления в выводе промпта, поэтому его текст ещё нельзя показать здесь.", "codexSet.dialog.textUnavailable": "На этой машине не удалось прочитать промпт Codex, поэтому текст недоступен.", "codexSet.class.base": "Базовые инструкции", "codexSet.class.config-toggle": "Переключается здесь", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a1d59281c99..f491c7f5277 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1618,6 +1618,7 @@ export const tr: Record = { "codexSet.dialog.notRendered": "Okuduğumuz turda bu katman hiçbir şey göndermedi. Bölümler yalnızca değiştiklerinde yeniden gönderilir, bu yüzden tek bir örnekte görünmeyebilir.", "codexSet.dialog.emptySource": "{path} konumundaki dosya var ancak boş, bu yüzden bu katman hiçbir şey göndermiyor.", "codexSet.dialog.notExposed": "Temel istem, Codex'in yazdırabildiği mesaj listesinin dışından geçtiği için burada gösterilemez. model_instructions_file ile değiştirilebilir.", + "codexSet.dialog.unmapped": "Bu katmanın istem çıktısında henüz doğrulanmış bir eşlemesi yok, bu nedenle metni burada henüz gösterilemiyor.", "codexSet.dialog.textUnavailable": "Bu makinede Codex istemi okunamadı, bu yüzden metin kullanılamıyor.", "codexSet.class.base": "Temel talimatlar", "codexSet.class.config-toggle": "Buradan değiştirilebilir", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a6ef50b7f8e..b26ec1ea95a 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1580,6 +1580,7 @@ export const vi: Record = { "codexSet.dialog.sourceBytes": "{bytes} byte", "codexSet.dialog.emptySource": "Tệp tại {path} tồn tại nhưng không có dữ liệu, vì vậy layer này không gửi đi nội dung nào cả.", "codexSet.dialog.notExposed": "Base prompt di chuyển bên ngoài danh sách tin nhắn mà Codex có thể in ra, vì vậy không thể hiển thị ở đây. Tuy nhiên có thể thay thế nó thông qua model_instructions_file.", + "codexSet.dialog.unmapped": "Lớp này chưa có ánh xạ được xác nhận trong đầu ra prompt, vì vậy chưa thể hiển thị văn bản của nó ở đây.", "codexSet.dialog.textUnavailable": "Không thể đọc prompt Codex trên máy này, do đó văn bản không khả dụng.", "codexSet.class.base": "Base instructions", "codexSet.class.config-toggle": "Có thể chuyển đổi tại đây", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 0ca62560f43..f23791fee87 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1375,6 +1375,7 @@ export const zhTW: Record = { "codexSet.dialog.notRendered": "在我們讀取的那一輪中,此層沒有傳送任何內容。各區段只在內容變更時才會重新傳送,因此單次取樣可能看不到它。", "codexSet.dialog.emptySource": "{path} 檔案存在但為空,因此此層不會傳送任何內容。", "codexSet.dialog.notExposed": "基礎提示詞不在 Codex 可列印的訊息清單中傳遞,因此無法在此顯示。可以透過 model_instructions_file 取代它。", + "codexSet.dialog.unmapped": "此層在提示詞輸出中尚無已確認的對應,因此暫時無法在此顯示其文字。", "codexSet.dialog.textUnavailable": "本機無法讀取 Codex 提示詞,因此無法顯示原文。", "codexSet.class.base": "基礎指令", "codexSet.class.config-toggle": "可在此切換", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 277cda2e64b..4a8dcbf7274 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1103,6 +1103,7 @@ export const zh: Record = { "codexSet.dialog.notRendered": "在我们读取的那一轮中,此层没有发送任何内容。各部分仅在内容变化时才会重新发送,因此单次采样可能看不到它。", "codexSet.dialog.emptySource": "{path} 文件存在但为空,因此此层不会发送任何内容。", "codexSet.dialog.notExposed": "基础提示词不在 Codex 可打印的消息列表中传递,因此无法在此显示。可以通过 model_instructions_file 替换它。", + "codexSet.dialog.unmapped": "此层在提示词输出中尚无已确认的映射,因此暂时无法在此显示其文本。", "codexSet.dialog.textUnavailable": "本机无法读取 Codex 提示词,因此无法显示原文。", "codexSet.class.base": "基础指令", "codexSet.class.config-toggle": "可在此切换", diff --git a/gui/tests/codex-set-prompt-layers.test.tsx b/gui/tests/codex-set-prompt-layers.test.tsx index 3d654b16aa0..42963d353c0 100644 --- a/gui/tests/codex-set-prompt-layers.test.tsx +++ b/gui/tests/codex-set-prompt-layers.test.tsx @@ -258,6 +258,26 @@ test("9. the dialog names WHY text is missing rather than omitting it silently", await act(async () => { root.unmount(); }); }); +test("9b. an unmapped layer does not receive the base-prompt explanation", async () => { + // A probe that succeeded but has no confirmed tag for a layer is neither + // "unavailable" nor the base prompt's "not-exposed": the not-exposed copy + // names the base prompt and model_instructions_file, which would mislead an + // operator reading it for an unrelated layer. + stubRoutes(call => call.url.endsWith("/api/codex-prompt/text") + ? json({ ok: true, layers: { personality: { text: null, reason: "unmapped", bytes: 0 } } }) + : json(snapshot())); + const { container, root } = await mount(); + await act(async () => { + (row(container, "personality")!.querySelector("button") as HTMLButtonElement).click(); + }); + const dialog = document.querySelector("dialog.modal-overlay")!; + const notice = dialog.querySelector(".codex-set-layer-dialog__no-text")!; + expect(notice.textContent).toContain("no confirmed mapping"); + expect(notice.textContent).not.toContain("base prompt"); + expect(notice.textContent).not.toContain("model_instructions_file"); + await act(async () => { root.unmount(); }); +}); + test("the prompt-text request is aborted when the panel unmounts", async () => { let textSignal: AbortSignal | null = null; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 8af11f00f5e..444b7bb763f 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -75,7 +75,7 @@ const UNMAPPED_LAYER_IDS = [ // The Rust source names a marker pair, but a world-state section is // DIFF-rendered: it emits nothing on a turn where its state has not changed. Live // `codex debug prompt-input` (codex-cli 0.145.0, 32978 bytes) showed no such block and - // no attribution text. Listing the id here reports "not exposed" honestly instead of + // no attribution text. Listing the id here reports "unmapped" honestly instead of // claiming a tag this extractor has never actually matched - the same mistake the // header above records for permissions. "git-attribution", @@ -85,7 +85,7 @@ export interface LayerText { /** Rendered text, when this layer produced a section on the probed turn. */ text: string | null; /** Why the text is absent, when it is. */ - reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unavailable"; + reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unmapped" | "unavailable"; bytes: number; /** * `expanded` is text Codex sends as written. `template` is a catalog @@ -103,7 +103,7 @@ export interface LayerText { /** * Why the base prompt is or is not readable, at the granularity a reader can act - * on. `LayerText.reason` has five coarse values and cannot express any of this, + * on. `LayerText.reason` has six coarse values and cannot express any of this, * which is why the detailed answer travels on its own record. */ export type BasePromptReason = @@ -374,7 +374,7 @@ function readBasePrompt(codexHome: string): BasePromptText { /** * Project the base prompt onto the legacy `base-instructions` layer slot. * - * The slot is lossy by construction - five coarse reasons, no renderer that reads + * The slot is lossy by construction - six coarse reasons, no renderer that reads * `representation` - so it carries only what it can carry honestly: published text * when the source is text Codex sends, and otherwise no text at all. A template is * deliberately NOT `ok` here, because the dialog labels every `ok` layer "Text @@ -944,9 +944,12 @@ export async function probePromptText( // Layers whose rendered tag we have not confirmed against live output. Leaving // them absent made the GUI fall through to "unavailable", which claims the probe - // failed when it succeeded. Saying we have no mapping is the smaller claim. + // failed when it succeeded. Keep this distinct from the base prompt's + // "not-exposed", which is confirmed to travel outside the printable message + // list - reusing it showed a base-prompt-specific explanation for unrelated + // layers. for (const id of UNMAPPED_LAYER_IDS) { - layers[id] ??= { text: null, reason: "not-exposed", bytes: 0 }; + layers[id] ??= { text: null, reason: "unmapped", bytes: 0 }; } return { ok: true, codexHome, layers, base, ...(reportedRuntime ? { runtime: reportedRuntime } : {}) }; } diff --git a/tests/codex-integration/codex-prompt-route.test.ts b/tests/codex-integration/codex-prompt-route.test.ts index feb38632db2..ec90891f2a5 100644 --- a/tests/codex-integration/codex-prompt-route.test.ts +++ b/tests/codex-integration/codex-prompt-route.test.ts @@ -1520,5 +1520,46 @@ describe("020 coverage completions", () => { expect(res.body.code).toBe("invalid_body"); }); + test("41. unmapped layers stay distinct from the unprintable base prompt", async () => { + // "not-exposed" is the base prompt's contract: it is confirmed to travel + // outside the printable message list, and the GUI renders a + // base-prompt-specific explanation for it. Reusing that reason for layers + // whose tag the extractor has not verified showed that explanation on + // unrelated layers. + const fx = fixture(""); + // The probe reads the base prompt from CODEX_HOME - the decoy here, whose + // sentinel config selects model "sentinel". A catalog row that publishes + // only an instructions_template makes the base confirmed unprintable, so + // base-instructions must report "not-exposed" while the tag-less layers + // report "unmapped". + writeFileSync(join(fx.decoyHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "sentinel", model_messages: { instructions_template: "template {{unprintable}}" } }], + }), "utf8"); + const probeOutput = JSON.stringify([{ + type: "message", + role: "developer", + content: [{ type: "input_text", text: "Skill text." }], + }]); + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(probeOutput)});`], + }); + + const res = await call("GET", "/api/codex-prompt/text", fx); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.base.representation).toBe("template"); + expect(res.body.layers["base-instructions"].reason).toBe("not-exposed"); + // Mirrors UNMAPPED_LAYER_IDS in prompt-text-probe.ts. + for (const id of [ + "model-switch", "context-window-guidance", "environments-instructions", + "tools", "multi-agent-mode", "personality", "realtime", "collaboration", + "git-attribution", + ]) { + expect(res.body.layers[id]?.reason).toBe("unmapped"); + } + expectDecoyUntouched(fx); + }); + }); diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 58df48c1c11..3d7b756dfa5 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -613,6 +613,38 @@ describe("prompt probe process lifecycle", () => { }); }); +describe("unmapped layers", () => { + test("a layer with no confirmed tag reports unmapped, not the base prompt's not-exposed", async () => { + // UNMAPPED_LAYER_IDS used to reuse "not-exposed", which is the base prompt's + // contract: the GUI renders a base-prompt-specific explanation for it. A + // layer the extractor simply has no verified tag for is a smaller claim. + const home = promptHome({ + "config.toml": "model = \"gpt-test\"\n", + "opencodex-catalog.json": catalogJson([{ slug: "gpt-test", base_instructions: "Base prompt body." }]), + }); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + const result = await probePromptText(2_000); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.layers["personality"]).toMatchObject({ text: null, reason: "unmapped", bytes: 0 }); + expect(result.layers["tools"]).toMatchObject({ text: null, reason: "unmapped", bytes: 0 }); + // The base prompt keeps its own contract: readable text stays "ok" and an + // unexpanded template stays "not-exposed" - never "unmapped". + expect(result.layers["base-instructions"]).toMatchObject({ text: "Base prompt body.", reason: "ok" }); + } + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); +}); + describe("runtime resolution and failure classification", () => { test("a runtime the shared resolver finds is spawned, not reported missing", async () => { // Issue 4458: the old four-path POSIX check reported "codex binary not