diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e2e26cf..bfd4a2491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename labels. Overlay rows bind by an ask id minted at emit, not render-order index. A stale or empty accept fail-closes as unavailable rather than impersonating Reject; Escape still denies. +- After context compaction, ChatGPT Codex requests keep the operating prompt as + instructions. ## [0.3.18] - 2026-09-08 diff --git a/src/agent/director.ts b/src/agent/director.ts index 5c2f21ee8..f5f2b4aae 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -526,6 +526,7 @@ class ChatDirectorImpl extends DefaultDirector { ...action.options, tools, retryPolicy: action.options?.retryPolicy ?? this.retryPolicy, + systemPrompt: action.options?.systemPrompt ?? this._systemPrompt, }; if (this.inactivityTimeoutMs !== undefined) options.inactivityTimeoutMs = this.inactivityTimeoutMs; diff --git a/src/director.test.ts b/src/director.test.ts index 9ba4072e9..54557d18c 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -510,15 +510,15 @@ describe("chatDirector compaction", () => { } as unknown as ReactorInboundEvent; } - function chatDirectorWithContinuation(onContinuation?: () => void) { - return createChatDirector("", [], { + function chatDirectorWithContinuation(systemPrompt: string, onContinuation?: () => void) { + return createChatDirector(systemPrompt, [], { onTasksChange: () => {}, requestContinuation: onContinuation ?? (() => {}), }); } test("compacts at the tool.done pause once over threshold", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation("Corbits operating prompt"); await director.decide(overThresholdToolTurn(), longState, mockCapabilities); const actions = actionsArray( await director.decide(makeToolDoneEvent("t1"), longState, mockCapabilities), @@ -535,6 +535,10 @@ describe("chatDirector compaction", () => { await director.decide(messageReceived(""), longState, mockCapabilities), ); expect(resumed.some((a) => a.type === "infer")).toBe(true); + const infer = resumed.find((a) => a.type === "infer"); + const options: ExtendedInferenceOptions | undefined = + infer?.type === "infer" ? infer.options : undefined; + expect(options?.systemPrompt).toBe("Corbits operating prompt"); }); // CL-6910: `timeout`/`retryable` are owned entirely by the harness's own @@ -545,7 +549,7 @@ describe("chatDirector compaction", () => { // turn); it now falls through to the base director's terminal // checkpoint + reply instead of recovering. test("does not re-issue inference for a timeout already exhausted by the harness", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); const timeout = { type: "inference.error", error: { category: "timeout", message: "request timed out" }, @@ -557,7 +561,7 @@ describe("chatDirector compaction", () => { }); test("recovers an internally aborted inference but keeps explicit abort terminal", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation("Corbits operating prompt"); const internalAbort = { type: "inference.error", error: { @@ -570,6 +574,10 @@ describe("chatDirector compaction", () => { await director.decide(internalAbort, longState, mockCapabilities), ); expect(recovered.some((action) => action.type === "infer")).toBe(true); + const infer = recovered.find((action) => action.type === "infer"); + const options: ExtendedInferenceOptions | undefined = + infer?.type === "infer" ? infer.options : undefined; + expect(options?.systemPrompt).toBe("Corbits operating prompt"); const explicitAbort = { type: "abort", @@ -581,7 +589,7 @@ describe("chatDirector compaction", () => { }); test("does not auto-recover user-stop aborted inference errors", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); const userStopAbort = { type: "inference.error", error: { @@ -602,7 +610,10 @@ describe("chatDirector compaction", () => { test("a context_overflow inference error triggers compact-and-retry, not a terminal reply", async () => { let continuations = 0; - const director = chatDirectorWithContinuation(() => continuations++); + const director = chatDirectorWithContinuation( + "Corbits operating prompt", + () => continuations++, + ); const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), ); @@ -615,10 +626,14 @@ describe("chatDirector compaction", () => { await director.decide(messageReceived(""), longState, mockCapabilities), ); expect(resumed.some((a) => a.type === "infer")).toBe(true); + const infer = resumed.find((a) => a.type === "infer"); + const options: ExtendedInferenceOptions | undefined = + infer?.type === "infer" ? infer.options : undefined; + expect(options?.systemPrompt).toBe("Corbits operating prompt"); }); test("overflow recovery is bounded so an incompressible history cannot loop forever", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); for (let i = 0; i < 2; i++) { const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), @@ -633,7 +648,7 @@ describe("chatDirector compaction", () => { }); test("chat posture is preserved: an idle turn never terminates the session", async () => { - const director = chatDirectorWithContinuation(); + const director = chatDirectorWithContinuation(""); const idle = actionsArray( await director.decide(textInferenceDone(10), longState, mockCapabilities), ); @@ -1038,7 +1053,7 @@ describe("transient nudges", () => { source: "test", }) as unknown as ReactorInboundEvent; - test("open-task nudge uses ephemeralTurns, not systemPrompt", async () => { + test("open-task nudge uses ephemeralTurns and keeps the stable system prompt", async () => { const director = createChatDirector("stable-base", [], { onTasksChange: () => {} }); await director.decide(manageTasksEvent("doing"), mockState, mockCapabilities); const actions = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities)); @@ -1050,6 +1065,6 @@ describe("transient nudges", () => { expect(options?.ephemeralTurns?.length ?? 0).toBeGreaterThan(0); const nudgeText = options?.ephemeralTurns?.[0]?.content?.find((b) => b.type === "text"); expect(nudgeText?.type === "text" ? nudgeText.text : "").toContain("tasks are still open"); - expect(options?.systemPrompt).toBeUndefined(); + expect(options?.systemPrompt).toBe("stable-base"); }); }); diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index ef166e19d..f7963d8ea 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -194,6 +194,7 @@ describe("SubAgentDirector tool failure recovery", () => { const resumed = inferAction(await director.decide(messageReceived(""), longState, caps)); const resumedTexts = ephemeralTexts(resumed); + expect(resumed.options?.systemPrompt).toBe("system"); expect(resumedTexts).toHaveLength(1); expect(resumedTexts?.[0]).toContain("A tool call failed"); @@ -260,6 +261,7 @@ describe("SubAgentDirector tool failure recovery", () => { const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); const resumedTexts = ephemeralTexts(resumed); + expect(resumed.options?.systemPrompt).toBe("system"); expect(resumedTexts).toHaveLength(1); expect(resumedTexts?.[0]).toContain("A tool call failed"); @@ -298,6 +300,7 @@ describe("SubAgentDirector tool failure recovery", () => { expect(continuations).toBe(1); const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); + expect(resumed.options?.systemPrompt).toBe("system"); expect(ephemeralTexts(resumed)).toBeUndefined(); }); }); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index b9b9810cd..a0eabec19 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -86,6 +86,7 @@ function isNonEmptyParentMessage(event: ReactorInboundEvent): boolean { export class SubAgentDirector extends DefaultDirector { private readonly compaction: CompactionGovernor; private readonly retryPolicy: RetryPolicy; + private readonly _systemPrompt: string; /** When true (CritiqueDirector), empty readCounts is not a successful complete. */ private readonly requireEvidence: boolean; private turnsCompleted = 0; @@ -177,6 +178,7 @@ export class SubAgentDirector extends DefaultDirector { retryPolicy: RetryPolicy = createCorbitsRetryPolicy(), ) { super(systemPrompt, toolDefinitions, {}); + this._systemPrompt = systemPrompt; this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions); this.stallTimeoutMs = stallTimeoutMs; this.now = now; @@ -197,6 +199,7 @@ export class SubAgentDirector extends DefaultDirector { infer({ ...(options ?? {}), retryPolicy: options?.retryPolicy ?? this.retryPolicy, + systemPrompt: options?.systemPrompt ?? this._systemPrompt, }), }; // A real parent follow-up re-opens the brief; empty continuations do not. diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4faebfbcf..0f16c16c4 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -8,6 +8,7 @@ import { CODEX_RESPONSES_PROVIDER, } from "../../src/provider/codex-responses-adapter.js"; import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js"; +import { COMPACTED_PREFIX } from "../../src/session/compactor.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime"; @@ -87,6 +88,35 @@ describe("codex-responses buildRequest", () => { expect(body["tool_choice"]).toBe("auto"); }); + // Adapter mapping only: compacted history still uses instructions, not a + // developer item. ChatDirector tests own the lock that infer carries the + // constructor systemPrompt after compaction or recovery. + test("sends compacted history with the system prompt as instructions and no developer item", () => { + const systemPrompt = "Corbits operating prompt"; + const turns: ConversationTurn[] = [ + userTurn(`${COMPACTED_PREFIX}\nPrior work summarized.`), + { role: "assistant", timestamp: 0, content: [{ type: "text", text: "ok" }] }, + userTurn("continue"), + ]; + const body = JSON.parse( + adapter().buildRequest(turns, "gpt-5-codex", { ...baseOptions, systemPrompt }).body, + ) as Record; + expect(body["instructions"]).toBe(systemPrompt); + const input = body["input"] as { + role?: string; + content?: { text?: string }[]; + }[]; + expect(input).toHaveLength(3); + expect(input[0]?.role).toBe("user"); + expect(input[1]?.role).toBe("assistant"); + expect(input[2]?.role).toBe("user"); + expect(input[0]?.content?.[0]?.text?.startsWith(COMPACTED_PREFIX)).toBe(true); + expect(input.every((item) => item.role !== "developer")).toBe(true); + expect(input.some((item) => item.content?.some((block) => block.text === systemPrompt))).toBe( + false, + ); + }); + test.each([undefined, "", "Corbits operating prompt"])( "preserves conversation order and system turns with systemPrompt %j", (systemPrompt) => {