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