Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/ade-cli/src/tuiClient/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ const SILENCED_EVENT_TYPES = new Set<AgentChatEvent["type"]>([
"error",
"tokens",
"codex_token_usage",
"codex_turn_stalled",
"codex_goal_updated",
"codex_goal_cleared",
"pending_input_resolved",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ describe("buildCodingAgentSystemPrompt", () => {
it("always includes operating loop, editing rules, and verification rules", () => {
const result = buildCodingAgentSystemPrompt({ cwd: "/x" });
expect(result).toContain("## Operating Loop");
expect(result).toContain("status checks, interruptions, and tool/subagent timeouts as checkpoints");
expect(result).toContain("unless the user explicitly says stop, pause, or only report status");
expect(result).toContain("## ADE");
expect(result).toContain("read the matching `ade-*` skill");
expect(result).toContain("Your ADE capabilities ship as Agent Skills");
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/ai/tools/systemPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export function buildCodingAgentSystemPrompt(args: {
"3. When you mutate code, keep edits narrow, preserve surrounding conventions, and avoid speculative rewrites.",
"4. Verify every meaningful change with diffs, tests, type checks, or targeted inspection.",
"5. Only finish once the task is complete or you are truly blocked.",
"6. Treat status checks, interruptions, and tool/subagent timeouts as checkpoints. Give the requested status, then continue the active directive unless the user explicitly says stop, pause, or only report status.",
"",
"## User-Facing Progress",
"Before the first meaningful tool burst, send one short preamble sentence describing what you are about to do.",
Expand Down
278 changes: 277 additions & 1 deletion apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ vi.mock("node:child_process", () => ({
} else if (payload.method === "turn/start" || payload.method === "review/start") {
mockState.codexTurnCounter += 1;
result = { turn: { id: `turn-${mockState.codexTurnCounter}` } };
} else if (payload.method === "thread/read") {
const params = payload.params as { threadId?: unknown } | undefined;
result = {
thread: {
id: typeof params?.threadId === "string" ? params.threadId : "thread-1",
status: { type: "active", activeFlags: [] },
},
};
} else if (payload.method === "thread/turns/list") {
result = { data: [], nextCursor: null };
} else if (payload.method === "collaborationMode/list") {
result = {
collaborationModes: mockState.codexCollaborationModes,
Expand Down Expand Up @@ -1159,7 +1169,7 @@ function createMockSessionService() {
resumeCommand: args.resumeCommand ?? null,
lastOutputPreview: null,
summary: null,
goal: null,
goal: args.goal ?? null,
manuallyNamed: false,
headShaStart: null,
headShaEnd: null,
Expand Down Expand Up @@ -1727,6 +1737,27 @@ describe("createAgentChatService", () => {
expect(sessionService.create).toHaveBeenCalledTimes(1);
});

it("persists create-time goals into the backing session row", async () => {
const { service, sessionService } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
goal: "Run quality, tests, ship, merge, and release.",
});

expect(session.goal).toBe("Run quality, tests, ship, merge, and release.");
expect(sessionService.create).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: session.id,
goal: "Run quality, tests, ship, merge, and release.",
}),
);
await expect(service.getSessionSummary(session.id)).resolves.toMatchObject({
goal: "Run quality, tests, ship, merge, and release.",
});
});

it("creates a claude session with default model", async () => {
const { service } = createService();
const session = await service.createSession({
Expand Down Expand Up @@ -13729,6 +13760,251 @@ describe("createAgentChatService", () => {
)).toBe(false);
});

it("seeds create-time ADE goals into the Codex app-server goal before the first turn", async () => {
mockState.codexResponseOverrides.set("thread/goal/set", (payload) => {
const params = payload.params as Record<string, unknown>;
return {
goal: {
objective: params.objective,
status: params.status ?? "active",
tokenBudget: params.tokenBudget,
},
};
});
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
goal: "Run quality, tests, ship, merge, and release.",
});

await service.sendMessage({
sessionId: session.id,
text: "Continue the work.",
}, { awaitDispatch: true });

const goalRequestIndex = mockState.codexRequestPayloads.findIndex((payload) => payload.method === "thread/goal/set");
const turnRequestIndex = mockState.codexRequestPayloads.findIndex((payload) => payload.method === "turn/start");
expect(goalRequestIndex).toBeGreaterThan(-1);
expect(turnRequestIndex).toBeGreaterThan(-1);
expect(goalRequestIndex).toBeLessThan(turnRequestIndex);
expect(mockState.codexRequestPayloads[goalRequestIndex]?.params).toMatchObject({
threadId: "thread-1",
objective: "Run quality, tests, ship, merge, and release.",
status: "active",
tokenBudget: null,
});
expect((await service.getSessionSummary(session.id))?.codexGoal).toMatchObject({
objective: "Run quality, tests, ship, merge, and release.",
status: "active",
tokenBudget: null,
});
});

it("surfaces Codex MCP startup failures without treating them as turn progress", async () => {
vi.useFakeTimers();
try {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});

await service.sendMessage({
sessionId: session.id,
text: "Keep working.",
}, { awaitDispatch: true });

mockState.emitCodexPayload({
method: "mcpServer/startupStatus/updated",
params: {
serverName: "local-tools",
status: "failed",
message: "http/request failed: error sending request",
},
});
mockState.emitCodexPayload({
method: "mcpServer/startupStatus/updated",
params: {
serverName: "local-tools",
status: "failed",
message: "http/request failed: error sending request",
},
});

await Promise.resolve();
const mcpNotices = events.filter((event) =>
event.event.type === "system_notice"
&& event.event.message.includes("Codex MCP server 'local-tools' is unavailable")
);
expect(mcpNotices).toHaveLength(1);

await vi.advanceTimersByTimeAsync(120_000);
await vi.waitFor(() => {
expect(events.some((event) =>
event.event.type === "codex_turn_stalled"
&& event.event.reason === "no_output"
)).toBe(true);
});
expect(events.some((event) =>
event.event.type === "system_notice"
&& event.event.message.includes("has not streamed model or tool output yet")
)).toBe(true);
} finally {
vi.useRealTimers();
}
});

it("reconciles a completed silent Codex turn from app-server state before reporting a stall", async () => {
vi.useFakeTimers();
try {
const events: AgentChatEventEnvelope[] = [];
mockState.codexResponseOverrides.set("thread/turns/list", () => ({
data: [
{
id: "turn-1",
status: "completed",
usage: { inputTokens: 7, outputTokens: 3 },
items: [
{
id: "msg-1",
type: "agentMessage",
text: "Recovered assistant output.",
},
],
},
],
nextCursor: null,
}));
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});

await service.sendMessage({
sessionId: session.id,
text: "Keep working.",
}, { awaitDispatch: true });

await vi.advanceTimersByTimeAsync(120_000);
await vi.waitFor(() => {
expect(events.some((event) =>
event.event.type === "done"
&& event.event.turnId === "turn-1"
&& event.event.status === "completed"
)).toBe(true);
});

expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/read")).toBe(true);
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/turns/list")).toBe(true);
expect(events.some((event) =>
event.event.type === "text"
&& event.event.text.includes("Recovered assistant output.")
)).toBe(true);
expect(events.some((event) => event.event.type === "codex_turn_stalled")).toBe(false);
} finally {
vi.useRealTimers();
}
});

it("routes structured Codex stall notices to an orchestration parent without auto-handoff", async () => {
vi.useFakeTimers();
try {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const parent = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
orchestrationRole: "lead",
});
const child = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
orchestrationRole: "worker",
orchestrationParentSessionId: parent.id,
});

await service.sendMessage({
sessionId: child.id,
text: "Keep working.",
}, { awaitDispatch: true });

await vi.advanceTimersByTimeAsync(120_000);
await vi.waitFor(() => {
expect(events.some((event) =>
event.sessionId === parent.id
&& event.event.type === "codex_turn_stalled"
&& event.event.sourceSessionId === child.id
)).toBe(true);
});

expect(events.some((event) =>
event.sessionId === child.id
&& event.event.type === "codex_turn_stalled"
&& event.event.reason === "no_output"
)).toBe(true);
expect(events.some((event) =>
event.sessionId === parent.id
&& event.event.type === "system_notice"
&& event.event.message.includes("Child Codex session")
)).toBe(true);
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true);
expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/interrupt")).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});

it("clears the Codex no-output watchdog when useful turn events arrive", async () => {
vi.useFakeTimers();
try {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});

await service.sendMessage({
sessionId: session.id,
text: "Keep working.",
}, { awaitDispatch: true });

mockState.emitCodexPayload({
method: "item/started",
params: {
turnId: "turn-1",
item: { id: "item-1", type: "agentMessage" },
},
});
await Promise.resolve();
await vi.advanceTimersByTimeAsync(120_000);

expect(events.some((event) =>
event.event.type === "system_notice"
&& event.event.message.includes("has not streamed model or tool output yet")
)).toBe(false);
} finally {
vi.useRealTimers();
}
});

it("exposes typed Codex goal controls with unlimited budgets and persisted summaries", async () => {
mockState.codexResponseOverrides.set("thread/goal/set", (payload) => {
const params = payload.params as Record<string, unknown>;
Expand Down
Loading