Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 26 additions & 11 deletions src/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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" },
Expand All @@ -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: {
Expand All @@ -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",
Expand All @@ -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: {
Expand All @@ -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),
);
Expand All @@ -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),
Expand All @@ -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),
);
Expand Down Expand Up @@ -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));
Expand All @@ -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");
});
});
3 changes: 3 additions & 0 deletions src/subagent/nudge-director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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();
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/subagent/nudge-director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/codex-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, unknown>;
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) => {
Expand Down
Loading