Skip to content

Commit 11288c3

Browse files
Merge pull request #840 from corbitsdev/cl-7533-codex-path-context-compaction-regression-after-0318-adapter
Restore the system prompt after context compaction
2 parents edfff2f + 06261ef commit 11288c3

6 files changed

Lines changed: 65 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2828
labels. Overlay rows bind by an ask id minted at emit, not render-order
2929
index. A stale or empty accept fail-closes as unavailable rather than
3030
impersonating Reject; Escape still denies.
31+
- After context compaction, ChatGPT Codex requests keep the operating prompt as
32+
instructions.
3133

3234
## [0.3.18] - 2026-09-08
3335

‎src/agent/director.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ class ChatDirectorImpl extends DefaultDirector {
526526
...action.options,
527527
tools,
528528
retryPolicy: action.options?.retryPolicy ?? this.retryPolicy,
529+
systemPrompt: action.options?.systemPrompt ?? this._systemPrompt,
529530
};
530531
if (this.inactivityTimeoutMs !== undefined)
531532
options.inactivityTimeoutMs = this.inactivityTimeoutMs;

‎src/director.test.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -510,15 +510,15 @@ describe("chatDirector compaction", () => {
510510
} as unknown as ReactorInboundEvent;
511511
}
512512

513-
function chatDirectorWithContinuation(onContinuation?: () => void) {
514-
return createChatDirector("", [], {
513+
function chatDirectorWithContinuation(systemPrompt: string, onContinuation?: () => void) {
514+
return createChatDirector(systemPrompt, [], {
515515
onTasksChange: () => {},
516516
requestContinuation: onContinuation ?? (() => {}),
517517
});
518518
}
519519

520520
test("compacts at the tool.done pause once over threshold", async () => {
521-
const director = chatDirectorWithContinuation();
521+
const director = chatDirectorWithContinuation("Corbits operating prompt");
522522
await director.decide(overThresholdToolTurn(), longState, mockCapabilities);
523523
const actions = actionsArray(
524524
await director.decide(makeToolDoneEvent("t1"), longState, mockCapabilities),
@@ -535,6 +535,10 @@ describe("chatDirector compaction", () => {
535535
await director.decide(messageReceived(""), longState, mockCapabilities),
536536
);
537537
expect(resumed.some((a) => a.type === "infer")).toBe(true);
538+
const infer = resumed.find((a) => a.type === "infer");
539+
const options: ExtendedInferenceOptions | undefined =
540+
infer?.type === "infer" ? infer.options : undefined;
541+
expect(options?.systemPrompt).toBe("Corbits operating prompt");
538542
});
539543

540544
// CL-6910: `timeout`/`retryable` are owned entirely by the harness's own
@@ -545,7 +549,7 @@ describe("chatDirector compaction", () => {
545549
// turn); it now falls through to the base director's terminal
546550
// checkpoint + reply instead of recovering.
547551
test("does not re-issue inference for a timeout already exhausted by the harness", async () => {
548-
const director = chatDirectorWithContinuation();
552+
const director = chatDirectorWithContinuation("");
549553
const timeout = {
550554
type: "inference.error",
551555
error: { category: "timeout", message: "request timed out" },
@@ -557,7 +561,7 @@ describe("chatDirector compaction", () => {
557561
});
558562

559563
test("recovers an internally aborted inference but keeps explicit abort terminal", async () => {
560-
const director = chatDirectorWithContinuation();
564+
const director = chatDirectorWithContinuation("Corbits operating prompt");
561565
const internalAbort = {
562566
type: "inference.error",
563567
error: {
@@ -570,6 +574,10 @@ describe("chatDirector compaction", () => {
570574
await director.decide(internalAbort, longState, mockCapabilities),
571575
);
572576
expect(recovered.some((action) => action.type === "infer")).toBe(true);
577+
const infer = recovered.find((action) => action.type === "infer");
578+
const options: ExtendedInferenceOptions | undefined =
579+
infer?.type === "infer" ? infer.options : undefined;
580+
expect(options?.systemPrompt).toBe("Corbits operating prompt");
573581

574582
const explicitAbort = {
575583
type: "abort",
@@ -581,7 +589,7 @@ describe("chatDirector compaction", () => {
581589
});
582590

583591
test("does not auto-recover user-stop aborted inference errors", async () => {
584-
const director = chatDirectorWithContinuation();
592+
const director = chatDirectorWithContinuation("");
585593
const userStopAbort = {
586594
type: "inference.error",
587595
error: {
@@ -602,7 +610,10 @@ describe("chatDirector compaction", () => {
602610

603611
test("a context_overflow inference error triggers compact-and-retry, not a terminal reply", async () => {
604612
let continuations = 0;
605-
const director = chatDirectorWithContinuation(() => continuations++);
613+
const director = chatDirectorWithContinuation(
614+
"Corbits operating prompt",
615+
() => continuations++,
616+
);
606617
const actions = actionsArray(
607618
await director.decide(overflowError(), longState, mockCapabilities),
608619
);
@@ -615,10 +626,14 @@ describe("chatDirector compaction", () => {
615626
await director.decide(messageReceived(""), longState, mockCapabilities),
616627
);
617628
expect(resumed.some((a) => a.type === "infer")).toBe(true);
629+
const infer = resumed.find((a) => a.type === "infer");
630+
const options: ExtendedInferenceOptions | undefined =
631+
infer?.type === "infer" ? infer.options : undefined;
632+
expect(options?.systemPrompt).toBe("Corbits operating prompt");
618633
});
619634

620635
test("overflow recovery is bounded so an incompressible history cannot loop forever", async () => {
621-
const director = chatDirectorWithContinuation();
636+
const director = chatDirectorWithContinuation("");
622637
for (let i = 0; i < 2; i++) {
623638
const actions = actionsArray(
624639
await director.decide(overflowError(), longState, mockCapabilities),
@@ -633,7 +648,7 @@ describe("chatDirector compaction", () => {
633648
});
634649

635650
test("chat posture is preserved: an idle turn never terminates the session", async () => {
636-
const director = chatDirectorWithContinuation();
651+
const director = chatDirectorWithContinuation("");
637652
const idle = actionsArray(
638653
await director.decide(textInferenceDone(10), longState, mockCapabilities),
639654
);
@@ -1038,7 +1053,7 @@ describe("transient nudges", () => {
10381053
source: "test",
10391054
}) as unknown as ReactorInboundEvent;
10401055

1041-
test("open-task nudge uses ephemeralTurns, not systemPrompt", async () => {
1056+
test("open-task nudge uses ephemeralTurns and keeps the stable system prompt", async () => {
10421057
const director = createChatDirector("stable-base", [], { onTasksChange: () => {} });
10431058
await director.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
10441059
const actions = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
@@ -1050,6 +1065,6 @@ describe("transient nudges", () => {
10501065
expect(options?.ephemeralTurns?.length ?? 0).toBeGreaterThan(0);
10511066
const nudgeText = options?.ephemeralTurns?.[0]?.content?.find((b) => b.type === "text");
10521067
expect(nudgeText?.type === "text" ? nudgeText.text : "").toContain("tasks are still open");
1053-
expect(options?.systemPrompt).toBeUndefined();
1068+
expect(options?.systemPrompt).toBe("stable-base");
10541069
});
10551070
});

‎src/subagent/nudge-director.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ describe("SubAgentDirector tool failure recovery", () => {
194194

195195
const resumed = inferAction(await director.decide(messageReceived(""), longState, caps));
196196
const resumedTexts = ephemeralTexts(resumed);
197+
expect(resumed.options?.systemPrompt).toBe("system");
197198
expect(resumedTexts).toHaveLength(1);
198199
expect(resumedTexts?.[0]).toContain("A tool call failed");
199200

@@ -260,6 +261,7 @@ describe("SubAgentDirector tool failure recovery", () => {
260261

261262
const resumed = inferAction(await director.decide(messageReceived(""), state, caps));
262263
const resumedTexts = ephemeralTexts(resumed);
264+
expect(resumed.options?.systemPrompt).toBe("system");
263265
expect(resumedTexts).toHaveLength(1);
264266
expect(resumedTexts?.[0]).toContain("A tool call failed");
265267

@@ -298,6 +300,7 @@ describe("SubAgentDirector tool failure recovery", () => {
298300
expect(continuations).toBe(1);
299301

300302
const resumed = inferAction(await director.decide(messageReceived(""), state, caps));
303+
expect(resumed.options?.systemPrompt).toBe("system");
301304
expect(ephemeralTexts(resumed)).toBeUndefined();
302305
});
303306
});

‎src/subagent/nudge-director.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ function isNonEmptyParentMessage(event: ReactorInboundEvent): boolean {
8686
export class SubAgentDirector extends DefaultDirector {
8787
private readonly compaction: CompactionGovernor;
8888
private readonly retryPolicy: RetryPolicy;
89+
private readonly _systemPrompt: string;
8990
/** When true (CritiqueDirector), empty readCounts is not a successful complete. */
9091
private readonly requireEvidence: boolean;
9192
private turnsCompleted = 0;
@@ -177,6 +178,7 @@ export class SubAgentDirector extends DefaultDirector {
177178
retryPolicy: RetryPolicy = createCorbitsRetryPolicy(),
178179
) {
179180
super(systemPrompt, toolDefinitions, {});
181+
this._systemPrompt = systemPrompt;
180182
this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions);
181183
this.stallTimeoutMs = stallTimeoutMs;
182184
this.now = now;
@@ -197,6 +199,7 @@ export class SubAgentDirector extends DefaultDirector {
197199
infer({
198200
...(options ?? {}),
199201
retryPolicy: options?.retryPolicy ?? this.retryPolicy,
202+
systemPrompt: options?.systemPrompt ?? this._systemPrompt,
200203
}),
201204
};
202205
// A real parent follow-up re-opens the brief; empty continuations do not.

‎tests/unit/codex-responses-adapter.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
CODEX_RESPONSES_PROVIDER,
99
} from "../../src/provider/codex-responses-adapter.js";
1010
import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js";
11+
import { COMPACTED_PREFIX } from "../../src/session/compactor.js";
1112
import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference";
1213
import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime";
1314

@@ -87,6 +88,35 @@ describe("codex-responses buildRequest", () => {
8788
expect(body["tool_choice"]).toBe("auto");
8889
});
8990

91+
// Adapter mapping only: compacted history still uses instructions, not a
92+
// developer item. ChatDirector tests own the lock that infer carries the
93+
// constructor systemPrompt after compaction or recovery.
94+
test("sends compacted history with the system prompt as instructions and no developer item", () => {
95+
const systemPrompt = "Corbits operating prompt";
96+
const turns: ConversationTurn[] = [
97+
userTurn(`${COMPACTED_PREFIX}\nPrior work summarized.`),
98+
{ role: "assistant", timestamp: 0, content: [{ type: "text", text: "ok" }] },
99+
userTurn("continue"),
100+
];
101+
const body = JSON.parse(
102+
adapter().buildRequest(turns, "gpt-5-codex", { ...baseOptions, systemPrompt }).body,
103+
) as Record<string, unknown>;
104+
expect(body["instructions"]).toBe(systemPrompt);
105+
const input = body["input"] as {
106+
role?: string;
107+
content?: { text?: string }[];
108+
}[];
109+
expect(input).toHaveLength(3);
110+
expect(input[0]?.role).toBe("user");
111+
expect(input[1]?.role).toBe("assistant");
112+
expect(input[2]?.role).toBe("user");
113+
expect(input[0]?.content?.[0]?.text?.startsWith(COMPACTED_PREFIX)).toBe(true);
114+
expect(input.every((item) => item.role !== "developer")).toBe(true);
115+
expect(input.some((item) => item.content?.some((block) => block.text === systemPrompt))).toBe(
116+
false,
117+
);
118+
});
119+
90120
test.each([undefined, "", "Corbits operating prompt"])(
91121
"preserves conversation order and system turns with systemPrompt %j",
92122
(systemPrompt) => {

0 commit comments

Comments
 (0)