feat(server): improve task CLI with steering, execution order, and planner guidance
- Fix steering notes to check scope task instead of leaf task
- Fix NOT_DONE replan to use scope for bird's eye view
- Pass reason to planner prompt ("Why you were called" section)
- Add execution-order.ts with reusable computeExecutionOrder and buildSortedChildrenMap
- Make 'tree' an alias for 'plan' command
- Add comprehensive help text for run command and acceptance criteria
- Add planner guidance for propagating requirements to subtasks
This commit is contained in:
@@ -161,3 +161,10 @@ export async function waitForAgentIdle(page: Page, timeout = 30000) {
|
|||||||
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
await expect(stopButton).not.toBeVisible({ timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getToolCallCount(page: Page): Promise<number> {
|
||||||
|
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
|
||||||
|
// They appear as pressable badges in the agent stream
|
||||||
|
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
|
||||||
|
return toolCallBadges.count();
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
allowPermission,
|
allowPermission,
|
||||||
denyPermission,
|
denyPermission,
|
||||||
waitForAgentIdle,
|
waitForAgentIdle,
|
||||||
|
getToolCallCount,
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
|
||||||
@@ -37,6 +38,10 @@ test.describe('permission prompts', () => {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
// Verify exactly one tool call is visible (no duplicate permission badge)
|
||||||
|
const toolCallCount = await getToolCallCount(page);
|
||||||
|
expect(toolCallCount).toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
@@ -61,6 +66,10 @@ test.describe('permission prompts', () => {
|
|||||||
await waitForAgentIdle(page);
|
await waitForAgentIdle(page);
|
||||||
|
|
||||||
expect(existsSync(filePath)).toBe(false);
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
|
||||||
|
// Verify exactly one tool call is visible (no duplicate permission badge)
|
||||||
|
const toolCallCount = await getToolCallCount(page);
|
||||||
|
expect(toolCallCount).toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1067,6 +1067,7 @@ interface ExpandableBadgeProps {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
isLastInSequence?: boolean;
|
isLastInSequence?: boolean;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpandableBadge = memo(function ExpandableBadge({
|
const ExpandableBadge = memo(function ExpandableBadge({
|
||||||
@@ -1081,6 +1082,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
isError = false,
|
isError = false,
|
||||||
isLastInSequence = false,
|
isLastInSequence = false,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
|
testID,
|
||||||
}: ExpandableBadgeProps) {
|
}: ExpandableBadgeProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing =
|
||||||
@@ -1140,7 +1142,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[expandableBadgeStylesheet.container, style]}>
|
<View style={[expandableBadgeStylesheet.container, style]} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={hasDetails ? onToggle : undefined}
|
onPress={hasDetails ? onToggle : undefined}
|
||||||
disabled={!hasDetails}
|
disabled={!hasDetails}
|
||||||
@@ -1495,6 +1497,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableBadge
|
<ExpandableBadge
|
||||||
|
testID="tool-call-badge"
|
||||||
label={toolName}
|
label={toolName}
|
||||||
secondaryLabel={principalParam}
|
secondaryLabel={principalParam}
|
||||||
icon={IconComponent}
|
icon={IconComponent}
|
||||||
|
|||||||
@@ -65,19 +65,6 @@ function toolTimeline(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionTimeline(id: string, status: string): AgentStreamEventPayload {
|
|
||||||
return {
|
|
||||||
type: "timeline",
|
|
||||||
provider: "claude",
|
|
||||||
item: {
|
|
||||||
type: "tool_call",
|
|
||||||
name: "permission_request",
|
|
||||||
status,
|
|
||||||
callId: id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload {
|
function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload {
|
||||||
return {
|
return {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -643,21 +630,7 @@ function testHydratedUserMessagesPersist() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 8: Permission tool calls should not show in the timeline
|
// Test 8: Todo lists should consolidate into a single entry and update completions
|
||||||
function testPermissionToolCallFiltering() {
|
|
||||||
const timestamp = new Date('2025-01-01T12:00:00Z');
|
|
||||||
const updates = [
|
|
||||||
{ event: permissionTimeline('permission-1', 'pending'), timestamp },
|
|
||||||
{ event: permissionTimeline('permission-1', 'granted'), timestamp },
|
|
||||||
];
|
|
||||||
|
|
||||||
const state = hydrateStreamState(updates);
|
|
||||||
const permissionEntries = state.filter(isAgentToolCallItem);
|
|
||||||
|
|
||||||
assert.strictEqual(permissionEntries.length, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 9: Todo lists should consolidate into a single entry and update completions
|
|
||||||
function testTodoListConsolidation() {
|
function testTodoListConsolidation() {
|
||||||
const timestamp1 = new Date('2025-01-01T12:30:00Z');
|
const timestamp1 = new Date('2025-01-01T12:30:00Z');
|
||||||
const timestamp2 = new Date('2025-01-01T12:31:00Z');
|
const timestamp2 = new Date('2025-01-01T12:31:00Z');
|
||||||
@@ -1064,7 +1037,6 @@ describe('stream timeline reducers', () => {
|
|||||||
it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation);
|
it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation);
|
||||||
it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
|
it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
|
||||||
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
|
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
|
||||||
it('filters permission tool calls from the stream', testPermissionToolCallFiltering);
|
|
||||||
it('consolidates todo list updates', testTodoListConsolidation);
|
it('consolidates todo list updates', testTodoListConsolidation);
|
||||||
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
|
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
|
||||||
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
|
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
|
||||||
|
|||||||
@@ -480,14 +480,6 @@ function appendAgentToolCall(
|
|||||||
return [...state, item];
|
return [...state, item];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermissionToolCall(raw: unknown): boolean {
|
|
||||||
if (!raw || typeof raw !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const candidate = raw as { name?: string };
|
|
||||||
return candidate.name === "permission_request";
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAILED_STATUS_PATTERN =
|
const FAILED_STATUS_PATTERN =
|
||||||
/fail|error|deny|reject|cancel|abort|exception|refus/;
|
/fail|error|deny|reject|cancel|abort|exception|refus/;
|
||||||
const COMPLETED_STATUS_PATTERN =
|
const COMPLETED_STATUS_PATTERN =
|
||||||
@@ -762,9 +754,6 @@ export function reduceStreamUpdate(
|
|||||||
case "reasoning":
|
case "reasoning":
|
||||||
return appendThought(state, item.text, timestamp);
|
return appendThought(state, item.text, timestamp);
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
if (isPermissionToolCall(item)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
nextState = appendAgentToolCall(
|
nextState = appendAgentToolCall(
|
||||||
state,
|
state,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1228,9 +1228,11 @@ export class DaemonClientV2 {
|
|||||||
pendingPermissionIds.add(request.id);
|
pendingPermissionIds.add(request.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sawRunning =
|
|
||||||
current?.status === "running" ||
|
// Track whether we've seen a running state. Important: we only set this
|
||||||
pendingPermissionIds.size > 0;
|
// when we see running IN THE QUEUE, not based on current state. This prevents
|
||||||
|
// finding old idle states that occurred before the current run started.
|
||||||
|
let sawRunningInQueue = false;
|
||||||
let queuedIdle: AgentSnapshotPayload | null = null;
|
let queuedIdle: AgentSnapshotPayload | null = null;
|
||||||
|
|
||||||
const updatePendingPermissions = (msg: SessionOutboundMessage): void => {
|
const updatePendingPermissions = (msg: SessionOutboundMessage): void => {
|
||||||
@@ -1267,10 +1269,11 @@ export class DaemonClientV2 {
|
|||||||
(msg.payload.pendingPermissions?.length ?? 0) > 0 ||
|
(msg.payload.pendingPermissions?.length ?? 0) > 0 ||
|
||||||
pendingPermissionIds.size > 0;
|
pendingPermissionIds.size > 0;
|
||||||
if (status === "running" || hasPendingPermissions) {
|
if (status === "running" || hasPendingPermissions) {
|
||||||
sawRunning = true;
|
sawRunningInQueue = true;
|
||||||
|
queuedIdle = null; // Reset: any previous idle was before this run
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
sawRunning &&
|
sawRunningInQueue &&
|
||||||
(status === "idle" || status === "error") &&
|
(status === "idle" || status === "error") &&
|
||||||
!hasPendingPermissions
|
!hasPendingPermissions
|
||||||
) {
|
) {
|
||||||
@@ -1281,6 +1284,15 @@ export class DaemonClientV2 {
|
|||||||
return queuedIdle;
|
return queuedIdle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If current state is running (or has pending permissions), we need to wait
|
||||||
|
// for the next idle. If current is already idle and we didn't see running
|
||||||
|
// in the queue, this is an edge case - the agent might not have started yet
|
||||||
|
// or was already idle. Use the current state's running status to seed the waiter.
|
||||||
|
let sawRunning =
|
||||||
|
sawRunningInQueue ||
|
||||||
|
current?.status === "running" ||
|
||||||
|
pendingPermissionIds.size > 0;
|
||||||
|
|
||||||
return this.waitFor(
|
return this.waitFor(
|
||||||
(msg) => {
|
(msg) => {
|
||||||
updatePendingPermissions(msg);
|
updatePendingPermissions(msg);
|
||||||
|
|||||||
@@ -1013,6 +1013,10 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dispatchStream(agentId: string, event: AgentStreamEvent): void {
|
private dispatchStream(agentId: string, event: AgentStreamEvent): void {
|
||||||
|
const eventDesc = event.type === "timeline"
|
||||||
|
? `timeline:${event.item.type}${event.item.type === "tool_call" ? `:${(event.item as any).name}` : ""}`
|
||||||
|
: event.type;
|
||||||
|
console.error(`[AGENT-MANAGER:dispatchStream] agentId=${agentId.substring(0, 8)} event=${eventDesc} t=${Date.now()}`);
|
||||||
this.dispatch({ type: "agent_stream", agentId, event });
|
this.dispatch({ type: "agent_stream", agentId, event });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,14 +504,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(sawResolvedAllow).toBe(true);
|
expect(sawResolvedAllow).toBe(true);
|
||||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||||
expect(
|
|
||||||
timeline.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "granted"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
timeline.some(
|
timeline.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -578,14 +570,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
try {
|
try {
|
||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(sawResolvedDeny).toBe(true);
|
expect(sawResolvedDeny).toBe(true);
|
||||||
expect(
|
|
||||||
timeline.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "denied"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
timeline.some(
|
timeline.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -657,14 +641,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(sawResolvedInterrupt).toBe(true);
|
expect(sawResolvedInterrupt).toBe(true);
|
||||||
expect(sawTerminalEvent).toBe(true);
|
expect(sawTerminalEvent).toBe(true);
|
||||||
expect(
|
|
||||||
timeline.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "denied"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
timeline.some(
|
timeline.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|||||||
@@ -532,6 +532,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
_options?: AgentRunOptions
|
_options?: AgentRunOptions
|
||||||
): AsyncGenerator<AgentStreamEvent> {
|
): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
console.error(`[CLAUDE-AGENT:stream] starting stream for prompt: "${typeof prompt === 'string' ? prompt.substring(0, 50) : 'object'}"`);
|
||||||
// Increment turn ID to invalidate any in-flight processPrompt() loops from previous turns.
|
// Increment turn ID to invalidate any in-flight processPrompt() loops from previous turns.
|
||||||
// This prevents race conditions where an interrupted turn's events get mixed with the new turn.
|
// This prevents race conditions where an interrupted turn's events get mixed with the new turn.
|
||||||
const turnId = ++this.currentTurnId;
|
const turnId = ++this.currentTurnId;
|
||||||
@@ -584,8 +585,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.logger.error({ err: error }, "Unexpected error in forwardPromptEvents");
|
this.logger.error({ err: error }, "Unexpected error in forwardPromptEvents");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let eventCount = 0;
|
||||||
try {
|
try {
|
||||||
|
console.error(`[CLAUDE-AGENT:stream] starting for-await-of loop on queue`);
|
||||||
for await (const event of queue) {
|
for await (const event of queue) {
|
||||||
|
eventCount++;
|
||||||
|
console.error(`[CLAUDE-AGENT:stream] yielding event #${eventCount}: type=${event.type}${event.type === "timeline" ? ` item.type=${(event as any).item?.type}` : ""}`);
|
||||||
yield event;
|
yield event;
|
||||||
if (
|
if (
|
||||||
event.type === "turn_completed" ||
|
event.type === "turn_completed" ||
|
||||||
@@ -593,9 +598,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
event.type === "turn_canceled"
|
event.type === "turn_canceled"
|
||||||
) {
|
) {
|
||||||
finishedNaturally = true;
|
finishedNaturally = true;
|
||||||
|
console.error(`[CLAUDE-AGENT:stream] terminal event reached: ${event.type}, total events: ${eventCount}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.error(`[CLAUDE-AGENT:stream] queue iteration completed naturally, eventCount=${eventCount}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (!finishedNaturally && !cancelIssued) {
|
if (!finishedNaturally && !cancelIssued) {
|
||||||
requestCancel();
|
requestCancel();
|
||||||
@@ -679,12 +686,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||||
};
|
};
|
||||||
pending.resolve(result);
|
pending.resolve(result);
|
||||||
this.pushToolCall({
|
|
||||||
name: "permission",
|
|
||||||
status: "granted",
|
|
||||||
callId: pending.request.id,
|
|
||||||
input: pending.request.input,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const result: PermissionResult = {
|
const result: PermissionResult = {
|
||||||
behavior: "deny",
|
behavior: "deny",
|
||||||
@@ -692,12 +693,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
interrupt: response.interrupt,
|
interrupt: response.interrupt,
|
||||||
};
|
};
|
||||||
pending.resolve(result);
|
pending.resolve(result);
|
||||||
this.pushToolCall({
|
|
||||||
name: "permission",
|
|
||||||
status: "denied",
|
|
||||||
callId: pending.request.id,
|
|
||||||
input: pending.request.input,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
@@ -967,6 +962,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
queue: Pushable<AgentStreamEvent>,
|
queue: Pushable<AgentStreamEvent>,
|
||||||
turnId: number
|
turnId: number
|
||||||
) {
|
) {
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] START turnId=${turnId}`);
|
||||||
// Create a turn-local context to track streaming state.
|
// Create a turn-local context to track streaming state.
|
||||||
// This prevents race conditions when a new stream() call interrupts a running one.
|
// This prevents race conditions when a new stream() call interrupts a running one.
|
||||||
const turnContext: TurnContext = {
|
const turnContext: TurnContext = {
|
||||||
@@ -974,21 +970,27 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
streamedReasoningThisTurn: false,
|
streamedReasoningThisTurn: false,
|
||||||
};
|
};
|
||||||
let completedNormally = false;
|
let completedNormally = false;
|
||||||
|
let eventCount = 0;
|
||||||
try {
|
try {
|
||||||
for await (const sdkEvent of this.processPrompt(message, turnId)) {
|
for await (const sdkEvent of this.processPrompt(message, turnId)) {
|
||||||
|
eventCount++;
|
||||||
// Check if this turn has been superseded before pushing events
|
// Check if this turn has been superseded before pushing events
|
||||||
if (this.currentTurnId !== turnId) {
|
if (this.currentTurnId !== turnId) {
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] turn superseded, breaking. currentTurnId=${this.currentTurnId} ourTurnId=${turnId}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const events = this.translateMessageToEvents(sdkEvent, turnContext);
|
const events = this.translateMessageToEvents(sdkEvent, turnContext);
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
queue.push(event);
|
queue.push(event);
|
||||||
if (event.type === "turn_completed") {
|
if (event.type === "turn_completed") {
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] turn_completed pushed`);
|
||||||
completedNormally = true;
|
completedNormally = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] processPrompt loop done, eventCount=${eventCount} completedNormally=${completedNormally}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] ERROR: ${error}`);
|
||||||
if (!this.turnCancelRequested && this.currentTurnId === turnId) {
|
if (!this.turnCancelRequested && this.currentTurnId === turnId) {
|
||||||
queue.push({
|
queue.push({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
@@ -1001,7 +1003,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
// Use turn_canceled (not turn_failed) to distinguish intentional interruption from errors.
|
// Use turn_canceled (not turn_failed) to distinguish intentional interruption from errors.
|
||||||
// Only emit if not already emitted by requestCancel() (indicated by turnCancelRequested).
|
// Only emit if not already emitted by requestCancel() (indicated by turnCancelRequested).
|
||||||
const wasSuperseded = this.currentTurnId !== turnId;
|
const wasSuperseded = this.currentTurnId !== turnId;
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] FINALLY wasSuperseded=${wasSuperseded} completedNormally=${completedNormally} turnCancelRequested=${this.turnCancelRequested}`);
|
||||||
if (wasSuperseded && !completedNormally && !this.turnCancelRequested) {
|
if (wasSuperseded && !completedNormally && !this.turnCancelRequested) {
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] pushing turn_canceled for superseded turn`);
|
||||||
queue.push({
|
queue.push({
|
||||||
type: "turn_canceled",
|
type: "turn_canceled",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -1009,6 +1013,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.turnCancelRequested = false;
|
this.turnCancelRequested = false;
|
||||||
|
console.error(`[CLAUDE-AGENT:forwardPromptEvents] calling queue.end()`);
|
||||||
queue.end();
|
queue.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1016,15 +1021,22 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private async interruptActiveTurn(): Promise<void> {
|
private async interruptActiveTurn(): Promise<void> {
|
||||||
const queryToInterrupt = this.query;
|
const queryToInterrupt = this.query;
|
||||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||||
|
this.logger.info("interruptActiveTurn: no query to interrupt");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
this.logger.info("interruptActiveTurn: calling query.interrupt()...");
|
||||||
|
const t0 = Date.now();
|
||||||
await queryToInterrupt.interrupt();
|
await queryToInterrupt.interrupt();
|
||||||
|
this.logger.info({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
|
||||||
// After interrupt(), the query iterator is done (returns done: true).
|
// After interrupt(), the query iterator is done (returns done: true).
|
||||||
// Clear it so ensureQuery() creates a fresh query for the next turn.
|
// Clear it so ensureQuery() creates a fresh query for the next turn.
|
||||||
// Also end the input stream and call return() to clean up the SDK process.
|
// Also end the input stream and call return() to clean up the SDK process.
|
||||||
this.input?.end();
|
this.input?.end();
|
||||||
|
this.logger.info("interruptActiveTurn: calling query.return()...");
|
||||||
|
const t1 = Date.now();
|
||||||
await queryToInterrupt.return?.();
|
await queryToInterrupt.return?.();
|
||||||
|
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
|
||||||
this.query = null;
|
this.query = null;
|
||||||
this.input = null;
|
this.input = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1178,13 +1190,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.pushToolCall({
|
|
||||||
name: "permission",
|
|
||||||
status: "requested",
|
|
||||||
callId: requestId,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pushEvent({ type: "permission_requested", provider: "claude", request });
|
this.pushEvent({ type: "permission_requested", provider: "claude", request });
|
||||||
|
|
||||||
return await new Promise<PermissionResult>((resolve, reject) => {
|
return await new Promise<PermissionResult>((resolve, reject) => {
|
||||||
@@ -1203,12 +1208,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.pendingPermissions.delete(requestId);
|
this.pendingPermissions.delete(requestId);
|
||||||
cleanup();
|
cleanup();
|
||||||
const error = new Error("Permission request timed out");
|
const error = new Error("Permission request timed out");
|
||||||
this.pushToolCall({
|
|
||||||
name: "permission",
|
|
||||||
status: "denied",
|
|
||||||
callId: requestId,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
type: "permission_resolved",
|
type: "permission_resolved",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import type {
|
|||||||
|
|
||||||
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
return mkdtempSync(path.join(os.tmpdir(), "codex-mcp-e2e-"));
|
return mkdtempSync(path.join(os.tmpdir(), "codex-mcp-e2e-"));
|
||||||
}
|
}
|
||||||
@@ -406,6 +410,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
} satisfies AgentSessionConfig;
|
} satisfies AgentSessionConfig;
|
||||||
@@ -474,6 +480,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
} satisfies AgentSessionConfig;
|
} satisfies AgentSessionConfig;
|
||||||
@@ -502,6 +510,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
} satisfies AgentSessionConfig;
|
} satisfies AgentSessionConfig;
|
||||||
@@ -530,7 +540,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
if (event.item.type === "reasoning") {
|
if (event.item.type === "reasoning") {
|
||||||
sawReasoning = true;
|
sawReasoning = true;
|
||||||
}
|
}
|
||||||
if (event.item.type === "tool_call" && event.item.name !== "permission") {
|
if (event.item.type === "tool_call") {
|
||||||
toolCalls.push(event.item);
|
toolCalls.push(event.item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,6 +614,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const nodeModulesPath = resolveNodeModulesPath();
|
const nodeModulesPath = resolveNodeModulesPath();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
extra: {
|
extra: {
|
||||||
@@ -750,6 +762,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const nodeModulesPath = resolveNodeModulesPath();
|
const nodeModulesPath = resolveNodeModulesPath();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
extra: {
|
extra: {
|
||||||
@@ -783,7 +797,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
|
|
||||||
for await (const event of session.stream(prompt)) {
|
for await (const event of session.stream(prompt)) {
|
||||||
if (event.type === "timeline" && providerFromEvent(event) === "codex") {
|
if (event.type === "timeline" && providerFromEvent(event) === "codex") {
|
||||||
if (event.item.type === "tool_call" && event.item.name !== "permission") {
|
if (event.item.type === "tool_call") {
|
||||||
toolCalls.push(event.item);
|
toolCalls.push(event.item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -894,6 +908,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
} satisfies AgentSessionConfig;
|
} satisfies AgentSessionConfig;
|
||||||
@@ -950,6 +966,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
} satisfies AgentSessionConfig;
|
} satisfies AgentSessionConfig;
|
||||||
@@ -1027,6 +1045,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
} satisfies AgentSessionConfig;
|
} satisfies AgentSessionConfig;
|
||||||
@@ -1063,6 +1083,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
approvalPolicy: "on-request",
|
approvalPolicy: "on-request",
|
||||||
@@ -1107,14 +1129,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(sawPermissionResolved).toBe(true);
|
expect(sawPermissionResolved).toBe(true);
|
||||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||||
expect(
|
|
||||||
timelineItems.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "granted"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
timelineItems.some(
|
timelineItems.some(
|
||||||
(item) => item.type === "tool_call" && item.name === "shell"
|
(item) => item.type === "tool_call" && item.name === "shell"
|
||||||
@@ -1140,6 +1154,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "read-only",
|
modeId: "read-only",
|
||||||
approvalPolicy: "on-request",
|
approvalPolicy: "on-request",
|
||||||
@@ -1171,14 +1187,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(
|
|
||||||
timelineItems.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "requested"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
} finally {
|
} finally {
|
||||||
await session?.close();
|
await session?.close();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
@@ -1197,6 +1205,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
approvalPolicy: "on-request",
|
approvalPolicy: "on-request",
|
||||||
@@ -1242,14 +1252,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
|
|
||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(sawPermissionDenied).toBe(true);
|
expect(sawPermissionDenied).toBe(true);
|
||||||
expect(
|
|
||||||
timelineItems.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "denied"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(existsSync(filePath)).toBe(false);
|
expect(existsSync(filePath)).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await session?.close();
|
await session?.close();
|
||||||
@@ -1269,6 +1271,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
approvalPolicy: "on-request",
|
approvalPolicy: "on-request",
|
||||||
@@ -1323,14 +1327,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
|
|
||||||
expect(captured).not.toBeNull();
|
expect(captured).not.toBeNull();
|
||||||
expect(sawPermissionResolved).toBe(true);
|
expect(sawPermissionResolved).toBe(true);
|
||||||
expect(
|
|
||||||
timelineItems.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "permission" &&
|
|
||||||
item.status === "denied"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(sawTurnFailed).toBe(true);
|
expect(sawTurnFailed).toBe(true);
|
||||||
const message = failureMessage ? failureMessage : "";
|
const message = failureMessage ? failureMessage : "";
|
||||||
expect(message).toMatch(/aborted|interrupted/i);
|
expect(message).toMatch(/aborted|interrupted/i);
|
||||||
@@ -1353,6 +1349,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
approvalPolicy: "on-request",
|
approvalPolicy: "on-request",
|
||||||
@@ -1428,6 +1426,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
const client = new CodexMcpAgentClient();
|
const client = new CodexMcpAgentClient();
|
||||||
const config = {
|
const config = {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
approvalPolicy: "on-request",
|
approvalPolicy: "on-request",
|
||||||
|
|||||||
@@ -2555,6 +2555,11 @@ function buildCodexMcpConfig(
|
|||||||
innerConfig.mcp_servers = mcpServers;
|
innerConfig.mcp_servers = mcpServers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add reasoning effort to config if provided
|
||||||
|
if (typeof config.reasoningEffort === "string" && config.reasoningEffort.length > 0) {
|
||||||
|
innerConfig.model_reasoning_effort = config.reasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
const configPayload: {
|
const configPayload: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -2579,6 +2584,7 @@ function buildCodexMcpConfig(
|
|||||||
configPayload.model = config.model;
|
configPayload.model = config.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Add developer instructions for session resume context
|
// Add developer instructions for session resume context
|
||||||
if (developerInstructions) {
|
if (developerInstructions) {
|
||||||
configPayload["developer-instructions"] = developerInstructions;
|
configPayload["developer-instructions"] = developerInstructions;
|
||||||
@@ -3113,17 +3119,6 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
this.pendingPermissionHandlers.delete(requestId);
|
this.pendingPermissionHandlers.delete(requestId);
|
||||||
this.pendingPermissions.delete(requestId);
|
this.pendingPermissions.delete(requestId);
|
||||||
this.resolvedPermissionRequests.add(requestId);
|
this.resolvedPermissionRequests.add(requestId);
|
||||||
const status = response.behavior === "allow" ? "granted" : "denied";
|
|
||||||
this.emitEvent({
|
|
||||||
type: "timeline",
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
item: createToolCallTimelineItem({
|
|
||||||
name: "permission",
|
|
||||||
status,
|
|
||||||
callId: pending.request.id,
|
|
||||||
input: pending.request.input,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "permission_resolved",
|
type: "permission_resolved",
|
||||||
@@ -3453,16 +3448,6 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitPermissionRequested(request: AgentPermissionRequest): void {
|
private emitPermissionRequested(request: AgentPermissionRequest): void {
|
||||||
this.emitEvent({
|
|
||||||
type: "timeline",
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
item: createToolCallTimelineItem({
|
|
||||||
name: "permission",
|
|
||||||
status: "requested",
|
|
||||||
callId: request.id,
|
|
||||||
input: request.input,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "permission_requested",
|
type: "permission_requested",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function tmpCwd(): string {
|
|||||||
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-"));
|
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
function waitForSignal<T>(
|
function waitForSignal<T>(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
setup: (
|
setup: (
|
||||||
@@ -151,7 +155,9 @@ describe("daemon client v2 E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Daemon Client V2",
|
title: "Daemon Client V2",
|
||||||
requestId: createRequestId,
|
requestId: createRequestId,
|
||||||
@@ -192,7 +198,9 @@ describe("daemon client v2 E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const failResult = await ctx.client.createAgentExpectFail({
|
const failResult = await ctx.client.createAgentExpectFail({
|
||||||
provider: "codex",
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd: "/this/path/does/not/exist/12345",
|
cwd: "/this/path/does/not/exist/12345",
|
||||||
title: "Should Fail",
|
title: "Should Fail",
|
||||||
requestId: failRequestId,
|
requestId: failRequestId,
|
||||||
@@ -401,7 +409,9 @@ describe("daemon client v2 E2E", () => {
|
|||||||
const filePath = path.join(cwd, "permission.txt");
|
const filePath = path.join(cwd, "permission.txt");
|
||||||
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Permission Test",
|
title: "Permission Test",
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
@@ -475,7 +485,9 @@ describe("daemon client v2 E2E", () => {
|
|||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Raw Events Test",
|
title: "Raw Events Test",
|
||||||
});
|
});
|
||||||
@@ -656,7 +668,9 @@ describe("daemon client v2 E2E", () => {
|
|||||||
writeFileSync(downloadFile, downloadContents, "utf-8");
|
writeFileSync(downloadFile, downloadContents, "utf-8");
|
||||||
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Git/File Test",
|
title: "Git/File Test",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test("creates agent and receives response", async () => {
|
||||||
|
// Create a Codex agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd: "/tmp",
|
||||||
|
title: "Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("codex");
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
// Title may or may not be set depending on timing
|
||||||
|
expect(agent.cwd).toBe("/tmp");
|
||||||
|
|
||||||
|
// Send a simple message
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'hello world' and nothing else");
|
||||||
|
|
||||||
|
// Wait for the agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
// Verify agent completed without error
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
expect(finalState.lastError).toBeUndefined();
|
||||||
|
expect(finalState.id).toBe(agent.id);
|
||||||
|
|
||||||
|
// Verify we received some stream events
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const streamEvents = queue.filter(
|
||||||
|
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id
|
||||||
|
);
|
||||||
|
expect(streamEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify there was a turn_started event
|
||||||
|
const hasTurnStarted = streamEvents.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" && m.payload.event.type === "turn_started"
|
||||||
|
);
|
||||||
|
expect(hasTurnStarted).toBe(true);
|
||||||
|
|
||||||
|
// Verify there was a turn_completed event
|
||||||
|
const hasTurnCompleted = streamEvents.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" && m.payload.event.type === "turn_completed"
|
||||||
|
);
|
||||||
|
expect(hasTurnCompleted).toBe(true);
|
||||||
|
|
||||||
|
// Verify there was an assistant message in the timeline
|
||||||
|
const hasAssistantMessage = streamEvents.some((m) => {
|
||||||
|
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
return item.type === "assistant_message" && item.text.length > 0;
|
||||||
|
});
|
||||||
|
expect(hasAssistantMessage).toBe(true);
|
||||||
|
}, 180000); // 3 minute timeout for E2E test
|
||||||
|
|
||||||
|
|
||||||
|
test("fails to create agent with non-existent cwd", async () => {
|
||||||
|
const nonExistentCwd = "/this/path/does/not/exist/12345";
|
||||||
|
|
||||||
|
const result = await ctx.client.createAgentExpectFail({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd: nonExistentCwd,
|
||||||
|
title: "Should Fail Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toContain("Working directory does not exist");
|
||||||
|
expect(result.error).toContain(nonExistentCwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Shared agent configurations for e2e tests.
|
||||||
|
* Enables running the same tests against both Claude and Codex providers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AgentTestConfig {
|
||||||
|
provider: "claude" | "codex";
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: string;
|
||||||
|
modes: {
|
||||||
|
full: string; // No permissions required
|
||||||
|
ask: string; // Requires permission approval
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentConfigs = {
|
||||||
|
claude: {
|
||||||
|
provider: "claude",
|
||||||
|
model: "haiku",
|
||||||
|
modes: {
|
||||||
|
full: "bypassPermissions",
|
||||||
|
ask: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
provider: "codex",
|
||||||
|
model: "gpt-5.1-codex-mini",
|
||||||
|
reasoningEffort: "low",
|
||||||
|
modes: {
|
||||||
|
full: "full-access",
|
||||||
|
ask: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, AgentTestConfig>;
|
||||||
|
|
||||||
|
export type AgentProvider = keyof typeof agentConfigs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get test config for creating an agent with full permissions (no prompts).
|
||||||
|
*/
|
||||||
|
export function getFullAccessConfig(provider: AgentProvider) {
|
||||||
|
const config = agentConfigs[provider];
|
||||||
|
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined;
|
||||||
|
return {
|
||||||
|
provider: config.provider,
|
||||||
|
model: config.model,
|
||||||
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||||
|
modeId: config.modes.full,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get test config for creating an agent that requires permission approval.
|
||||||
|
*/
|
||||||
|
export function getAskModeConfig(provider: AgentProvider) {
|
||||||
|
const config = agentConfigs[provider];
|
||||||
|
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined;
|
||||||
|
return {
|
||||||
|
provider: config.provider,
|
||||||
|
model: config.model,
|
||||||
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||||
|
modeId: config.modes.ask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to run a test for each provider.
|
||||||
|
*/
|
||||||
|
export const allProviders: AgentProvider[] = ["claude", "codex"];
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("timestamp behavior", () => {
|
||||||
|
test(
|
||||||
|
"opening agent without interaction does not update timestamp",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create a Codex agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Timestamp Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Record the initial updatedAt timestamp
|
||||||
|
const initialUpdatedAt = agent.updatedAt;
|
||||||
|
expect(initialUpdatedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// Wait a bit to ensure any timestamp update would be visible
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
// Clear message queue before the "click" action
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Simulate clicking on the agent (initialize_agent_request)
|
||||||
|
// This is what happens when the user opens an agent in the UI
|
||||||
|
const refreshedState = await ctx.client.initializeAgent(agent.id);
|
||||||
|
|
||||||
|
// Verify agent is still idle
|
||||||
|
expect(refreshedState.status).toBe("idle");
|
||||||
|
|
||||||
|
// CRITICAL: The timestamp should NOT have changed
|
||||||
|
// Just opening/clicking an agent should not update its updatedAt
|
||||||
|
expect(refreshedState.updatedAt).toBe(initialUpdatedAt);
|
||||||
|
|
||||||
|
// Also clear attention (what happens when opening an agent with notification)
|
||||||
|
await ctx.client.clearAgentAttention(agent.id);
|
||||||
|
|
||||||
|
// Get the state again after clearing attention
|
||||||
|
const stateAfterClear = await ctx.client.initializeAgent(agent.id);
|
||||||
|
|
||||||
|
// Timestamp should STILL not have changed
|
||||||
|
expect(stateAfterClear.updatedAt).toBe(initialUpdatedAt);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"sending message DOES update timestamp",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create a Codex agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Timestamp Update Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Record the initial updatedAt timestamp
|
||||||
|
const initialUpdatedAt = new Date(agent.updatedAt);
|
||||||
|
|
||||||
|
// Wait a bit to ensure timestamp difference is visible
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
// Send a message (this SHOULD update the timestamp)
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
|
||||||
|
// The timestamp SHOULD have been updated (should be later than initial)
|
||||||
|
const finalUpdatedAt = new Date(finalState.updatedAt);
|
||||||
|
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("cancelAgent", () => {
|
||||||
|
test(
|
||||||
|
"cancels a running agent mid-execution",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Codex agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Cancel Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Send a prompt that triggers a long-running operation
|
||||||
|
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
|
||||||
|
|
||||||
|
// Wait for the agent to start running (tool call starts)
|
||||||
|
let sawRunning = false;
|
||||||
|
const startPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
// Wait for running state
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Timeout waiting for agent to start running"));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
const checkForRunning = (): void => {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
||||||
|
if (msg.payload.status === "running") {
|
||||||
|
sawRunning = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check periodically
|
||||||
|
const interval = setInterval(checkForRunning, 50);
|
||||||
|
const cleanup = (): void => {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override reject to cleanup
|
||||||
|
const originalReject = reject;
|
||||||
|
reject = (err): void => {
|
||||||
|
cleanup();
|
||||||
|
originalReject(err);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sawRunning).toBe(true);
|
||||||
|
|
||||||
|
// Record timestamp before cancel
|
||||||
|
const cancelStart = Date.now();
|
||||||
|
|
||||||
|
// Cancel the agent
|
||||||
|
await ctx.client.cancelAgent(agent.id);
|
||||||
|
|
||||||
|
// Wait for agent to reach idle or error state
|
||||||
|
const finalState = await new Promise<AgentSnapshotPayload>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Timeout waiting for agent to stop after cancel (>2 seconds)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, 5000); // Give extra margin, but test should complete in 2s
|
||||||
|
|
||||||
|
const queueStart = ctx.client.getMessageQueue().length;
|
||||||
|
const checkForStopped = (): void => {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = queueStart; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
||||||
|
if (
|
||||||
|
msg.payload.status === "idle" ||
|
||||||
|
msg.payload.status === "error"
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(msg.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkForStopped, 50);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate how long the cancel took
|
||||||
|
const cancelDuration = Date.now() - cancelStart;
|
||||||
|
|
||||||
|
// Verify agent stopped within reasonable time (2 seconds)
|
||||||
|
expect(cancelDuration).toBeLessThan(2000);
|
||||||
|
|
||||||
|
// Verify agent is now idle or error
|
||||||
|
expect(["idle", "error"]).toContain(finalState.status);
|
||||||
|
|
||||||
|
// Verify no zombie sleep processes left (check for sleep 30)
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
try {
|
||||||
|
const result = execSync("pgrep -f 'sleep 30'", {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
// If pgrep succeeds, there are zombie processes
|
||||||
|
if (result.trim()) {
|
||||||
|
// Kill them and fail the test
|
||||||
|
execSync("pkill -f 'sleep 30'");
|
||||||
|
expect.fail("Found zombie sleep processes after cancel");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// pgrep returns non-zero when no processes found - this is expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("setAgentMode", () => {
|
||||||
|
test(
|
||||||
|
"switches agent mode and persists across messages",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create a Codex agent with default mode ("auto")
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Mode Switch Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify initial mode is "auto" (the default)
|
||||||
|
expect(agent.currentModeId).toBe("auto");
|
||||||
|
|
||||||
|
// Clear message queue before mode switch
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
const startPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
// Switch to "read-only" mode
|
||||||
|
await ctx.client.setAgentMode(agent.id, "read-only");
|
||||||
|
|
||||||
|
// Wait for agent_state update reflecting the new mode
|
||||||
|
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Timeout waiting for mode change in agent_state"));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const checkForModeChange = (): void => {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (
|
||||||
|
msg.type === "agent_state" &&
|
||||||
|
msg.payload.id === agent.id &&
|
||||||
|
msg.payload.currentModeId === "read-only"
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(msg.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkForModeChange, 50);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify mode changed to "read-only"
|
||||||
|
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
|
||||||
|
|
||||||
|
// Now verify the mode persists: send a message and check the mode is still "read-only"
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
|
||||||
|
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
// Mode should still be "read-only" after the message
|
||||||
|
expect(finalState.currentModeId).toBe("read-only");
|
||||||
|
|
||||||
|
// Also verify runtimeInfo has the updated modeId
|
||||||
|
expect(finalState.runtimeInfo?.modeId).toBe("read-only");
|
||||||
|
|
||||||
|
// Switch to another mode: "full-access"
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
const position2 = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
await ctx.client.setAgentMode(agent.id, "full-access");
|
||||||
|
|
||||||
|
// Wait for agent_state update
|
||||||
|
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Timeout waiting for full-access mode change"));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const checkForModeChange = (): void => {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = position2; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (
|
||||||
|
msg.type === "agent_state" &&
|
||||||
|
msg.payload.id === agent.id &&
|
||||||
|
msg.payload.currentModeId === "full-access"
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(msg.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkForModeChange, 50);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000 // 3 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("listAgents", () => {
|
||||||
|
test(
|
||||||
|
"returns current agents and reflects create/delete operations",
|
||||||
|
async () => {
|
||||||
|
const cwd1 = tmpCwd();
|
||||||
|
const cwd2 = tmpCwd();
|
||||||
|
|
||||||
|
// Initially, there should be no agents (fresh session)
|
||||||
|
const initialAgents = ctx.client.listAgents();
|
||||||
|
expect(initialAgents).toHaveLength(0);
|
||||||
|
|
||||||
|
// Create first agent
|
||||||
|
const agent1 = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd: cwd1,
|
||||||
|
title: "List Test Agent 1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent1.id).toBeTruthy();
|
||||||
|
expect(agent1.status).toBe("idle");
|
||||||
|
|
||||||
|
// listAgents should now return 1 agent
|
||||||
|
const afterFirst = ctx.client.listAgents();
|
||||||
|
expect(afterFirst).toHaveLength(1);
|
||||||
|
expect(afterFirst[0].id).toBe(agent1.id);
|
||||||
|
// Title may or may not be set depending on timing
|
||||||
|
expect(afterFirst[0].cwd).toBe(cwd1);
|
||||||
|
|
||||||
|
// Create second agent
|
||||||
|
const agent2 = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd: cwd2,
|
||||||
|
title: "List Test Agent 2",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent2.id).toBeTruthy();
|
||||||
|
expect(agent2.status).toBe("idle");
|
||||||
|
|
||||||
|
// listAgents should now return 2 agents
|
||||||
|
const afterSecond = ctx.client.listAgents();
|
||||||
|
expect(afterSecond).toHaveLength(2);
|
||||||
|
|
||||||
|
// Verify both agents are present with correct IDs and states
|
||||||
|
const ids = afterSecond.map((a) => a.id);
|
||||||
|
expect(ids).toContain(agent1.id);
|
||||||
|
expect(ids).toContain(agent2.id);
|
||||||
|
|
||||||
|
const agent1State = afterSecond.find((a) => a.id === agent1.id);
|
||||||
|
const agent2State = afterSecond.find((a) => a.id === agent2.id);
|
||||||
|
|
||||||
|
// Title may or may not be set depending on timing
|
||||||
|
expect(agent1State?.cwd).toBe(cwd1);
|
||||||
|
expect(agent1State?.status).toBe("idle");
|
||||||
|
|
||||||
|
// Title may or may not be set depending on timing
|
||||||
|
expect(agent2State?.cwd).toBe(cwd2);
|
||||||
|
expect(agent2State?.status).toBe("idle");
|
||||||
|
|
||||||
|
// Delete first agent
|
||||||
|
await ctx.client.deleteAgent(agent1.id);
|
||||||
|
|
||||||
|
// listAgents should now return only 1 agent
|
||||||
|
const afterDelete = ctx.client.listAgents();
|
||||||
|
expect(afterDelete).toHaveLength(1);
|
||||||
|
expect(afterDelete[0].id).toBe(agent2.id);
|
||||||
|
expect(afterDelete[0].cwd).toBe(cwd2);
|
||||||
|
|
||||||
|
// Verify agent1 is no longer in the list
|
||||||
|
const deletedAgent = afterDelete.find((a) => a.id === agent1.id);
|
||||||
|
expect(deletedAgent).toBeUndefined();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent2.id);
|
||||||
|
rmSync(cwd1, { recursive: true, force: true });
|
||||||
|
rmSync(cwd2, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("file download tokens", () => {
|
||||||
|
test(
|
||||||
|
"issues token over WS and downloads via HTTP",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "download.txt");
|
||||||
|
const fileContents = "download test payload";
|
||||||
|
writeFileSync(filePath, fileContents, "utf-8");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Download Token Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
|
agent.id,
|
||||||
|
"download.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokenResponse.error).toBeNull();
|
||||||
|
expect(tokenResponse.token).toBeTruthy();
|
||||||
|
expect(tokenResponse.fileName).toBe("download.txt");
|
||||||
|
|
||||||
|
const authHeader = ctx.daemon.agentMcpAuthHeader;
|
||||||
|
expect(authHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||||
|
{ headers: { Authorization: authHeader! } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe(
|
||||||
|
tokenResponse.mimeType
|
||||||
|
);
|
||||||
|
const disposition = response.headers.get("content-disposition") ?? "";
|
||||||
|
expect(disposition).toContain("download.txt");
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
expect(body).toBe(fileContents);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"rejects invalid token",
|
||||||
|
async () => {
|
||||||
|
const authHeader = ctx.daemon.agentMcpAuthHeader;
|
||||||
|
expect(authHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=invalid-token`,
|
||||||
|
{ headers: { Authorization: authHeader! } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
},
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"rejects expired token",
|
||||||
|
async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
ctx = await createDaemonTestContext({ downloadTokenTtlMs: 50 });
|
||||||
|
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "expired.txt");
|
||||||
|
writeFileSync(filePath, "expired", "utf-8");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Expired Token Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
|
agent.id,
|
||||||
|
"expired.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokenResponse.error).toBeNull();
|
||||||
|
expect(tokenResponse.token).toBeTruthy();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
const authHeader = ctx.daemon.agentMcpAuthHeader;
|
||||||
|
expect(authHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||||
|
{ headers: { Authorization: authHeader! } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"rejects paths outside the agent cwd",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Outside Path Token Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
|
agent.id,
|
||||||
|
"../outside.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokenResponse.token).toBeNull();
|
||||||
|
expect(tokenResponse.error).toBeTruthy();
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("exploreFileSystem", () => {
|
||||||
|
test(
|
||||||
|
"lists directory contents",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create test files and directories
|
||||||
|
writeFileSync(path.join(cwd, "test.txt"), "hello world\n");
|
||||||
|
writeFileSync(path.join(cwd, "data.json"), '{"key": "value"}\n');
|
||||||
|
mkdirSync(path.join(cwd, "subdir"));
|
||||||
|
writeFileSync(path.join(cwd, "subdir", "nested.txt"), "nested content\n");
|
||||||
|
|
||||||
|
// Create agent in the directory
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "File Explorer Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// List directory contents
|
||||||
|
const result = await ctx.client.exploreFileSystem(agent.id, cwd, "list");
|
||||||
|
|
||||||
|
// Verify listing returned without error
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.mode).toBe("list");
|
||||||
|
expect(result.directory).toBeTruthy();
|
||||||
|
expect(result.directory!.entries).toBeTruthy();
|
||||||
|
|
||||||
|
// Find expected entries
|
||||||
|
const entries = result.directory!.entries;
|
||||||
|
const testTxt = entries.find((e) => e.name === "test.txt");
|
||||||
|
const dataJson = entries.find((e) => e.name === "data.json");
|
||||||
|
const subdir = entries.find((e) => e.name === "subdir");
|
||||||
|
|
||||||
|
expect(testTxt).toBeTruthy();
|
||||||
|
expect(testTxt!.kind).toBe("file");
|
||||||
|
expect(testTxt!.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(dataJson).toBeTruthy();
|
||||||
|
expect(dataJson!.kind).toBe("file");
|
||||||
|
|
||||||
|
expect(subdir).toBeTruthy();
|
||||||
|
expect(subdir!.kind).toBe("directory");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"reads file contents",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testContent = "This is test file content.\nLine 2.";
|
||||||
|
const testFile = path.join(cwd, "readme.txt");
|
||||||
|
writeFileSync(testFile, testContent);
|
||||||
|
|
||||||
|
// Create agent in the directory
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "File Read Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Read file contents
|
||||||
|
const result = await ctx.client.exploreFileSystem(agent.id, testFile, "file");
|
||||||
|
|
||||||
|
// Verify file read
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.mode).toBe("file");
|
||||||
|
expect(result.file).toBeTruthy();
|
||||||
|
// Server may return basename or full path
|
||||||
|
expect(result.file!.path).toContain("readme.txt");
|
||||||
|
expect(result.file!.kind).toBe("text");
|
||||||
|
expect(result.file!.content).toBe(testContent);
|
||||||
|
expect(result.file!.size).toBe(testContent.length);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"returns error for non-existent path",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "File Explorer Error Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Try to list non-existent path
|
||||||
|
const nonExistent = path.join(cwd, "does-not-exist");
|
||||||
|
const result = await ctx.client.exploreFileSystem(agent.id, nonExistent, "list");
|
||||||
|
|
||||||
|
// Should return error
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("getGitDiff", () => {
|
||||||
|
test(
|
||||||
|
"returns diff for modified file in git repo",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
execSync("git init", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "original content\n");
|
||||||
|
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify the file (creates unstaged changes)
|
||||||
|
writeFileSync(testFile, "modified content\n");
|
||||||
|
|
||||||
|
// Create agent in the git repo
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Git Diff Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Get git diff
|
||||||
|
const result = await ctx.client.getGitDiff(agent.id);
|
||||||
|
|
||||||
|
// Verify diff returned without error
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.diff).toBeTruthy();
|
||||||
|
expect(result.diff).toContain("test.txt");
|
||||||
|
expect(result.diff).toContain("-original content");
|
||||||
|
expect(result.diff).toContain("+modified content");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"returns empty diff when no changes",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Initialize git repo with clean state
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
execSync("git init", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "content\n");
|
||||||
|
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create agent in the git repo (no modifications)
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Git Diff Clean Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Get git diff - should be empty
|
||||||
|
const result = await ctx.client.getGitDiff(agent.id);
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.diff).toBe("");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"returns error for non-git directory",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
// Don't initialize git - just a regular directory
|
||||||
|
|
||||||
|
// Create agent in a non-git directory
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Git Diff Non-Git Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Get git diff - should return error
|
||||||
|
const result = await ctx.client.getGitDiff(agent.id);
|
||||||
|
|
||||||
|
expect(result.diff).toBe("");
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(result.error).toContain("git");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("getGitRepoInfo", () => {
|
||||||
|
test(
|
||||||
|
"returns repo info for git repo with branch and dirty state",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
execSync("git init", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "original content\n");
|
||||||
|
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify the file (makes repo dirty)
|
||||||
|
writeFileSync(testFile, "modified content\n");
|
||||||
|
|
||||||
|
// Create agent in the git repo
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Git Repo Info Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Get git repo info
|
||||||
|
const result = await ctx.client.getGitRepoInfo(agent.id);
|
||||||
|
|
||||||
|
// Verify repo info returned without error
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
// macOS symlinks /var to /private/var, so we check containment
|
||||||
|
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||||
|
expect(result.currentBranch).toBeTruthy();
|
||||||
|
expect(result.branches.length).toBeGreaterThan(0);
|
||||||
|
expect(result.branches.some((b) => b.isCurrent)).toBe(true);
|
||||||
|
expect(result.isDirty).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"returns clean state when no uncommitted changes",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Initialize git repo with clean state
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
execSync("git init", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
|
||||||
|
// Create and commit a file (no uncommitted changes)
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "content\n");
|
||||||
|
execSync("git add test.txt", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create agent in the git repo
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Git Repo Info Clean Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Get git repo info
|
||||||
|
const result = await ctx.client.getGitRepoInfo(agent.id);
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.isDirty).toBe(false);
|
||||||
|
expect(result.currentBranch).toBeTruthy();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"returns error for non-git directory",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
// Don't initialize git - just a regular directory
|
||||||
|
|
||||||
|
// Create agent in a non-git directory
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Git Repo Info Non-Git Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Get git repo info - should return error
|
||||||
|
const result = await ctx.client.getGitRepoInfo(agent.id);
|
||||||
|
|
||||||
|
// Server returns cwd as repoRoot even on error, so we just check for error
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("sendImages", () => {
|
||||||
|
// Minimal 1x1 red PNG image encoded in base64
|
||||||
|
// This is a valid PNG that can be decoded by image processing libraries
|
||||||
|
const MINIMAL_PNG_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
test(
|
||||||
|
"sends message with image attachment to Claude agent",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Claude agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Image Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("claude");
|
||||||
|
|
||||||
|
// Send message with image attachment
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"I'm sending you an image. Describe what information you received about the image attachment. Reply with a single short sentence.",
|
||||||
|
{
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
data: MINIMAL_PNG_BASE64,
|
||||||
|
mimeType: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
expect(finalState.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify stream events show the agent processed the message
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const streamEvents = queue.filter(
|
||||||
|
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have received stream events
|
||||||
|
expect(streamEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify turn completed successfully
|
||||||
|
const hasTurnCompleted = streamEvents.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" && m.payload.event.type === "turn_completed"
|
||||||
|
);
|
||||||
|
expect(hasTurnCompleted).toBe(true);
|
||||||
|
|
||||||
|
// Verify there was an assistant message in the timeline
|
||||||
|
const hasAssistantMessage = streamEvents.some((m) => {
|
||||||
|
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
return item.type === "assistant_message" && item.text.length > 0;
|
||||||
|
});
|
||||||
|
expect(hasAssistantMessage).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000 // 3 minute timeout for Claude API call
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"sends message with multiple image attachments",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Claude agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Multi-Image Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Send message with two image attachments
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"I'm sending you two images. How many image attachments are mentioned in the context? Reply with just a number.",
|
||||||
|
{
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
data: MINIMAL_PNG_BASE64,
|
||||||
|
mimeType: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: MINIMAL_PNG_BASE64,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
expect(finalState.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify turn completed
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasTurnCompleted = queue.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "turn_completed"
|
||||||
|
);
|
||||||
|
expect(hasTurnCompleted).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000 // 3 minute timeout for Claude API call
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("listProviderModels", () => {
|
||||||
|
test(
|
||||||
|
"returns model list for Codex provider",
|
||||||
|
async () => {
|
||||||
|
// List models for Codex provider - no agent needed
|
||||||
|
const result = await ctx.client.listProviderModels("codex");
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
expect(result.provider).toBe("codex");
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.fetchedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// Should return at least one model
|
||||||
|
expect(result.models).toBeTruthy();
|
||||||
|
expect(result.models.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify model structure
|
||||||
|
const model = result.models[0];
|
||||||
|
expect(model.provider).toBe("codex");
|
||||||
|
expect(model.id).toBeTruthy();
|
||||||
|
expect(model.label).toBeTruthy();
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"returns model list for Claude provider",
|
||||||
|
async () => {
|
||||||
|
// List models for Claude provider - no agent needed
|
||||||
|
const result = await ctx.client.listProviderModels("claude");
|
||||||
|
|
||||||
|
// Verify response structure
|
||||||
|
expect(result.provider).toBe("claude");
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.fetchedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// Should return at least one model
|
||||||
|
expect(result.models).toBeTruthy();
|
||||||
|
expect(result.models.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify model structure
|
||||||
|
const model = result.models[0];
|
||||||
|
expect(model.provider).toBe("claude");
|
||||||
|
expect(model.id).toBeTruthy();
|
||||||
|
expect(model.label).toBeTruthy();
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("multi-agent orchestration", () => {
|
||||||
|
test(
|
||||||
|
"parent agent creates child agent via agent-control MCP",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const childCwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create parent Codex agent
|
||||||
|
const parent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Parent Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parent.id).toBeTruthy();
|
||||||
|
expect(parent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Prompt the parent to create a child agent using agent-control MCP
|
||||||
|
const prompt = [
|
||||||
|
`Use the create_agent tool from the agent-control MCP server to create a new codex agent.`,
|
||||||
|
`Set the cwd to: ${childCwd}`,
|
||||||
|
`Set the title to: Child Agent`,
|
||||||
|
`Set agentType to: codex`,
|
||||||
|
`Do NOT set an initialPrompt - just create the agent.`,
|
||||||
|
`After creating the agent, reply with "CREATED" followed by the child's agentId.`,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(parent.id, prompt);
|
||||||
|
|
||||||
|
// Wait for parent to complete
|
||||||
|
const afterCreate = await ctx.client.waitForAgentIdle(
|
||||||
|
parent.id,
|
||||||
|
120000
|
||||||
|
);
|
||||||
|
expect(afterCreate.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify timeline contains a tool call to create_agent
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const timelineItems: AgentTimelineItem[] = [];
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === parent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
timelineItems.push(m.payload.event.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have a tool call to create_agent from agent-control
|
||||||
|
const hasCreateAgentCall = timelineItems.some(
|
||||||
|
(item) =>
|
||||||
|
item.type === "tool_call" &&
|
||||||
|
item.name === "agent-control.create_agent"
|
||||||
|
);
|
||||||
|
expect(hasCreateAgentCall).toBe(true);
|
||||||
|
|
||||||
|
// Now verify we can see both agents via session_state
|
||||||
|
// Send a list_persisted_agents_request to trigger session_state refresh
|
||||||
|
// Or we can check the queue for agent_state messages
|
||||||
|
const agentStateMessages = queue.filter(
|
||||||
|
(m) => m.type === "agent_state"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract unique agent IDs from state messages
|
||||||
|
const agentIds = new Set<string>();
|
||||||
|
for (const m of agentStateMessages) {
|
||||||
|
if (m.type === "agent_state") {
|
||||||
|
agentIds.add(m.payload.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least 2 agents (parent + child)
|
||||||
|
expect(agentIds.size).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(agentIds.has(parent.id)).toBe(true);
|
||||||
|
|
||||||
|
// Get the child agent ID from the tool call output
|
||||||
|
const createAgentCall = timelineItems.find(
|
||||||
|
(item) =>
|
||||||
|
item.type === "tool_call" &&
|
||||||
|
item.name === "agent-control.create_agent"
|
||||||
|
);
|
||||||
|
|
||||||
|
let childAgentId: string | null = null;
|
||||||
|
if (
|
||||||
|
createAgentCall &&
|
||||||
|
createAgentCall.type === "tool_call" &&
|
||||||
|
createAgentCall.output
|
||||||
|
) {
|
||||||
|
// The output contains the agentId
|
||||||
|
const output = createAgentCall.output as { agentId?: string };
|
||||||
|
if (output.agentId) {
|
||||||
|
childAgentId = output.agentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we found the child agent ID
|
||||||
|
expect(childAgentId).toBeTruthy();
|
||||||
|
expect(agentIds.has(childAgentId!)).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
rmSync(childCwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout for multi-agent E2E
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
useTempClaudeConfigDir,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("permission flow: Claude", () => {
|
||||||
|
// Use isolated Claude config to ensure permission prompts are triggered
|
||||||
|
// (user's real config may have allow rules that auto-approve commands)
|
||||||
|
let restoreClaudeConfig: () => void;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
restoreClaudeConfig = useTempClaudeConfigDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreClaudeConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"approves permission and executes command",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "permission.txt");
|
||||||
|
writeFileSync(filePath, "ok", "utf8");
|
||||||
|
|
||||||
|
// Create Claude agent with sandbox config that requires permission for bash
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Permission Test",
|
||||||
|
modeId: "default",
|
||||||
|
extra: {
|
||||||
|
claude: {
|
||||||
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Send a prompt that requires permission (rm command triggers approval)
|
||||||
|
const prompt = [
|
||||||
|
"You must call the Bash command tool with the exact command `rm -f permission.txt`.",
|
||||||
|
"After approval, run it and reply DONE.",
|
||||||
|
"Do not respond before the command finishes.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, prompt);
|
||||||
|
|
||||||
|
// Wait for permission request
|
||||||
|
const permission = await ctx.client.waitForPermission(agent.id, 60000);
|
||||||
|
expect(permission).not.toBeNull();
|
||||||
|
expect(permission.id).toBeTruthy();
|
||||||
|
expect(permission.kind).toBe("tool");
|
||||||
|
|
||||||
|
// Approve the permission
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify the file was deleted
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
|
||||||
|
// Verify permission_resolved event was received
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasPermissionResolved = queue.some((m) => {
|
||||||
|
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
|
||||||
|
return (
|
||||||
|
m.payload.event.type === "permission_resolved" &&
|
||||||
|
m.payload.event.requestId === permission.id &&
|
||||||
|
m.payload.event.resolution.behavior === "allow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(hasPermissionResolved).toBe(true);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"denies permission and prevents execution",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "permission.txt");
|
||||||
|
writeFileSync(filePath, "ok", "utf8");
|
||||||
|
|
||||||
|
// Create Claude agent with sandbox config that requires permission for bash
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Permission Deny Test",
|
||||||
|
modeId: "default",
|
||||||
|
extra: {
|
||||||
|
claude: {
|
||||||
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Send a prompt that requires permission
|
||||||
|
const prompt = [
|
||||||
|
"You must call the Bash command tool with the exact command `rm -f permission.txt`.",
|
||||||
|
"If approval is denied, reply DENIED and stop.",
|
||||||
|
"Do not respond before the command finishes or the denial is confirmed.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, prompt);
|
||||||
|
|
||||||
|
// Wait for permission request
|
||||||
|
const permission = await ctx.client.waitForPermission(agent.id, 60000);
|
||||||
|
expect(permission).not.toBeNull();
|
||||||
|
expect(permission.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Deny the permission
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "deny",
|
||||||
|
message: "Not allowed.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify the file was NOT deleted
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
// Verify permission_resolved event was received with deny
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasPermissionDenied = queue.some((m) => {
|
||||||
|
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
|
||||||
|
return (
|
||||||
|
m.payload.event.type === "permission_resolved" &&
|
||||||
|
m.payload.event.requestId === permission.id &&
|
||||||
|
m.payload.event.resolution.behavior === "deny"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(hasPermissionDenied).toBe(true);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("permission flow: Codex", () => {
|
||||||
|
test(
|
||||||
|
"approves permission and executes command",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "permission.txt");
|
||||||
|
|
||||||
|
// Create Codex agent with on-request approval policy
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Permission Test",
|
||||||
|
modeId: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Send a prompt that requires permission
|
||||||
|
const prompt = [
|
||||||
|
"Request approval to run the command `printf \"ok\" > permission.txt`.",
|
||||||
|
"After approval, run it and reply DONE.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, prompt);
|
||||||
|
|
||||||
|
// Wait for permission request
|
||||||
|
const permission = await ctx.client.waitForPermission(agent.id, 60000);
|
||||||
|
expect(permission).not.toBeNull();
|
||||||
|
expect(permission.id).toBeTruthy();
|
||||||
|
expect(permission.kind).toBe("tool");
|
||||||
|
|
||||||
|
// Approve the permission
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify the file was created
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
// Verify permission_resolved event was received
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasPermissionResolved = queue.some((m) => {
|
||||||
|
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
|
||||||
|
return (
|
||||||
|
m.payload.event.type === "permission_resolved" &&
|
||||||
|
m.payload.event.requestId === permission.id &&
|
||||||
|
m.payload.event.resolution.behavior === "allow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(hasPermissionResolved).toBe(true);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"denies permission and prevents execution",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "permission.txt");
|
||||||
|
|
||||||
|
// Create Codex agent with on-request approval policy
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Permission Deny Test",
|
||||||
|
modeId: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Send a prompt that requires permission
|
||||||
|
const prompt = [
|
||||||
|
"Request approval to run the command `printf \"ok\" > permission.txt`.",
|
||||||
|
"If approval is denied, acknowledge and stop.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, prompt);
|
||||||
|
|
||||||
|
// Wait for permission request
|
||||||
|
const permission = await ctx.client.waitForPermission(agent.id, 60000);
|
||||||
|
expect(permission).not.toBeNull();
|
||||||
|
expect(permission.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Deny the permission
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "deny",
|
||||||
|
message: "Not allowed.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify the file was NOT created
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
|
||||||
|
// Verify permission_resolved event was received with deny
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasPermissionDenied = queue.some((m) => {
|
||||||
|
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
|
||||||
|
return (
|
||||||
|
m.payload.event.type === "permission_resolved" &&
|
||||||
|
m.payload.event.requestId === permission.id &&
|
||||||
|
m.payload.event.resolution.behavior === "deny"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(hasPermissionDenied).toBe(true);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Fix this test - there's a race condition causing agent not found errors
|
||||||
|
test.skip(
|
||||||
|
"Codex agent can complete a new turn after interrupt",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Codex agent with full-access (no permissions needed)
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Interrupt Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.currentModeId).toBe("full-access");
|
||||||
|
|
||||||
|
// Send first message to start the agent
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(agent.id, "List the files in the current directory.");
|
||||||
|
|
||||||
|
// Wait for agent to start running
|
||||||
|
await ctx.client.waitForAgentState(
|
||||||
|
agent.id,
|
||||||
|
(snapshot) => snapshot.status === "running",
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel while running
|
||||||
|
await ctx.client.cancelAgent(agent.id);
|
||||||
|
|
||||||
|
// Wait for agent to become idle after cancellation
|
||||||
|
// Don't use waitForAgentIdle because it requires seeing "running" first,
|
||||||
|
// but we already saw it above. Just wait for "idle" or "error".
|
||||||
|
await ctx.client.waitForAgentState(
|
||||||
|
agent.id,
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.status === "idle" || snapshot.status === "error",
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now send another message - this should work
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Say 'hello from interrupt test' and nothing else."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for this to complete
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 60000);
|
||||||
|
|
||||||
|
// Verify we got an assistant message in the queue
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasAssistantMessage = queue.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.agentId === agent.id &&
|
||||||
|
m.event?.type === "timeline" &&
|
||||||
|
m.event?.item?.type === "assistant_message"
|
||||||
|
);
|
||||||
|
expect(hasAssistantMessage).toBe(true);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
120000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"aborting Codex actually stops execution (sleep + write test)",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "abort-test-file.txt");
|
||||||
|
|
||||||
|
// Create Codex agent with full-access (no permissions needed)
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Abort Stop Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Ask Codex to sleep 15 seconds then write a file
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Run this bash command: sleep 15 && echo 'abort-test-completed' > abort-test-file.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait 3 seconds for the command to start
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
// Cancel/interrupt the agent
|
||||||
|
await ctx.client.cancelAgent(agent.id);
|
||||||
|
|
||||||
|
// Wait 10 seconds - if abort works, file should NOT be written
|
||||||
|
// (sleep would have completed at 15s if not interrupted)
|
||||||
|
await new Promise((r) => setTimeout(r, 10000));
|
||||||
|
|
||||||
|
// Assert the file was NOT created (proving Codex actually stopped)
|
||||||
|
const fileExists = existsSync(filePath);
|
||||||
|
expect(fileExists).toBe(false);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
30000 // 30 second timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Fix this test - there's a race condition causing timeout errors
|
||||||
|
test.skip(
|
||||||
|
"switching from auto to full-access mode allows writes without permission",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "mode-switch-test.txt");
|
||||||
|
|
||||||
|
// Step 1: Create Codex agent with "auto" mode (requires permission for writes)
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Mode Switch Permission Test",
|
||||||
|
modeId: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.currentModeId).toBe("auto");
|
||||||
|
|
||||||
|
// Step 2: Ask agent to write a file - this should trigger permission request
|
||||||
|
// Note: We DON'T tell the agent to "stop" if denied - this keeps the conversation
|
||||||
|
// alive and tests the real scenario where mode switch must work mid-conversation.
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
const writePrompt =
|
||||||
|
"Write a file called mode-switch-test.txt with the content 'first'";
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, writePrompt);
|
||||||
|
|
||||||
|
// Step 3: Wait for permission request
|
||||||
|
const permission = await ctx.client.waitForPermission(agent.id, 60000);
|
||||||
|
expect(permission).not.toBeNull();
|
||||||
|
expect(permission.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Step 4: Deny the permission
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "deny",
|
||||||
|
message: "Permission denied for test.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to complete after denial
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
// Verify file was NOT created after denial
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
|
||||||
|
// Step 5: Switch to "full-access" mode
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
const modeStartPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
await ctx.client.setAgentMode(agent.id, "full-access");
|
||||||
|
|
||||||
|
// Wait for mode change to be reflected in agent_state
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Timeout waiting for full-access mode change"));
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
const checkForModeChange = (): void => {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = modeStartPosition; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (
|
||||||
|
msg.type === "agent_state" &&
|
||||||
|
msg.payload.id === agent.id &&
|
||||||
|
msg.payload.currentModeId === "full-access"
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkForModeChange, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 6: Ask agent to write file again - should succeed WITHOUT permission request
|
||||||
|
// In full-access mode, the agent should just execute without asking.
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
const writePrompt2 =
|
||||||
|
"Write a file called mode-switch-test.txt with the content 'success'";
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, writePrompt2);
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
// Step 7: Verify file was created (mode switch worked)
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
const content = readFileSync(filePath, "utf-8");
|
||||||
|
expect(content).toBe("success");
|
||||||
|
|
||||||
|
// Verify no permission was requested in this second attempt
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasPermissionRequest = queue.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_permission_request" &&
|
||||||
|
m.agentId === agent.id
|
||||||
|
);
|
||||||
|
expect(hasPermissionRequest).toBe(false);
|
||||||
|
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
240000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("persistence flow", () => {
|
||||||
|
test(
|
||||||
|
"persists and resumes Codex agent with conversation history",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Persistence Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
const originalAgentId = agent.id;
|
||||||
|
|
||||||
|
// Send a message to generate some state
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Say 'state saved' and nothing else"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const afterMessage = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(afterMessage.status).toBe("idle");
|
||||||
|
|
||||||
|
// Get the timeline to verify we have messages
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const timelineItems: AgentTimelineItem[] = [];
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
timelineItems.push(m.payload.event.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least one assistant message
|
||||||
|
const assistantMessages = timelineItems.filter(
|
||||||
|
(item) => item.type === "assistant_message"
|
||||||
|
);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Get persistence handle from agent state
|
||||||
|
expect(afterMessage.persistence).toBeTruthy();
|
||||||
|
const persistence = afterMessage.persistence;
|
||||||
|
expect(persistence?.provider).toBe("codex");
|
||||||
|
expect(persistence?.sessionId).toBeTruthy();
|
||||||
|
// Codex uses conversationId in metadata for resumption
|
||||||
|
expect(
|
||||||
|
(persistence?.metadata as { conversationId?: string })?.conversationId
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// Delete the agent from the current session
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
|
||||||
|
// Verify agent deletion was confirmed (agent_deleted event was received)
|
||||||
|
const queue2 = ctx.client.getMessageQueue();
|
||||||
|
const hasDeletedEvent = queue2.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_deleted" && m.payload.agentId === originalAgentId
|
||||||
|
);
|
||||||
|
expect(hasDeletedEvent).toBe(true);
|
||||||
|
|
||||||
|
// Resume the agent using the persistence handle directly
|
||||||
|
// NOTE: Codex MCP doesn't implement listPersistedAgents() because conversations
|
||||||
|
// are stored internally by codex CLI. We resume by passing the persistence handle.
|
||||||
|
const resumedAgent = await ctx.client.resumeAgent(persistence!);
|
||||||
|
|
||||||
|
expect(resumedAgent.id).toBeTruthy();
|
||||||
|
expect(resumedAgent.status).toBe("idle");
|
||||||
|
expect(resumedAgent.cwd).toBe(cwd);
|
||||||
|
expect(resumedAgent.provider).toBe("codex");
|
||||||
|
|
||||||
|
// Note: AgentSnapshotPayload doesn't include timeline directly.
|
||||||
|
// Timeline events are streamed separately. The key verification
|
||||||
|
// is that we can send a follow-up message and the agent responds
|
||||||
|
// with awareness of the previous conversation context.
|
||||||
|
|
||||||
|
// Verify we can send another message to the resumed agent
|
||||||
|
// This proves the conversation context is preserved
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
resumedAgent.id,
|
||||||
|
"What did I ask you to say earlier?"
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterResume = await ctx.client.waitForAgentIdle(
|
||||||
|
resumedAgent.id,
|
||||||
|
120000
|
||||||
|
);
|
||||||
|
expect(afterResume.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify we got a response
|
||||||
|
const resumeQueue = ctx.client.getMessageQueue();
|
||||||
|
const hasResumeResponse = resumeQueue.some((m) => {
|
||||||
|
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return m.payload.event.item.type === "assistant_message";
|
||||||
|
});
|
||||||
|
expect(hasResumeResponse).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(resumedAgent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout for persistence E2E
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("timeline persistence across daemon restart", () => {
|
||||||
|
test(
|
||||||
|
"Codex agent timeline survives daemon restart",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// === Phase 1: Create agent and generate timeline items ===
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Restart Timeline Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Send a message to generate timeline items
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Say 'timeline test' and nothing else"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const afterMessage = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(afterMessage.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify we have timeline items before restart
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const timelineItems: AgentTimelineItem[] = [];
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
timelineItems.push(m.payload.event.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least one assistant message
|
||||||
|
const assistantMessages = timelineItems.filter(
|
||||||
|
(item) => item.type === "assistant_message"
|
||||||
|
);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Get persistence handle
|
||||||
|
const persistence = afterMessage.persistence;
|
||||||
|
expect(persistence).toBeTruthy();
|
||||||
|
expect(persistence?.provider).toBe("codex");
|
||||||
|
expect(persistence?.sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
// Record how many timeline items we had
|
||||||
|
const preRestartTimelineCount = timelineItems.length;
|
||||||
|
expect(preRestartTimelineCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// === Phase 2: Restart daemon ===
|
||||||
|
// Cleanup old context (stops daemon)
|
||||||
|
await ctx.cleanup();
|
||||||
|
|
||||||
|
// Create new daemon context (starts fresh daemon)
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
|
||||||
|
// === Phase 3: Resume agent and verify timeline is preserved ===
|
||||||
|
const resumedAgent = await ctx.client.resumeAgent(persistence!);
|
||||||
|
|
||||||
|
expect(resumedAgent.id).toBeTruthy();
|
||||||
|
expect(resumedAgent.status).toBe("idle");
|
||||||
|
expect(resumedAgent.provider).toBe("codex");
|
||||||
|
|
||||||
|
// Wait a moment for history events to be emitted
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Get timeline items that were emitted after resume
|
||||||
|
// Timeline items from history are sent as agent_stream_snapshot, not individual agent_stream
|
||||||
|
const resumeQueue = ctx.client.getMessageQueue();
|
||||||
|
const resumedTimelineItems: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
|
// First check for agent_stream_snapshot (batched history)
|
||||||
|
for (const m of resumeQueue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream_snapshot" &&
|
||||||
|
(m.payload as { agentId: string }).agentId === resumedAgent.id
|
||||||
|
) {
|
||||||
|
const events = (m.payload as { events: Array<{ event: { type: string; item?: AgentTimelineItem } }> }).events;
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.event.type === "timeline" && e.event.item) {
|
||||||
|
resumedTimelineItems.push(e.event.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for individual agent_stream events (in case they were sent that way)
|
||||||
|
for (const m of resumeQueue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === resumedAgent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
resumedTimelineItems.push(m.payload.event.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION: Timeline should NOT be empty after daemon restart
|
||||||
|
// This verifies that persisted history is loaded from disk (rollout files)
|
||||||
|
// when SESSION_HISTORY is empty due to daemon restart
|
||||||
|
expect(resumedTimelineItems.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify the original messages are present
|
||||||
|
const resumedAssistant = resumedTimelineItems.filter(
|
||||||
|
(item) => item.type === "assistant_message"
|
||||||
|
);
|
||||||
|
expect(resumedAssistant.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(resumedAgent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout for restart test
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("external Codex session import", () => {
|
||||||
|
test(
|
||||||
|
"imports external codex exec session and preserves conversation context",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const { execSync, spawn } = await import("child_process");
|
||||||
|
|
||||||
|
// Initialize git repo (Codex requires a trusted directory)
|
||||||
|
execSync("git init", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
writeFileSync(path.join(cwd, "README.md"), "# Test\n");
|
||||||
|
execSync("git add .", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// === STEP 1: Run external codex exec with a memorable number ===
|
||||||
|
// We use spawn so we can send input and capture output
|
||||||
|
|
||||||
|
// Use a memorable number that we'll ask about later
|
||||||
|
const magicNumber = 69;
|
||||||
|
const prompt = `Remember this number: ${magicNumber}. Just confirm you've remembered it and reply with a single short sentence.`;
|
||||||
|
|
||||||
|
// Spawn codex exec and capture stdout to get session ID
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let codexOutput = "";
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
codexProcess.kill();
|
||||||
|
reject(new Error("Codex exec timeout after 120 seconds"));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
const codexProcess = spawn("codex", ["exec", prompt], {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Ensure full-access mode to avoid permission prompts
|
||||||
|
CODEX_SANDBOX: "danger-full-access",
|
||||||
|
CODEX_APPROVAL_POLICY: "never",
|
||||||
|
},
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
codexProcess.stdout.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
codexOutput += text;
|
||||||
|
|
||||||
|
// Look for session ID in output
|
||||||
|
// Format: "session id: 019b5ea3-25d5-7202-bd06-6b1db405e505"
|
||||||
|
const match = text.match(/session id:\s*([0-9a-f-]+)/i);
|
||||||
|
if (match) {
|
||||||
|
sessionId = match[1];
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
codexProcess.stderr.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
|
||||||
|
// Session ID might also appear in stderr
|
||||||
|
const match = text.match(/session id:\s*([0-9a-f-]+)/i);
|
||||||
|
if (match && !sessionId) {
|
||||||
|
sessionId = match[1];
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
codexProcess.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (code === 0 || sessionId) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`codex exec failed with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
codexProcess.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify we captured the session ID
|
||||||
|
expect(sessionId).not.toBeNull();
|
||||||
|
expect(sessionId).toMatch(/^[0-9a-f-]+$/);
|
||||||
|
|
||||||
|
// === STEP 2: Find the transcript file for this session ===
|
||||||
|
// Codex stores transcripts at ~/.codex/sessions/**/*-{sessionId}.jsonl
|
||||||
|
const codexHome = process.env.CODEX_HOME || path.join(tmpdir(), "..", "..", "home", process.env.USER || "", ".codex");
|
||||||
|
const actualCodexHome = path.join(process.env.HOME || "", ".codex");
|
||||||
|
const sessionsDir = path.join(actualCodexHome, "sessions");
|
||||||
|
|
||||||
|
// Find the transcript file
|
||||||
|
function findTranscriptFile(dir: string, targetSessionId: string): string | null {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const found = findTranscriptFile(fullPath, targetSessionId);
|
||||||
|
if (found) return found;
|
||||||
|
} else if (entry.isFile() && fullPath.endsWith(`-${targetSessionId}.jsonl`)) {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist or not readable
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptFile = findTranscriptFile(sessionsDir, sessionId!);
|
||||||
|
|
||||||
|
// Verify transcript file exists
|
||||||
|
expect(transcriptFile).not.toBeNull();
|
||||||
|
expect(existsSync(transcriptFile!)).toBe(true);
|
||||||
|
|
||||||
|
// Read and verify transcript has content
|
||||||
|
const transcriptContent = readFileSync(transcriptFile!, "utf-8");
|
||||||
|
|
||||||
|
expect(transcriptContent.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// === STEP 3: Import this session into the daemon ===
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "External Session Import Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
extra: {
|
||||||
|
codex: {
|
||||||
|
experimental_resume: transcriptFile,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// === STEP 4: Ask the daemon agent about the number ===
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
expect(finalState.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// === STEP 5: Verify the response contains the magic number ===
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const assistantMessages: string[] = [];
|
||||||
|
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
assistantMessages.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullResponse = assistantMessages.join("");
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION: The response should contain the magic number
|
||||||
|
// This proves the daemon agent successfully loaded the external session's context
|
||||||
|
expect(fullResponse).toContain(String(magicNumber));
|
||||||
|
|
||||||
|
// === STEP 6: Verify history was present when importing ===
|
||||||
|
// Check that we received history timeline items (from the external session)
|
||||||
|
// These would be in agent_stream_snapshot if history was replayed
|
||||||
|
|
||||||
|
// Note: The experimental_resume feature loads history directly into Codex,
|
||||||
|
// so we may not see individual history items streamed back. The key test
|
||||||
|
// is that the agent can recall the number, which proves context was preserved.
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout for external session test
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"fails gracefully when resuming non-existent external session",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Try to create agent with a non-existent transcript file
|
||||||
|
const fakeTranscriptFile = path.join(cwd, "non-existent-session.jsonl");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Non-existent Session Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
extra: {
|
||||||
|
codex: {
|
||||||
|
experimental_resume: fakeTranscriptFile,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// The agent should still work, just without the resume context
|
||||||
|
// Send a simple message to verify it's functional
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else.");
|
||||||
|
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 60000);
|
||||||
|
|
||||||
|
// Agent should complete (possibly with Codex warning about missing file,
|
||||||
|
// but should still function)
|
||||||
|
expect(["idle", "error"]).toContain(finalState.status);
|
||||||
|
|
||||||
|
// Verify we got some response
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const hasResponse = queue.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline" &&
|
||||||
|
m.payload.event.item.type === "assistant_message"
|
||||||
|
);
|
||||||
|
expect(hasResponse).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
120000 // 2 minute timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("Claude persisted agent import", () => {
|
||||||
|
test("filters internal warmup entries from persisted Claude history", async () => {
|
||||||
|
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
const claudeConfigDir = mkdtempSync(path.join(tmpdir(), "claude-config-"));
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
||||||
|
|
||||||
|
const projectDir = path.join(claudeConfigDir, "projects", "test-project");
|
||||||
|
mkdirSync(projectDir, { recursive: true });
|
||||||
|
|
||||||
|
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const cwd = "/tmp/claude-import-test";
|
||||||
|
const historyLines = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
isSidechain: true,
|
||||||
|
sessionId,
|
||||||
|
cwd,
|
||||||
|
message: { role: "user", content: "Warmup" },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
sessionId,
|
||||||
|
cwd,
|
||||||
|
message: { role: "user", content: "Real task prompt" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const historyPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
writeFileSync(historyPath, `${historyLines.join("\n")}\n`, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const persisted =
|
||||||
|
await ctx.daemon.daemon.agentManager.listPersistedAgents();
|
||||||
|
const claudeEntry = persisted.find((item) => item.sessionId === sessionId);
|
||||||
|
|
||||||
|
expect(claudeEntry).toBeTruthy();
|
||||||
|
expect(claudeEntry?.title).toBe("Real task prompt");
|
||||||
|
|
||||||
|
const timelineTexts = (claudeEntry?.timeline ?? [])
|
||||||
|
.map((item) => {
|
||||||
|
if (item.type === "user_message" || item.type === "assistant_message") {
|
||||||
|
return item.text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((text): text is string => typeof text === "string");
|
||||||
|
|
||||||
|
expect(timelineTexts).toContain("Real task prompt");
|
||||||
|
expect(timelineTexts).not.toContain("Warmup");
|
||||||
|
} finally {
|
||||||
|
if (previousClaudeConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
||||||
|
}
|
||||||
|
rmSync(claudeConfigDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("Codex persisted agent import", () => {
|
||||||
|
test("lists Codex sessions from rollout files", async () => {
|
||||||
|
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
||||||
|
const codexSessionDir = mkdtempSync(path.join(tmpdir(), "codex-session-"));
|
||||||
|
process.env.CODEX_SESSION_DIR = codexSessionDir;
|
||||||
|
|
||||||
|
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const cwd = "/tmp/codex-import-test";
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const rolloutPath = path.join(codexSessionDir, `rollout-${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now,
|
||||||
|
type: "session_meta",
|
||||||
|
payload: { id: sessionId, timestamp: now, cwd },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now,
|
||||||
|
type: "response_item",
|
||||||
|
payload: {
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_text", text: "Import this Codex session" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: now,
|
||||||
|
type: "response_item",
|
||||||
|
payload: {
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Codex import ready" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
writeFileSync(rolloutPath, `${lines.join("\n")}\n`, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const persisted =
|
||||||
|
await ctx.daemon.daemon.agentManager.listPersistedAgents();
|
||||||
|
const codexEntry = persisted.find(
|
||||||
|
(item) => item.provider === "codex" && item.sessionId === sessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(codexEntry).toBeTruthy();
|
||||||
|
expect(codexEntry?.cwd).toBe(cwd);
|
||||||
|
|
||||||
|
const timelineTexts = (codexEntry?.timeline ?? [])
|
||||||
|
.map((item) => {
|
||||||
|
if (item.type === "user_message" || item.type === "assistant_message") {
|
||||||
|
return item.text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((text): text is string => typeof text === "string");
|
||||||
|
|
||||||
|
expect(timelineTexts).toContain("Import this Codex session");
|
||||||
|
expect(timelineTexts).toContain("Codex import ready");
|
||||||
|
} finally {
|
||||||
|
if (previousCodexSessionDir === undefined) {
|
||||||
|
delete process.env.CODEX_SESSION_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
|
||||||
|
}
|
||||||
|
rmSync(codexSessionDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("Claude session persistence", () => {
|
||||||
|
test(
|
||||||
|
"persists and resumes Claude agent with conversation history (remembers number)",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Use a memorable number that we'll ask about later
|
||||||
|
const magicNumber = 69;
|
||||||
|
|
||||||
|
// === STEP 1: Create Claude agent and have it remember a number ===
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Persistence Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
expect(agent.provider).toBe("claude");
|
||||||
|
|
||||||
|
// === STEP 2: Ask it to remember the number ===
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
`Remember this number: ${magicNumber}. Just confirm you've remembered it and reply with a single short sentence.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterRemember = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(afterRemember.status).toBe("idle");
|
||||||
|
expect(afterRemember.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify we got a confirmation response
|
||||||
|
let queue = ctx.client.getMessageQueue();
|
||||||
|
const confirmationMessages: string[] = [];
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
confirmationMessages.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const confirmationResponse = confirmationMessages.join("");
|
||||||
|
|
||||||
|
expect(confirmationResponse.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// === STEP 3: Get persistence handle and delete agent ===
|
||||||
|
expect(afterRemember.persistence).toBeTruthy();
|
||||||
|
const persistence = afterRemember.persistence;
|
||||||
|
expect(persistence?.provider).toBe("claude");
|
||||||
|
expect(persistence?.sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
// Delete the agent
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
|
||||||
|
// === STEP 4: Resume the agent using persistence handle ===
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
const resumedAgent = await ctx.client.resumeAgent(persistence!);
|
||||||
|
|
||||||
|
expect(resumedAgent.id).toBeTruthy();
|
||||||
|
expect(resumedAgent.status).toBe("idle");
|
||||||
|
expect(resumedAgent.provider).toBe("claude");
|
||||||
|
|
||||||
|
// === STEP 5: Ask about the remembered number ===
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
resumedAgent.id,
|
||||||
|
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterRecall = await ctx.client.waitForAgentIdle(resumedAgent.id, 120000);
|
||||||
|
expect(afterRecall.status).toBe("idle");
|
||||||
|
expect(afterRecall.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// === STEP 6: Verify the response contains the magic number ===
|
||||||
|
queue = ctx.client.getMessageQueue();
|
||||||
|
const recallMessages: string[] = [];
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === resumedAgent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
recallMessages.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fullResponse = recallMessages.join("");
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION: The response should contain the magic number
|
||||||
|
// This proves the Claude agent successfully preserved conversation context
|
||||||
|
expect(fullResponse).toContain(String(magicNumber));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(resumedAgent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout for multiple Claude API calls
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { beforeAll, afterAll } from "vitest";
|
||||||
|
import { mkdtempSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import { agentConfigs } from "./agent-configs.js";
|
||||||
|
|
||||||
|
// Re-export for backward compatibility - prefer using agentConfigs instead
|
||||||
|
export const CODEX_TEST_MODEL = agentConfigs.codex.model;
|
||||||
|
export const CODEX_TEST_REASONING_EFFORT = agentConfigs.codex.reasoningEffort;
|
||||||
|
|
||||||
|
// Re-export agent configs
|
||||||
|
export {
|
||||||
|
agentConfigs,
|
||||||
|
getFullAccessConfig,
|
||||||
|
getAskModeConfig,
|
||||||
|
allProviders,
|
||||||
|
type AgentProvider,
|
||||||
|
type AgentTestConfig,
|
||||||
|
} from "./agent-configs.js";
|
||||||
|
|
||||||
|
export function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared daemon context for all e2e tests
|
||||||
|
let sharedCtx: DaemonTestContext | null = null;
|
||||||
|
|
||||||
|
export function getTestContext(): DaemonTestContext {
|
||||||
|
if (!sharedCtx) {
|
||||||
|
throw new Error("Test context not initialized. Did you call setupDaemonE2E()?");
|
||||||
|
}
|
||||||
|
return sharedCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupDaemonE2E(): void {
|
||||||
|
beforeAll(async () => {
|
||||||
|
sharedCtx = await createDaemonTestContext();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (sharedCtx) {
|
||||||
|
await sharedCtx.cleanup();
|
||||||
|
sharedCtx = null;
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("Claude agent streaming text integrity", () => {
|
||||||
|
test(
|
||||||
|
"assistant_message text is coherent and not garbled during streaming",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Claude agent
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Streaming Text Integrity Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("claude");
|
||||||
|
|
||||||
|
// Send a message that should produce a longer, coherent response
|
||||||
|
// The agent should complete a sentence with proper grammar
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Please complete this sentence with exactly one more sentence: 'The quick brown fox jumps over the lazy dog.' Write a follow-up sentence about what the fox did next. Reply with just the two sentences, nothing else."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for agent to complete
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
expect(finalState.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Collect all assistant_message timeline events in order
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const assistantChunks: string[] = [];
|
||||||
|
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
assistantChunks.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have received at least one assistant message chunk
|
||||||
|
expect(assistantChunks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Concatenate all chunks to form the complete response
|
||||||
|
const fullResponse = assistantChunks.join("");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 1: Response should not be empty
|
||||||
|
expect(fullResponse.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 2: Response should contain coherent English text
|
||||||
|
// Check for garbled patterns from the bug report:
|
||||||
|
// - "wasd" (random characters in word)
|
||||||
|
// - "passesd" (double letters incorrectly)
|
||||||
|
// - words cut off mid-word and merged with other words
|
||||||
|
|
||||||
|
// Check that the response contains real words and proper sentence structure
|
||||||
|
// A garbled response like "The agent wasd. my an a newdex" would fail these checks
|
||||||
|
|
||||||
|
// The response should contain "fox" or "dog" since we asked about them
|
||||||
|
const lowerResponse = fullResponse.toLowerCase();
|
||||||
|
const containsRelevantContent =
|
||||||
|
lowerResponse.includes("fox") ||
|
||||||
|
lowerResponse.includes("dog") ||
|
||||||
|
lowerResponse.includes("quick") ||
|
||||||
|
lowerResponse.includes("brown") ||
|
||||||
|
lowerResponse.includes("lazy") ||
|
||||||
|
lowerResponse.includes("jumps");
|
||||||
|
|
||||||
|
expect(containsRelevantContent).toBe(true);
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 3: Check for garbled text patterns
|
||||||
|
// These patterns indicate text corruption during streaming
|
||||||
|
const garbledPatterns = [
|
||||||
|
/\w{2,}d\.\s+[a-z]+\s+[a-z]+\s+[a-z]+d/, // "wasd. my an a ...d" pattern
|
||||||
|
/\b\w+sd\b/, // words ending in "sd" like "passesd", "wasd"
|
||||||
|
/\b\w+d\s+\w+d\s+\w+d\b/, // multiple consecutive words ending in "d"
|
||||||
|
/[a-z]{10,}/, // very long "words" that are actually merged text
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of garbledPatterns) {
|
||||||
|
const match = fullResponse.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
|
||||||
|
}
|
||||||
|
// Note: We log but don't fail on these patterns as they might occur in valid text
|
||||||
|
// The real test is whether the response is semantically coherent
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 4: Each individual chunk should not start/end mid-word in a corrupted way
|
||||||
|
// Check that we don't have incomplete Unicode or obviously broken text
|
||||||
|
for (let i = 0; i < assistantChunks.length; i++) {
|
||||||
|
const chunk = assistantChunks[i];
|
||||||
|
|
||||||
|
// Chunks should not contain null bytes or other corruption
|
||||||
|
expect(chunk).not.toMatch(/\x00/);
|
||||||
|
|
||||||
|
// Chunks should be valid UTF-8 (no replacement characters unless intentional)
|
||||||
|
expect(chunk).not.toMatch(/\uFFFD/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 5: Verify sentence completeness
|
||||||
|
// The response should contain at least one period (sentence ending)
|
||||||
|
expect(fullResponse).toMatch(/\./);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000 // 3 minute timeout for Claude API call
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("Claude agent streaming text integrity - long running", () => {
|
||||||
|
test(
|
||||||
|
"streaming chunks remain coherent after multiple back-and-forth messages",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Claude agent with bypassPermissions mode to avoid permission prompts
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Long Running Streaming Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("claude");
|
||||||
|
|
||||||
|
// === MESSAGE 1: Establish conversation context ===
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Remember the number 42. Just confirm you remember it."
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(state.status).toBe("idle");
|
||||||
|
expect(state.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// === MESSAGE 2: Build on conversation ===
|
||||||
|
ctx.client.clearMessageQueue(); // Clear queue to isolate message 2
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Now remember the word 'elephant'. Just confirm you remember both the number and the word."
|
||||||
|
);
|
||||||
|
|
||||||
|
state = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(state.status).toBe("idle");
|
||||||
|
expect(state.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// === MESSAGE 3: This is where the bug was reported to manifest ===
|
||||||
|
// Clear queue so we can capture streaming chunks for message 3 only
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Write a complete sentence using both the number (42) and the word (elephant) you remembered. The sentence should be grammatically correct English."
|
||||||
|
);
|
||||||
|
|
||||||
|
state = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
expect(state.status).toBe("idle");
|
||||||
|
expect(state.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Collect all assistant_message timeline events from message 3
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const assistantChunks: string[] = [];
|
||||||
|
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
assistantChunks.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have received at least one assistant message chunk
|
||||||
|
expect(assistantChunks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Concatenate all chunks to form the complete response
|
||||||
|
const fullResponse = assistantChunks.join("");
|
||||||
|
|
||||||
|
|
||||||
|
for (let i = 0; i < assistantChunks.length; i++) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 1: Response should contain expected content
|
||||||
|
const lowerResponse = fullResponse.toLowerCase();
|
||||||
|
const containsNumber = lowerResponse.includes("42");
|
||||||
|
const containsWord = lowerResponse.includes("elephant");
|
||||||
|
|
||||||
|
|
||||||
|
expect(containsNumber).toBe(true);
|
||||||
|
expect(containsWord).toBe(true);
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 2: Check for garbled text patterns
|
||||||
|
// These patterns indicate chunks being incorrectly split/merged
|
||||||
|
// Pattern from bug report: "acheck error" instead of "a typecheck error" (missing "type")
|
||||||
|
|
||||||
|
// Check consecutive chunks for suspicious splits
|
||||||
|
for (let i = 0; i < assistantChunks.length - 1; i++) {
|
||||||
|
const current = assistantChunks[i];
|
||||||
|
const next = assistantChunks[i + 1];
|
||||||
|
|
||||||
|
// Look for a chunk ending with a letter followed by a chunk starting with
|
||||||
|
// a letter that wouldn't make sense together (e.g., "a" + "check")
|
||||||
|
const currentEndsWithLetter = /[a-zA-Z]$/.test(current);
|
||||||
|
const nextStartsWithLetter = /^[a-zA-Z]/.test(next);
|
||||||
|
|
||||||
|
if (currentEndsWithLetter && nextStartsWithLetter) {
|
||||||
|
// This could be legitimate (word continues) or a split issue
|
||||||
|
// Log for debugging
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 3: Check for UTF-8 corruption
|
||||||
|
for (const chunk of assistantChunks) {
|
||||||
|
expect(chunk).not.toMatch(/\x00/); // No null bytes
|
||||||
|
expect(chunk).not.toMatch(/\uFFFD/); // No replacement characters
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION 4: The full response should be valid English
|
||||||
|
// Check that the response has proper word spacing
|
||||||
|
const wordPattern = /\b[a-zA-Z]+\b/g;
|
||||||
|
const words = fullResponse.match(wordPattern) || [];
|
||||||
|
expect(words.length).toBeGreaterThan(3); // Should have multiple words
|
||||||
|
|
||||||
|
// Check for improperly concatenated words (very long "words" that shouldn't exist)
|
||||||
|
const suspiciouslyLongWords = words.filter(w => w.length > 20);
|
||||||
|
if (suspiciouslyLongWords.length > 0) {
|
||||||
|
|
||||||
|
}
|
||||||
|
// Allow some technical words but flag excessive length
|
||||||
|
expect(suspiciouslyLongWords.filter(w => w.length > 30).length).toBe(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
300000 // 5 minute timeout for multiple Claude API calls
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("Claude agent overlapping stream() calls race condition", () => {
|
||||||
|
test(
|
||||||
|
"interrupting message should produce coherent text without garbling from race condition",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
// Create Claude agent with bypassPermissions mode
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Overlapping Streams Race Condition Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("claude");
|
||||||
|
|
||||||
|
// === MESSAGE 1: Start a long-running prompt that will be interrupted ===
|
||||||
|
|
||||||
|
// Record queue position BEFORE message 1 to find the cutoff point later
|
||||||
|
const msg1StartPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
// Use sendMessage but don't await waitForAgentIdle - let it run
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Write a very detailed 500 word essay about the history of computing, starting from the earliest mechanical computers through modern quantum computing. Include specific dates, inventors, and technological milestones."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait a short time for Turn 1 to start streaming (but not finish)
|
||||||
|
// This ensures forwardPromptEvents() is actively running
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// === MESSAGE 2: Immediately send another message to interrupt ===
|
||||||
|
// This triggers the race condition where Turn 2's forwardPromptEvents
|
||||||
|
// resets streamedAssistantTextThisTurn while Turn 1 is still reading it
|
||||||
|
|
||||||
|
// Record queue position BEFORE message 2 to find message 2 chunks
|
||||||
|
const msg2StartPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Stop. Just say exactly: 'Hello world from interrupted message'"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for Turn 2 to complete - use a manual polling approach
|
||||||
|
// We need to wait for: running -> idle (after msg2's user_message)
|
||||||
|
|
||||||
|
const maxWaitMs = 120000;
|
||||||
|
const pollIntervalMs = 500;
|
||||||
|
const startTime = Date.now();
|
||||||
|
let lastState: AgentSnapshotPayload | null = null;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
// Check agent_state messages in the queue
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
|
||||||
|
// Look for pattern: user_message (msg2) -> ... -> running -> ... -> idle/error
|
||||||
|
let sawMsg2UserMessage = false;
|
||||||
|
let sawRunningAfterMsg2 = false;
|
||||||
|
let sawIdleAfterRunning = false;
|
||||||
|
|
||||||
|
for (let i = msg2StartPosition; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (
|
||||||
|
msg.type === "agent_stream" &&
|
||||||
|
msg.payload.agentId === agent.id &&
|
||||||
|
msg.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = msg.payload.event.item;
|
||||||
|
if (item.type === "user_message" && (item.text as string)?.includes("Hello world")) {
|
||||||
|
sawMsg2UserMessage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
||||||
|
if (sawMsg2UserMessage && msg.payload.status === "running") {
|
||||||
|
sawRunningAfterMsg2 = true;
|
||||||
|
}
|
||||||
|
if (sawRunningAfterMsg2 && (msg.payload.status === "idle" || msg.payload.status === "error")) {
|
||||||
|
sawIdleAfterRunning = true;
|
||||||
|
lastState = msg.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawIdleAfterRunning) {
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lastState).not.toBeNull();
|
||||||
|
expect(lastState!.status).toBe("idle");
|
||||||
|
expect(lastState!.lastError).toBeUndefined();
|
||||||
|
|
||||||
|
// Collect assistant_message chunks from message 2 only (after msg2StartPosition)
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const assistantChunks: string[] = [];
|
||||||
|
|
||||||
|
// Debug: dump all events from queue
|
||||||
|
|
||||||
|
for (let i = 0; i < queue.length; i++) {
|
||||||
|
const m = queue[i];
|
||||||
|
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
|
||||||
|
const event = m.payload.event;
|
||||||
|
if (event.type === "timeline") {
|
||||||
|
const item = event.item;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
} else if (m.type === "agent_state" && m.payload.id === agent.id) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user_message for message 2 to mark the boundary
|
||||||
|
let foundMsg2UserMessage = false;
|
||||||
|
|
||||||
|
for (let i = msg2StartPosition; i < queue.length; i++) {
|
||||||
|
const m = queue[i];
|
||||||
|
|
||||||
|
// Look for our user message to mark the start of message 2 context
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "user_message" && (item.text as string)?.includes("Hello world")) {
|
||||||
|
foundMsg2UserMessage = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
// Collect assistant messages after we found the user message
|
||||||
|
if (foundMsg2UserMessage && item.type === "assistant_message" && item.text) {
|
||||||
|
assistantChunks.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (let i = 0; i < assistantChunks.length; i++) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have received at least one assistant message chunk
|
||||||
|
expect(assistantChunks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Concatenate all chunks
|
||||||
|
const fullResponse = assistantChunks.join("");
|
||||||
|
|
||||||
|
// CRITICAL ASSERTION: Response should contain coherent text
|
||||||
|
// If there's a race condition with flag corruption, we might get:
|
||||||
|
// - Missing chunks (suppression applied incorrectly)
|
||||||
|
// - Duplicate chunks (suppression NOT applied when it should be)
|
||||||
|
// - Garbled/mixed text from Turn 1 and Turn 2
|
||||||
|
|
||||||
|
// Check for basic coherence - should have recognizable words
|
||||||
|
const wordPattern = /\b[a-zA-Z]+\b/g;
|
||||||
|
const words = fullResponse.match(wordPattern) || [];
|
||||||
|
|
||||||
|
expect(words.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check for UTF-8 corruption
|
||||||
|
for (const chunk of assistantChunks) {
|
||||||
|
expect(chunk).not.toMatch(/\x00/); // No null bytes
|
||||||
|
expect(chunk).not.toMatch(/\uFFFD/); // No replacement characters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspiciously long "words" that indicate missing spaces/garbling
|
||||||
|
const suspiciouslyLongWords = words.filter(w => w.length > 30);
|
||||||
|
if (suspiciouslyLongWords.length > 0) {
|
||||||
|
|
||||||
|
}
|
||||||
|
expect(suspiciouslyLongWords.length).toBe(0);
|
||||||
|
|
||||||
|
// CRITICAL: Verify the response is for message 2, not message 1
|
||||||
|
// Message 2 asked for "Hello world from interrupted message"
|
||||||
|
// If we see extensive content about "history of computing", that's race condition corruption
|
||||||
|
const lowerResponse = fullResponse.toLowerCase();
|
||||||
|
const containsComputingContent =
|
||||||
|
lowerResponse.includes("mechanical") ||
|
||||||
|
lowerResponse.includes("quantum") ||
|
||||||
|
lowerResponse.includes("inventor") ||
|
||||||
|
lowerResponse.includes("eniac") ||
|
||||||
|
lowerResponse.includes("babbage");
|
||||||
|
|
||||||
|
if (containsComputingContent) {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
// This MUST fail if we got message 1's response instead of message 2's
|
||||||
|
expect(containsComputingContent).toBe(false);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000 // 3 minute timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"sending message while agent is executing a tool call",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testStart = Date.now();
|
||||||
|
const log = (msg: string) => console.error(`[TEST t=${Date.now() - testStart}ms] ${msg}`);
|
||||||
|
|
||||||
|
log(`creating agent`);
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Interrupt During Tool Call Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
log(`agent created: ${agent.id}`);
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
const startPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
// Subscribe to all messages for logging
|
||||||
|
const unsubscribe = ctx.client.on((event) => {
|
||||||
|
if (event.type === "agent_state" && event.agentId === agent.id) {
|
||||||
|
log(`[EVENT] agent_state: status=${event.payload.status}`);
|
||||||
|
} else if (event.type === "agent_stream" && event.agentId === agent.id) {
|
||||||
|
const evt = event.event;
|
||||||
|
if (evt.type === "timeline") {
|
||||||
|
const item = evt.item as any;
|
||||||
|
const desc = item.type === "tool_call"
|
||||||
|
? `tool_call:${item.name} result=${item.result ? 'yes' : 'no'}`
|
||||||
|
: `${item.type}${item.text ? `:${item.text.substring(0, 30)}` : ''}`;
|
||||||
|
log(`[EVENT] timeline: ${desc}`);
|
||||||
|
} else {
|
||||||
|
log(`[EVENT] stream: ${evt.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a message that triggers a long-running tool call
|
||||||
|
log(`sending first message`);
|
||||||
|
await ctx.client.sendMessage(agent.id, "Execute this exact bash command and wait for it to complete: sleep 30");
|
||||||
|
log(`first message sent`);
|
||||||
|
|
||||||
|
// Wait for agent to actually start executing a tool call (not just "running" status)
|
||||||
|
let sawToolCall = false;
|
||||||
|
let toolCallStartTime: number | null = null;
|
||||||
|
const waitStartTime = Date.now();
|
||||||
|
while (Date.now() - waitStartTime < 15000) {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
|
const msg = queue[i];
|
||||||
|
if (
|
||||||
|
msg.type === "agent_stream" &&
|
||||||
|
msg.payload.agentId === agent.id &&
|
||||||
|
msg.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const itemType = msg.payload.event.item.type;
|
||||||
|
const itemName = (msg.payload.event.item as any).name || "";
|
||||||
|
if (itemType === "tool_call" && itemName.toLowerCase().includes("bash")) {
|
||||||
|
const toolCall = msg.payload.event.item as any;
|
||||||
|
// We want to see the tool call START (no result yet)
|
||||||
|
if (!toolCall.result) {
|
||||||
|
log(`saw bash tool call START: ${itemName}`);
|
||||||
|
sawToolCall = true;
|
||||||
|
toolCallStartTime = Date.now();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sawToolCall) break;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
log(`sawToolCall=${sawToolCall}`);
|
||||||
|
expect(sawToolCall).toBe(true);
|
||||||
|
|
||||||
|
// Give the tool call time to actually start executing
|
||||||
|
log(`waiting 2s for tool to be mid-execution...`);
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
// Check if agent is still running
|
||||||
|
const queueBeforeStop = ctx.client.getMessageQueue();
|
||||||
|
let lastStatus = "unknown";
|
||||||
|
for (const msg of queueBeforeStop) {
|
||||||
|
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
|
||||||
|
lastStatus = msg.payload.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`agent status before Stop: ${lastStatus}`);
|
||||||
|
|
||||||
|
// Send an interrupting message
|
||||||
|
const stopSentAt = Date.now();
|
||||||
|
log(`sending Stop message`);
|
||||||
|
await ctx.client.sendMessage(agent.id, "Stop");
|
||||||
|
log(`Stop message sent`);
|
||||||
|
|
||||||
|
// Agent should go idle within 5 seconds after interrupt
|
||||||
|
log(`waiting for idle...`);
|
||||||
|
const finalState = await ctx.client.waitForAgentIdle(agent.id, 10000);
|
||||||
|
const idleReceivedAt = Date.now();
|
||||||
|
log(`got idle state: ${finalState.status} (took ${idleReceivedAt - stopSentAt}ms after Stop)`);
|
||||||
|
expect(finalState.status).toBe("idle");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
unsubscribe();
|
||||||
|
log(`deleting agent`);
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
log(`agent deleted`);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"rapid sequential messages to same agent",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Rapid Sequential Messages Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Send 3 messages in rapid succession without waiting
|
||||||
|
|
||||||
|
const startPosition = ctx.client.getMessageQueue().length;
|
||||||
|
|
||||||
|
const msg1 = "Say: MESSAGE_ONE";
|
||||||
|
const msg2 = "Say: MESSAGE_TWO";
|
||||||
|
const msg3 = "Say: MESSAGE_THREE";
|
||||||
|
|
||||||
|
// Helper to count user messages in queue
|
||||||
|
const countUserMessages = (): number => {
|
||||||
|
let count = 0;
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
|
const m = queue[i];
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline" &&
|
||||||
|
m.payload.event.item.type === "user_message"
|
||||||
|
) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send all 3 messages
|
||||||
|
await ctx.client.sendMessage(agent.id, msg1);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, msg2);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, msg3);
|
||||||
|
|
||||||
|
// First, wait until all 3 user messages are recorded
|
||||||
|
|
||||||
|
const userMsgWaitStart = Date.now();
|
||||||
|
while (Date.now() - userMsgWaitStart < 30000) {
|
||||||
|
const count = countUserMessages();
|
||||||
|
|
||||||
|
if (count >= 3) break;
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUserMsgCount = countUserMessages();
|
||||||
|
|
||||||
|
// Now wait for agent to become idle after processing all 3 messages
|
||||||
|
// We need to see at least 3 running transitions to know all messages were processed
|
||||||
|
|
||||||
|
const waitStart = Date.now();
|
||||||
|
let runningCount = 0;
|
||||||
|
let finalState: AgentSnapshotPayload | null = null;
|
||||||
|
|
||||||
|
while (Date.now() - waitStart < 120000) {
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
let currentRunningCount = 0;
|
||||||
|
let lastState: AgentSnapshotPayload | null = null;
|
||||||
|
|
||||||
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
|
const m = queue[i];
|
||||||
|
if (m.type === "agent_state" && m.payload.id === agent.id) {
|
||||||
|
if (m.payload.status === "running") currentRunningCount++;
|
||||||
|
lastState = m.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCount = currentRunningCount;
|
||||||
|
|
||||||
|
// Need to have seen at least 3 running states (one per message) and end up idle
|
||||||
|
if (runningCount >= 3 && lastState && lastState.status === "idle") {
|
||||||
|
finalState = lastState;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Analyze what happened
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const userMessages: string[] = [];
|
||||||
|
const assistantMessages: string[] = [];
|
||||||
|
const stateChanges: string[] = [];
|
||||||
|
|
||||||
|
for (let i = startPosition; i < queue.length; i++) {
|
||||||
|
const m = queue[i];
|
||||||
|
if (m.type === "agent_state" && m.payload.id === agent.id) {
|
||||||
|
stateChanges.push(m.payload.status);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline"
|
||||||
|
) {
|
||||||
|
const item = m.payload.event.item;
|
||||||
|
if (item.type === "user_message") {
|
||||||
|
userMessages.push((item.text as string) || "");
|
||||||
|
}
|
||||||
|
if (item.type === "assistant_message" && item.text) {
|
||||||
|
assistantMessages.push(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// All 3 user messages should have been recorded
|
||||||
|
expect(userMessages.length).toBe(3);
|
||||||
|
|
||||||
|
// Agent should have responded (at least to the final message)
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The last turn should have completed successfully (not failed due to race condition)
|
||||||
|
const lastResponse = assistantMessages[assistantMessages.length - 1]?.toLowerCase() || "";
|
||||||
|
|
||||||
|
// Verify we got a proper turn_completed event (not turn_failed from race condition)
|
||||||
|
const turnCompletedEvents = queue.filter((m, i) =>
|
||||||
|
i >= startPosition &&
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "turn_completed"
|
||||||
|
);
|
||||||
|
const turnFailedEvents = queue.filter((m, i) =>
|
||||||
|
i >= startPosition &&
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "turn_failed"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// The final turn should complete successfully, not fail
|
||||||
|
expect(turnCompletedEvents.length).toBeGreaterThan(0);
|
||||||
|
// We might have some turn_failed from interrupted turns, but the last turn should succeed
|
||||||
|
expect(turnFailedEvents.length).toBe(0);
|
||||||
|
|
||||||
|
// The response should mention "three" since that was the last message sent
|
||||||
|
const combinedResponse = assistantMessages.join(" ").toLowerCase();
|
||||||
|
|
||||||
|
expect(combinedResponse).toContain("three");
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||||
|
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||||
|
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||||
|
|
||||||
|
describe("daemon E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("tool call structure", () => {
|
||||||
|
// Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version
|
||||||
|
function extractToolCalls(
|
||||||
|
queue: SessionOutboundMessage[],
|
||||||
|
agentId: string
|
||||||
|
): AgentTimelineItem[] {
|
||||||
|
const byCallId = new Map<string, AgentTimelineItem>();
|
||||||
|
const noCallId: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
|
for (const m of queue) {
|
||||||
|
if (
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agentId &&
|
||||||
|
m.payload.event.type === "timeline" &&
|
||||||
|
m.payload.event.item.type === "tool_call"
|
||||||
|
) {
|
||||||
|
const tc = m.payload.event.item;
|
||||||
|
if (tc.callId) {
|
||||||
|
byCallId.set(tc.callId, tc);
|
||||||
|
} else {
|
||||||
|
noCallId.push(tc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byCallId.values(), ...noCallId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to log tool call structure in a consistent format
|
||||||
|
function logToolCall(prefix: string, tc: AgentTimelineItem): void {
|
||||||
|
if (tc.type !== "tool_call") return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Claude agent: Read tool",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Read Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CLAUDE_READ", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Read");
|
||||||
|
expect(readCall).toBeDefined();
|
||||||
|
expect(readCall?.name).toBe("Read");
|
||||||
|
expect(readCall?.input).toBeDefined();
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Claude agent: Bash tool",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Bash Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Run `echo hello` and tell me what it outputs. Be brief."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CLAUDE_BASH", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bashCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Bash");
|
||||||
|
expect(bashCall).toBeDefined();
|
||||||
|
expect(bashCall?.name).toBe("Bash");
|
||||||
|
expect(bashCall?.input).toBeDefined();
|
||||||
|
// Command text should be in input.command
|
||||||
|
const bashInput = bashCall?.input as { command?: string } | undefined;
|
||||||
|
expect(bashInput?.command).toContain("echo");
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Claude agent: Edit tool",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "hello world\n");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: "Claude Edit Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CLAUDE_EDIT", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Edit");
|
||||||
|
expect(editCall).toBeDefined();
|
||||||
|
expect(editCall?.input).toBeDefined();
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent: shell command",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Shell Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Run `echo hello` and tell me what it outputs. Be brief."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CODEX_SHELL", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "shell");
|
||||||
|
expect(shellCall).toBeDefined();
|
||||||
|
expect(shellCall?.name).toBe("shell");
|
||||||
|
expect(shellCall?.input).toBeDefined();
|
||||||
|
// Command text should be in input.command
|
||||||
|
const shellInput = shellCall?.input as { command?: string } | undefined;
|
||||||
|
expect(shellInput?.command).toContain("echo");
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent: file read",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Read Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CODEX_READ", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex may use shell cat or file read
|
||||||
|
const readCall = toolCalls.find(
|
||||||
|
(tc) => tc.type === "tool_call" && tc.name === "read_file"
|
||||||
|
);
|
||||||
|
if (readCall) {
|
||||||
|
expect(readCall.name).toBe("read_file");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex agent: file edit",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testFile = path.join(cwd, "test.txt");
|
||||||
|
writeFileSync(testFile, "hello world\n");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||||
|
cwd,
|
||||||
|
title: "Codex Edit Test",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||||
|
|
||||||
|
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
logToolCall("CODEX_EDIT", tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex uses apply_patch for edits
|
||||||
|
const editCall = toolCalls.find(
|
||||||
|
(tc) => tc.type === "tool_call" && tc.name === "apply_patch"
|
||||||
|
);
|
||||||
|
if (editCall) {
|
||||||
|
expect(editCall.name).toBe("apply_patch");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "wait-for-idle-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for waitForAgentIdle edge cases.
|
||||||
|
* Uses haiku for speed. 10s timeout per operation - if slower, it's a bug.
|
||||||
|
*/
|
||||||
|
describe("waitForAgentIdle edge cases", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
test("waitForAgentIdle immediately after sendMessage", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
model: "haiku",
|
||||||
|
cwd,
|
||||||
|
title: "Immediate Wait Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
// This was the original bug: waitForAgentIdle returned old idle states
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'hello'");
|
||||||
|
const state = await ctx.client.waitForAgentIdle(agent.id, 10000);
|
||||||
|
|
||||||
|
expect(state.status).toBe("idle");
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
test("rapid fire messages then single wait", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
model: "haiku",
|
||||||
|
cwd,
|
||||||
|
title: "Rapid Fire Test",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send 3 messages without waiting - tests that waitForAgentIdle
|
||||||
|
// finds the idle AFTER the last running state
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'one'");
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'two'");
|
||||||
|
await ctx.client.sendMessage(agent.id, "Say 'three'");
|
||||||
|
|
||||||
|
const state = await ctx.client.waitForAgentIdle(agent.id, 10000);
|
||||||
|
expect(state.status).toBe("idle");
|
||||||
|
|
||||||
|
// Verify all 3 messages were recorded
|
||||||
|
const queue = ctx.client.getMessageQueue();
|
||||||
|
const userMessages = queue.filter(
|
||||||
|
(m) =>
|
||||||
|
m.type === "agent_stream" &&
|
||||||
|
m.payload.agentId === agent.id &&
|
||||||
|
m.payload.event.type === "timeline" &&
|
||||||
|
m.payload.event.item.type === "user_message"
|
||||||
|
);
|
||||||
|
expect(userMessages.length).toBe(3);
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
test("two agents: waitForAgentIdle filters by agent", async () => {
|
||||||
|
const cwd1 = tmpCwd();
|
||||||
|
const cwd2 = tmpCwd();
|
||||||
|
|
||||||
|
const agent1 = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
model: "haiku",
|
||||||
|
cwd: cwd1,
|
||||||
|
title: "Agent 1",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent2 = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
model: "haiku",
|
||||||
|
cwd: cwd2,
|
||||||
|
title: "Agent 2",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start both agents
|
||||||
|
await ctx.client.sendMessage(agent1.id, "Say 'agent one'");
|
||||||
|
await ctx.client.sendMessage(agent2.id, "Say 'agent two'");
|
||||||
|
|
||||||
|
// Wait for each - should not be confused by the other's state
|
||||||
|
const state2 = await ctx.client.waitForAgentIdle(agent2.id, 10000);
|
||||||
|
expect(state2.status).toBe("idle");
|
||||||
|
expect(state2.id).toBe(agent2.id);
|
||||||
|
|
||||||
|
const state1 = await ctx.client.waitForAgentIdle(agent1.id, 10000);
|
||||||
|
expect(state1.status).toBe("idle");
|
||||||
|
expect(state1.id).toBe(agent1.id);
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent1.id);
|
||||||
|
await ctx.client.deleteAgent(agent2.id);
|
||||||
|
rmSync(cwd1, { recursive: true, force: true });
|
||||||
|
rmSync(cwd2, { recursive: true, force: true });
|
||||||
|
}, 25000);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -364,25 +364,21 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) {
|
if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) {
|
||||||
|
console.error(`[INTERRUPT:${agentId.substring(0, 8)}] not running, skipping. lifecycle=${snapshot.lifecycle} pendingRun=${!!snapshot.pendingRun}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessionLogger.info(
|
console.error(`[INTERRUPT:${agentId.substring(0, 8)}] starting interrupt. lifecycle=${snapshot.lifecycle} pendingRun=${!!snapshot.pendingRun}`);
|
||||||
{ agentId },
|
|
||||||
`Interrupting active run for agent ${agentId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const t0 = Date.now();
|
||||||
const cancelled = await this.agentManager.cancelAgentRun(agentId);
|
const cancelled = await this.agentManager.cancelAgentRun(agentId);
|
||||||
|
console.error(`[INTERRUPT] cancelAgentRun returned cancelled=${cancelled} in ${Date.now() - t0}ms`);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
this.sessionLogger.warn(
|
console.error(`[INTERRUPT] WARNING: reported running but no active run was cancelled`);
|
||||||
{ agentId },
|
|
||||||
`Agent ${agentId} reported running but no active run was cancelled`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the agent to become idle after cancellation
|
// Wait for the agent to become idle after cancellation
|
||||||
// cancelAgentRun only initiates cancellation; doesn't wait for generator to terminate
|
|
||||||
const maxWaitMs = 5000;
|
const maxWaitMs = 5000;
|
||||||
const pollIntervalMs = 50;
|
const pollIntervalMs = 50;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -392,15 +388,17 @@ export class Session {
|
|||||||
throw new Error(`Agent ${agentId} not found during cancellation wait`);
|
throw new Error(`Agent ${agentId} not found during cancellation wait`);
|
||||||
}
|
}
|
||||||
if (current.lifecycle !== "running" && !current.pendingRun) {
|
if (current.lifecycle !== "running" && !current.pendingRun) {
|
||||||
|
console.error(`[INTERRUPT] agent became idle after ${Date.now() - startTime}ms`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||||
}
|
}
|
||||||
|
const finalState = this.agentManager.getAgent(agentId);
|
||||||
|
if (finalState?.lifecycle === "running" || finalState?.pendingRun) {
|
||||||
|
console.error(`[INTERRUPT] WARNING: agent still running after 5s wait! lifecycle=${finalState?.lifecycle} pendingRun=${!!finalState?.pendingRun}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
console.error(`[INTERRUPT] ERROR:`, error);
|
||||||
{ err: error, agentId },
|
|
||||||
`Failed to interrupt agent ${agentId}`
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,6 +407,7 @@ export class Session {
|
|||||||
* Start streaming an agent run and forward results via the websocket broadcast
|
* Start streaming an agent run and forward results via the websocket broadcast
|
||||||
*/
|
*/
|
||||||
private startAgentStream(agentId: string, prompt: AgentPromptInput): void {
|
private startAgentStream(agentId: string, prompt: AgentPromptInput): void {
|
||||||
|
console.error(`[SESSION:startAgentStream] starting for agent ${agentId.substring(0, 8)}, prompt="${typeof prompt === 'string' ? prompt.substring(0, 40) : 'object'}"`);
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId },
|
{ agentId },
|
||||||
`Starting agent stream for ${agentId}`
|
`Starting agent stream for ${agentId}`
|
||||||
@@ -1007,6 +1006,7 @@ export class Session {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
console.error(`[DELETE_AGENT] handleDeleteAgentRequest called for ${agentId} requestId=${requestId}`, new Error().stack);
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId },
|
{ agentId },
|
||||||
`Deleting agent ${agentId} from registry`
|
`Deleting agent ${agentId} from registry`
|
||||||
@@ -1060,14 +1060,18 @@ export class Session {
|
|||||||
messageId?: string,
|
messageId?: string,
|
||||||
images?: Array<{ data: string; mimeType: string }>
|
images?: Array<{ data: string; mimeType: string }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] ENTERED agentId=${agentId.substring(0, 8)} text="${text.substring(0, 40)}"`);
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
||||||
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
|
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] calling ensureAgentLoaded...`);
|
||||||
await this.ensureAgentLoaded(agentId);
|
await this.ensureAgentLoaded(agentId);
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] ensureAgentLoaded done`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] ensureAgentLoaded FAILED:`, error);
|
||||||
this.handleAgentRunError(
|
this.handleAgentRunError(
|
||||||
agentId,
|
agentId,
|
||||||
error,
|
error,
|
||||||
@@ -1077,8 +1081,12 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const snapshotBeforeInterrupt = this.agentManager.getAgent(agentId);
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] before interrupt: lifecycle=${snapshotBeforeInterrupt?.lifecycle} pendingRun=${!!snapshotBeforeInterrupt?.pendingRun}`);
|
||||||
await this.interruptAgentIfRunning(agentId);
|
await this.interruptAgentIfRunning(agentId);
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] interrupt done`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] interrupt FAILED:`, error);
|
||||||
this.handleAgentRunError(
|
this.handleAgentRunError(
|
||||||
agentId,
|
agentId,
|
||||||
error,
|
error,
|
||||||
@@ -1088,6 +1096,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = this.buildAgentPrompt(text, images);
|
const prompt = this.buildAgentPrompt(text, images);
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] calling recordUserMessage...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(agentId, text, { messageId });
|
this.agentManager.recordUserMessage(agentId, text, { messageId });
|
||||||
@@ -1098,6 +1107,7 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error(`[SESSION:handleSendAgentMessage] calling startAgentStream...`);
|
||||||
this.startAgentStream(agentId, prompt);
|
this.startAgentStream(agentId, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { spawnSync } from "node:child_process";
|
|||||||
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
|
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { FileTaskStore } from "./task-store.js";
|
import { FileTaskStore } from "./task-store.js";
|
||||||
|
import {
|
||||||
|
computeExecutionOrder,
|
||||||
|
buildSortedChildrenMap,
|
||||||
|
} from "./execution-order.js";
|
||||||
import type { AgentType, ModelName, Task } from "./types.js";
|
import type { AgentType, ModelName, Task } from "./types.js";
|
||||||
|
|
||||||
const TASKS_DIR = resolve(process.cwd(), ".tasks");
|
const TASKS_DIR = resolve(process.cwd(), ".tasks");
|
||||||
@@ -65,11 +69,6 @@ Examples:
|
|||||||
# See completed work
|
# See completed work
|
||||||
task closed --scope abc123
|
task closed --scope abc123
|
||||||
|
|
||||||
# Run agent loop on an epic
|
|
||||||
task run abc123
|
|
||||||
task run abc123 --agent codex
|
|
||||||
task run --watch
|
|
||||||
|
|
||||||
Body vs Notes:
|
Body vs Notes:
|
||||||
The BODY is the task's markdown document - edit it while grooming/defining the task.
|
The BODY is the task's markdown document - edit it while grooming/defining the task.
|
||||||
NOTES are timestamped entries added during implementation to document progress.
|
NOTES are timestamped entries added during implementation to document progress.
|
||||||
@@ -77,6 +76,89 @@ Body vs Notes:
|
|||||||
- While defining a task: edit the body with "task update <id> --body ..."
|
- While defining a task: edit the body with "task update <id> --body ..."
|
||||||
- While implementing: add notes with "task note <id> ..."
|
- While implementing: add notes with "task note <id> ..."
|
||||||
- When done: add a final note explaining what was done, then close
|
- When done: add a final note explaining what was done, then close
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The Run Command (task run):
|
||||||
|
|
||||||
|
The run command executes an agent loop that works until acceptance criteria are met.
|
||||||
|
This can run for hours, days, or weeks for large epics.
|
||||||
|
|
||||||
|
Basic usage:
|
||||||
|
task run <scope-id> # Run without planner (task must be well-defined)
|
||||||
|
task run <scope-id> --plan # Run with planner (for larger epics)
|
||||||
|
|
||||||
|
Without --plan: The scoped task runs in a worker/judge loop. The worker implements,
|
||||||
|
the judge verifies. If not done, it loops. No breakdown happens - the task must be
|
||||||
|
atomic and well-defined from the start.
|
||||||
|
|
||||||
|
With --plan: The planner first breaks down the scope into subtasks, then workers
|
||||||
|
execute leaf tasks while the planner can reorganize as needed. Use this for epics
|
||||||
|
where you define WHAT you want but not HOW to achieve it.
|
||||||
|
|
||||||
|
When replanning happens:
|
||||||
|
- At the start (initial breakdown)
|
||||||
|
- When a task fails 5+ times (something is wrong with the approach)
|
||||||
|
- When you add a steering note (task steer <scope-id> "new direction")
|
||||||
|
|
||||||
|
Monitoring a running loop:
|
||||||
|
task plan <scope-id> # See execution timeline and progress
|
||||||
|
task show <task-id> # Check specific task details
|
||||||
|
tail -f task-run.*.log # Follow the live log
|
||||||
|
|
||||||
|
Steering mid-run:
|
||||||
|
task steer <scope-id> "focus on X first"
|
||||||
|
task steer <scope-id> "skip Y, it's not needed"
|
||||||
|
task steer <scope-id> "run e2e tests every iteration"
|
||||||
|
|
||||||
|
Steering triggers an immediate replan. Use it to course-correct without stopping.
|
||||||
|
|
||||||
|
Writing Good Acceptance Criteria:
|
||||||
|
|
||||||
|
Acceptance criteria determine when a task is DONE. They must be specific enough
|
||||||
|
that agents cannot "cheat" by taking shortcuts.
|
||||||
|
|
||||||
|
Bad (agents can game these):
|
||||||
|
- "tests pass" → could delete tests, skip them, or weaken assertions
|
||||||
|
- "code works" → subjective, unverifiable
|
||||||
|
- "feature complete" → vague
|
||||||
|
|
||||||
|
Good (specific and non-gameable):
|
||||||
|
- "npm test exits 0 with 0 failures and 0 skipped"
|
||||||
|
- "test count >= 150 (no deleting tests)"
|
||||||
|
- "coverage does not decrease from baseline"
|
||||||
|
- "npm run typecheck exits 0"
|
||||||
|
- "GET /api/users returns 200 with JSON array"
|
||||||
|
|
||||||
|
Philosophy: Specify the floor (minimum requirements) without capping the ceiling.
|
||||||
|
"Zero failures, zero skipped" is strict but doesn't prevent adding more tests.
|
||||||
|
|
||||||
|
For test-related tasks, consider:
|
||||||
|
--accept "npm test exits 0"
|
||||||
|
--accept "no test files deleted"
|
||||||
|
--accept "no .skip or test.todo added"
|
||||||
|
--accept "no assertions removed or weakened"
|
||||||
|
|
||||||
|
Acceptance Criteria vs Body Guidance:
|
||||||
|
|
||||||
|
Acceptance criteria = required OUTPUT (must-haves, verified at the end)
|
||||||
|
Body guidance = HOW to approach the work (instructions for the planner)
|
||||||
|
|
||||||
|
Acceptance criteria examples (verifiable end-state):
|
||||||
|
- "PR created with description and test plan"
|
||||||
|
- "Branch name follows convention: feat/<name>"
|
||||||
|
- "npm run typecheck exits 0"
|
||||||
|
|
||||||
|
Body guidance examples (process instructions):
|
||||||
|
- "Split work by module"
|
||||||
|
- "Commit after each subtask"
|
||||||
|
- "Run typecheck after each change"
|
||||||
|
|
||||||
|
The planner reads body guidance and propagates it into subtask acceptance criteria.
|
||||||
|
For example, if the body says "commit after each chunk of work", the planner adds
|
||||||
|
"git status shows clean working tree" to each subtask's criteria.
|
||||||
|
|
||||||
|
This makes process requirements verifiable at each step, not just at the end.
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -233,68 +315,106 @@ program
|
|||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("tree <id>")
|
.command("plan [scope]")
|
||||||
.description("Show task hierarchy with dependencies")
|
.alias("tree")
|
||||||
.action(async (id) => {
|
.description("Show execution timeline (tree view by default)")
|
||||||
const root = await store.get(id);
|
.option("--flat", "Show flat list instead of tree")
|
||||||
if (!root) {
|
.option("-d, --depth <n>", "Limit tree depth (0 = root only)")
|
||||||
process.stderr.write(`Task not found: ${id}\n`);
|
.action(async (scopeId: string | undefined, opts) => {
|
||||||
process.exit(1);
|
const { timeline, orderMap, blocked } = await computeExecutionOrder(
|
||||||
}
|
store,
|
||||||
|
scopeId
|
||||||
// Build a map of all tasks for dependency lookups
|
);
|
||||||
const allTasks = await store.list();
|
const allTasks = await store.list();
|
||||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||||
|
const maxDepth = opts.depth !== undefined ? parseInt(opts.depth, 10) : Infinity;
|
||||||
|
|
||||||
// Print a task line with optional dependency info
|
const formatDeps = (task: Task): string => {
|
||||||
const printTask = (task: Task, prefix: string, connector: string) => {
|
if (task.deps.length === 0) return "";
|
||||||
const assignee = task.assignee ? ` @${task.assignee}` : "";
|
return ` (deps: ${task.deps.join(", ")})`;
|
||||||
const priority = task.priority !== undefined ? ` !${task.priority}` : "";
|
|
||||||
process.stdout.write(
|
|
||||||
`${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}\n`
|
|
||||||
);
|
|
||||||
// Print dependencies on next line with arrow
|
|
||||||
if (task.deps.length > 0) {
|
|
||||||
const depNames = task.deps
|
|
||||||
.map((depId) => {
|
|
||||||
const dep = taskMap.get(depId);
|
|
||||||
return dep ? `${dep.title} (${depId})` : depId;
|
|
||||||
})
|
|
||||||
.join(", ");
|
|
||||||
const depPrefix = prefix + (connector === "└── " ? " " : "│ ");
|
|
||||||
process.stdout.write(`${depPrefix}→ depends on: ${depNames}\n`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Print root task
|
if (opts.flat) {
|
||||||
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
|
// Flat list view
|
||||||
const rootPriority = root.priority !== undefined ? ` !${root.priority}` : "";
|
const printTask = (t: Task, idx: number) => {
|
||||||
process.stdout.write(`${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}\n`);
|
const priority = t.priority !== undefined ? `!${t.priority} ` : "";
|
||||||
if (root.deps.length > 0) {
|
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
||||||
const depNames = root.deps
|
const num = String(idx + 1).padStart(3, " ");
|
||||||
.map((depId) => {
|
const mark = t.status === "done" ? "✓" : " ";
|
||||||
const dep = taskMap.get(depId);
|
const deps = formatDeps(t);
|
||||||
return dep ? `${dep.title} (${depId})` : depId;
|
process.stdout.write(
|
||||||
})
|
`${mark}${num}. ${priority}${t.id} ${t.title}${assignee}${deps}\n`
|
||||||
.join(", ");
|
);
|
||||||
process.stdout.write(`→ depends on: ${depNames}\n`);
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < timeline.length; i++) {
|
||||||
|
printTask(timeline[i], i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tree view (default)
|
||||||
|
if (!scopeId) {
|
||||||
|
process.stderr.write("Tree view requires a scope ID\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = await store.get(scopeId);
|
||||||
|
if (!root) {
|
||||||
|
process.stderr.write(`Task not found: ${scopeId}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedChildrenMap = buildSortedChildrenMap(allTasks, orderMap);
|
||||||
|
|
||||||
|
const printTask = (
|
||||||
|
task: Task,
|
||||||
|
prefix: string,
|
||||||
|
connector: string,
|
||||||
|
depth: number
|
||||||
|
) => {
|
||||||
|
const assignee = task.assignee ? ` @${task.assignee}` : "";
|
||||||
|
const priority =
|
||||||
|
task.priority !== undefined ? ` !${task.priority}` : "";
|
||||||
|
const mark = task.status === "done" ? "✓ " : " ";
|
||||||
|
const deps = formatDeps(task);
|
||||||
|
process.stdout.write(
|
||||||
|
`${mark}${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}${deps}\n`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Print root
|
||||||
|
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
|
||||||
|
const rootPriority =
|
||||||
|
root.priority !== undefined ? ` !${root.priority}` : "";
|
||||||
|
const rootMark = root.status === "done" ? "✓ " : " ";
|
||||||
|
const rootDeps = formatDeps(root);
|
||||||
|
process.stdout.write(
|
||||||
|
`${rootMark}${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}${rootDeps}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recursively print children in execution order
|
||||||
|
const printChildren = (parentId: string, prefix: string, depth: number) => {
|
||||||
|
if (depth >= maxDepth) return;
|
||||||
|
const children = sortedChildrenMap.get(parentId) ?? [];
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const child = children[i];
|
||||||
|
const isLast = i === children.length - 1;
|
||||||
|
const connector = isLast ? "└── " : "├── ";
|
||||||
|
const childPrefix = prefix + (isLast ? " " : "│ ");
|
||||||
|
|
||||||
|
printTask(child, prefix, connector, depth);
|
||||||
|
printChildren(child.id, childPrefix, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
printChildren(scopeId, "", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively print children (hierarchy)
|
if (blocked.size > 0) {
|
||||||
const printChildren = async (parentId: string, prefix: string) => {
|
process.stdout.write(`\n... +${blocked.size} blocked/unreachable\n`);
|
||||||
const children = await store.getChildren(parentId);
|
}
|
||||||
for (let i = 0; i < children.length; i++) {
|
if (timeline.length === 0) {
|
||||||
const child = children[i];
|
process.stdout.write("No tasks.\n");
|
||||||
const isLast = i === children.length - 1;
|
}
|
||||||
const connector = isLast ? "└── " : "├── ";
|
|
||||||
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
||||||
|
|
||||||
printTask(child, prefix, connector);
|
|
||||||
await printChildren(child.id, childPrefix);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await printChildren(id, "");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -590,7 +710,7 @@ ${task.body || "(no body)"}
|
|||||||
${acceptanceCriteriaText}${notesText}`;
|
${acceptanceCriteriaText}${notesText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePlannerPrompt(task: Task, context: string, iteration: number): string {
|
function makePlannerPrompt(task: Task, context: string, reason: string): string {
|
||||||
return `${context}
|
return `${context}
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -599,7 +719,9 @@ You are a PLANNER agent. You ORCHESTRATE work - you do NOT do the work yourself.
|
|||||||
|
|
||||||
Your job is to organize tasks so that WORKER agents can accomplish the top-level acceptance criteria.
|
Your job is to organize tasks so that WORKER agents can accomplish the top-level acceptance criteria.
|
||||||
|
|
||||||
Current iteration: ${iteration}
|
## Why you were called
|
||||||
|
|
||||||
|
${reason}
|
||||||
|
|
||||||
## Planning vs Working
|
## Planning vs Working
|
||||||
|
|
||||||
@@ -617,29 +739,71 @@ As a planner you must NOT:
|
|||||||
Take the task descriptions AS GIVEN and create well-organized subtasks for workers to investigate and implement.
|
Take the task descriptions AS GIVEN and create well-organized subtasks for workers to investigate and implement.
|
||||||
|
|
||||||
## Task CLI Commands
|
## Task CLI Commands
|
||||||
- \`task show ${task.id}\` - view task details with parent context
|
|
||||||
- \`task list\` - see all tasks
|
### Understanding the plan
|
||||||
- \`task tree ${task.id}\` - see full task hierarchy
|
- \`task plan ${task.id}\` - **START HERE**: shows full execution timeline (done ✓ then pending in order)
|
||||||
- \`task children ${task.id}\` - list subtasks
|
- \`task tree ${task.id}\` - see task hierarchy with dependencies (parent → children)
|
||||||
- \`task ready\` - see what's ready to work on
|
- \`task show <id>\` - view task details, body, acceptance criteria, and notes
|
||||||
- \`task create "title" --parent <id> --body "..." --accept "criterion"\` - create task (any level)
|
- \`task children <id>\` - list direct children of a task
|
||||||
- \`task update <id> --title "..." --body "..." --accept "criterion"\` - update task
|
- \`task ready --scope ${task.id}\` - see what's ready to work on right now
|
||||||
|
- \`task blocked --scope ${task.id}\` - see what's blocked and why
|
||||||
|
|
||||||
|
### Navigating the task tree
|
||||||
|
|
||||||
|
The tree has two relationships:
|
||||||
|
1. **Parent/children** (hierarchy): \`--parent <id>\` groups tasks. Use \`task tree\` and \`task children\`.
|
||||||
|
2. **Dependencies** (ordering): \`--deps <id>\` blocks execution. Shown as \`← [dep1, dep2]\` in tree output.
|
||||||
|
|
||||||
|
To understand what workers will actually implement, find the **leaf tasks** (tasks with no children).
|
||||||
|
Parent tasks are just containers - workers execute leaf tasks.
|
||||||
|
|
||||||
|
**Always navigate down to leaves:**
|
||||||
|
\`\`\`
|
||||||
|
task tree ${task.id} # see full structure
|
||||||
|
task children <parent> # drill into a branch
|
||||||
|
task show <leaf> # read the actual work item
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
When planning, ensure leaf tasks have:
|
||||||
|
- Clear acceptance criteria (verifiable by judge)
|
||||||
|
- Correct dependencies (won't run until deps are done)
|
||||||
|
- Appropriate priority (controls order among siblings)
|
||||||
|
|
||||||
|
### Modifying tasks
|
||||||
|
- \`task create "title" --parent <id> --body "..." --accept "criterion" -p <priority>\` - create task
|
||||||
|
- \`task update <id> --title "..." --body "..." -p <priority>\` - update task properties
|
||||||
|
- \`task update <id> --accept "criterion"\` - add acceptance criterion
|
||||||
|
- \`task update <id> --clear-acceptance -a "new criterion"\` - replace all acceptance criteria
|
||||||
|
- \`task delete <id>\` - remove a task permanently
|
||||||
- \`task note <id> "content"\` - add planning notes
|
- \`task note <id> "content"\` - add planning notes
|
||||||
- \`task delete <id>\` - remove a task that's no longer needed
|
|
||||||
- \`task fail <id>\` - mark task as failed if catastrophically stuck
|
- \`task fail <id>\` - mark task as failed if catastrophically stuck
|
||||||
|
|
||||||
|
### Priority (-p flag)
|
||||||
|
Priority controls execution order. **Lower number = higher priority** (executed first).
|
||||||
|
- \`-p 0\` - critical/urgent, do immediately
|
||||||
|
- \`-p 1\` - high priority
|
||||||
|
- \`-p 2\` - normal priority
|
||||||
|
- (no priority) - lowest, done after all prioritized tasks
|
||||||
|
|
||||||
|
When user steers with urgent changes, set priority on new tasks to ensure they run BEFORE existing tasks.
|
||||||
|
Example: \`task create "Fix critical bug" --parent ${task.id} -p 0 --accept "..."\`
|
||||||
|
Example: \`task update <id> -p 0\` - bump existing task to run next
|
||||||
|
|
||||||
## Your Scope
|
## Your Scope
|
||||||
|
|
||||||
You can reorganize ANY task under this scope. The TOP-LEVEL task's acceptance criteria are IMMUTABLE - they are the north star. Everything else can be:
|
You can reorganize ANY task under this scope. The TOP-LEVEL task's acceptance criteria are IMMUTABLE - they are the north star. Everything else can be:
|
||||||
|
|
||||||
- Broken down into subtasks
|
- **Deleted** if no longer relevant (use \`task delete <id>\`)
|
||||||
- Deleted if no longer relevant
|
- **Reprioritized** to change execution order (use \`task update <id> -p <n>\`)
|
||||||
- Reordered/reprioritized
|
- **Broken down** into subtasks
|
||||||
- Updated with better acceptance criteria
|
- **Updated** with better acceptance criteria
|
||||||
|
|
||||||
User steering notes override the task body - follow them.
|
**You are FREE to delete tasks and start over.** If the plan isn't working, throw it away. If user steers in a new direction, delete obsolete tasks rather than leaving them in the queue.
|
||||||
|
|
||||||
Always keep the top-level goal in mind when reorganizing.
|
User steering notes (STEER:) override the task body - follow them immediately by:
|
||||||
|
1. Creating new tasks with high priority (-p 0 or -p 1)
|
||||||
|
2. Deleting tasks that are now obsolete
|
||||||
|
3. Adjusting priorities on existing tasks if needed
|
||||||
|
|
||||||
## Writing Good Acceptance Criteria
|
## Writing Good Acceptance Criteria
|
||||||
|
|
||||||
@@ -660,6 +824,33 @@ Good examples:
|
|||||||
- "File src/utils/parser.ts exports parseConfig function"
|
- "File src/utils/parser.ts exports parseConfig function"
|
||||||
- "Running \`node cli.js --help\` prints usage information"
|
- "Running \`node cli.js --help\` prints usage information"
|
||||||
|
|
||||||
|
## Propagating Requirements to Subtasks
|
||||||
|
|
||||||
|
Both body guidance AND acceptance criteria must be propagated to subtasks. Break them
|
||||||
|
down so requirements are verified at each step, not deferred until the end.
|
||||||
|
|
||||||
|
**Propagate body guidance** (process instructions → subtask criteria):
|
||||||
|
|
||||||
|
Example: Body says "commit after each chunk of work"
|
||||||
|
→ Add to EACH subtask: --accept "git status shows clean working tree"
|
||||||
|
|
||||||
|
Example: Body says "run typecheck after each change"
|
||||||
|
→ Add to EACH subtask: --accept "npm run typecheck exits 0"
|
||||||
|
|
||||||
|
**Propagate acceptance criteria** (scope them to the subtask's module/area):
|
||||||
|
|
||||||
|
Example: Top-level says "no test.skip added"
|
||||||
|
→ Subtask for module X: --accept "no test.skip in src/modules/X/**"
|
||||||
|
|
||||||
|
Example: Top-level says "npm test passes with 0 failures"
|
||||||
|
→ Subtask for auth module: --accept "npm test src/auth passes with 0 failures"
|
||||||
|
|
||||||
|
Example: Top-level says "coverage >= 80%"
|
||||||
|
→ Subtask for utils: --accept "coverage for src/utils >= 80%"
|
||||||
|
|
||||||
|
This ensures requirements are enforced incrementally. Don't wait until the end to
|
||||||
|
verify - by then it's too late to fix without rework.
|
||||||
|
|
||||||
## TDD Pattern
|
## TDD Pattern
|
||||||
|
|
||||||
When the top-level task mentions "TDD" or "test-driven", you MUST structure subtasks as test-first pairs:
|
When the top-level task mentions "TDD" or "test-driven", you MUST structure subtasks as test-first pairs:
|
||||||
@@ -701,6 +892,17 @@ Key points:
|
|||||||
|
|
||||||
Note: Multiple iterations are fine if there's progress. Only mark failed if truly stuck with no way forward.
|
Note: Multiple iterations are fine if there's progress. Only mark failed if truly stuck with no way forward.
|
||||||
|
|
||||||
|
## Before You Exit - Sanity Check
|
||||||
|
|
||||||
|
ALWAYS run these checks before finishing:
|
||||||
|
|
||||||
|
1. **Run \`task plan ${task.id}\`** - verify the execution order makes sense
|
||||||
|
2. **Check priorities** - are urgent/steering tasks at the top (low priority numbers)?
|
||||||
|
3. **Check acceptance criteria** - does every pending task have clear, verifiable criteria?
|
||||||
|
4. **Clean up obsolete tasks** - delete anything that's no longer relevant
|
||||||
|
|
||||||
|
If something looks wrong, fix it before exiting.
|
||||||
|
|
||||||
DO NOT implement tasks. Only plan and organize.
|
DO NOT implement tasks. Only plan and organize.
|
||||||
When done planning, simply exit. Do not mark tasks as done.
|
When done planning, simply exit. Do not mark tasks as done.
|
||||||
`;
|
`;
|
||||||
@@ -836,7 +1038,7 @@ program
|
|||||||
const runPlanner = async (task: Task, reason: string): Promise<boolean> => {
|
const runPlanner = async (task: Task, reason: string): Promise<boolean> => {
|
||||||
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
|
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
|
||||||
const context = await buildTaskContext(task, scopeId);
|
const context = await buildTaskContext(task, scopeId);
|
||||||
const plannerPrompt = makePlannerPrompt(task, context, 1);
|
const plannerPrompt = makePlannerPrompt(task, context, reason);
|
||||||
runAgentWithModel(plannerPrompt, plannerModel, logFile);
|
runAgentWithModel(plannerPrompt, plannerModel, logFile);
|
||||||
|
|
||||||
// Check if planner created subtasks for this task
|
// Check if planner created subtasks for this task
|
||||||
@@ -895,7 +1097,13 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = ready[0];
|
const task = ready[0];
|
||||||
const workerModel = (task.assignee === "codex" ? "gpt-5.2" : "sonnet") as ModelName;
|
const baseModel = (
|
||||||
|
task.assignee === "codex" ? "gpt-5.2-codex" :
|
||||||
|
task.assignee ? task.assignee :
|
||||||
|
"sonnet"
|
||||||
|
) as ModelName;
|
||||||
|
let workerModel = baseModel;
|
||||||
|
const canUpgrade = baseModel === "sonnet"; // Only upgrade if starting from sonnet
|
||||||
|
|
||||||
log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`);
|
log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`);
|
||||||
await store.start(task.id);
|
await store.start(task.id);
|
||||||
@@ -904,27 +1112,36 @@ program
|
|||||||
let iteration = 1;
|
let iteration = 1;
|
||||||
let taskDone = false;
|
let taskDone = false;
|
||||||
let consecutiveNotDone = 0;
|
let consecutiveNotDone = 0;
|
||||||
let lastSeenSteerCount = task.notes.filter(n => n.content.startsWith("STEER:")).length;
|
// Track steering notes on scope (not leaf task) since that's where user adds them
|
||||||
|
const scopeTaskForSteer = scopeId ? await store.get(scopeId) : null;
|
||||||
|
let lastSeenSteerCount = scopeTaskForSteer?.notes.filter(n => n.content.startsWith("STEER:")).length ?? 0;
|
||||||
|
|
||||||
while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) {
|
while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) {
|
||||||
const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`;
|
const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`;
|
||||||
log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`);
|
log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`);
|
||||||
|
|
||||||
// Check for new steering notes before worker runs
|
// Check for new steering notes on scope before worker runs
|
||||||
const preTask = await store.get(task.id);
|
if (enablePlanner && scopeId) {
|
||||||
if (!preTask) break;
|
const freshScope = await store.get(scopeId);
|
||||||
const currentSteerCount = preTask.notes.filter(n => n.content.startsWith("STEER:")).length;
|
if (freshScope) {
|
||||||
if (enablePlanner && currentSteerCount > lastSeenSteerCount) {
|
const currentSteerCount = freshScope.notes.filter(n => n.content.startsWith("STEER:")).length;
|
||||||
const newSteers = preTask.notes.filter(n => n.content.startsWith("STEER:")).slice(lastSeenSteerCount);
|
if (currentSteerCount > lastSeenSteerCount) {
|
||||||
log(logFile, `[STEER] New steering note detected - triggering planner`);
|
const newSteers = freshScope.notes
|
||||||
lastSeenSteerCount = currentSteerCount;
|
.filter(n => n.content.startsWith("STEER:"))
|
||||||
const shouldRestart = await runPlanner(preTask, `steering: ${newSteers.map(n => n.content).join("; ")}`);
|
.slice(lastSeenSteerCount)
|
||||||
if (shouldRestart) {
|
.map(n => n.content.replace(/^STEER:\s*/, ""));
|
||||||
const updatedTask = await store.get(task.id);
|
log(logFile, `[STEER] New steering note detected - triggering planner`);
|
||||||
if (updatedTask && updatedTask.status === "in_progress") {
|
lastSeenSteerCount = currentSteerCount;
|
||||||
await store.update(task.id, { status: "open" });
|
const reason = `User steering: ${newSteers.join("; ")}`;
|
||||||
|
const shouldRestart = await runPlanner(freshScope, reason);
|
||||||
|
if (shouldRestart) {
|
||||||
|
const updatedTask = await store.get(task.id);
|
||||||
|
if (updatedTask && updatedTask.status === "in_progress") {
|
||||||
|
await store.update(task.id, { status: "open" });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,12 +1171,18 @@ program
|
|||||||
consecutiveNotDone = 0;
|
consecutiveNotDone = 0;
|
||||||
} else {
|
} else {
|
||||||
consecutiveNotDone++;
|
consecutiveNotDone++;
|
||||||
// Only replan after 5 consecutive NOT_DONEs
|
// Upgrade to opus on first NOT_DONE (if not already using opus)
|
||||||
if (enablePlanner && consecutiveNotDone >= 5) {
|
if (canUpgrade && workerModel === "sonnet") {
|
||||||
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE - triggering planner`);
|
workerModel = "opus";
|
||||||
const currentTask = await store.get(task.id);
|
log(logFile, `[WORKER] Upgrading to opus after NOT_DONE`);
|
||||||
if (currentTask) {
|
}
|
||||||
const shouldRestart = await runPlanner(currentTask, `${consecutiveNotDone} consecutive NOT_DONE`);
|
// Only replan after 5 consecutive NOT_DONEs - use scope for bird's eye view
|
||||||
|
if (enablePlanner && consecutiveNotDone >= 5 && scopeId) {
|
||||||
|
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE on ${task.id} - triggering planner`);
|
||||||
|
const scopeTask = await store.get(scopeId);
|
||||||
|
if (scopeTask) {
|
||||||
|
const reason = `task ${task.id} "${task.title}" got ${consecutiveNotDone} consecutive NOT_DONE`;
|
||||||
|
const shouldRestart = await runPlanner(scopeTask, reason);
|
||||||
consecutiveNotDone = 0;
|
consecutiveNotDone = 0;
|
||||||
if (shouldRestart) {
|
if (shouldRestart) {
|
||||||
// Planner created subtasks or marked failed, restart the main loop
|
// Planner created subtasks or marked failed, restart the main loop
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js";
|
||||||
|
import type { Task, TaskStore } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory task store for testing
|
||||||
|
*/
|
||||||
|
class MemoryTaskStore implements TaskStore {
|
||||||
|
private tasks: Map<string, Task> = new Map();
|
||||||
|
|
||||||
|
addTask(task: Task): void {
|
||||||
|
this.tasks.set(task.id, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<Task[]> {
|
||||||
|
return [...this.tasks.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<Task | null> {
|
||||||
|
return this.tasks.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDescendants(id: string): Promise<Task[]> {
|
||||||
|
const result: Task[] = [];
|
||||||
|
const traverse = (parentId: string): void => {
|
||||||
|
for (const task of this.tasks.values()) {
|
||||||
|
if (task.parentId === parentId) {
|
||||||
|
result.push(task);
|
||||||
|
traverse(task.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverse(id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused in tests but required by interface
|
||||||
|
async getDepTree(): Promise<Task[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getAncestors(): Promise<Task[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getChildren(id: string): Promise<Task[]> {
|
||||||
|
return [...this.tasks.values()].filter((t) => t.parentId === id);
|
||||||
|
}
|
||||||
|
async getReady(): Promise<Task[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getBlocked(): Promise<Task[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getClosed(): Promise<Task[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async create(): Promise<Task> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async update(): Promise<Task> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async delete(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async addDep(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async removeDep(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async setParent(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async addNote(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async open(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async start(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async fail(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
async addAcceptanceCriteria(): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(
|
||||||
|
id: string,
|
||||||
|
opts: {
|
||||||
|
title?: string;
|
||||||
|
status?: Task["status"];
|
||||||
|
parentId?: string;
|
||||||
|
deps?: string[];
|
||||||
|
priority?: number;
|
||||||
|
created?: string;
|
||||||
|
} = {}
|
||||||
|
): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: opts.title ?? id,
|
||||||
|
status: opts.status ?? "open",
|
||||||
|
parentId: opts.parentId,
|
||||||
|
deps: opts.deps ?? [],
|
||||||
|
priority: opts.priority,
|
||||||
|
created: opts.created ?? new Date().toISOString(),
|
||||||
|
body: "",
|
||||||
|
acceptanceCriteria: [],
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeExecutionOrder", () => {
|
||||||
|
let store: MemoryTaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new MemoryTaskStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty timeline for empty store", async () => {
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
expect(result.timeline).toEqual([]);
|
||||||
|
expect(result.orderMap.size).toBe(0);
|
||||||
|
expect(result.blocked.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should order by priority (lower number first)", async () => {
|
||||||
|
store.addTask(makeTask("a", { priority: 2, created: "2024-01-01" }));
|
||||||
|
store.addTask(makeTask("b", { priority: 0, created: "2024-01-02" }));
|
||||||
|
store.addTask(makeTask("c", { priority: 1, created: "2024-01-03" }));
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
expect(ids).toEqual(["b", "c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should order tasks without priority after prioritized tasks", async () => {
|
||||||
|
store.addTask(makeTask("a", { created: "2024-01-01" })); // no priority
|
||||||
|
store.addTask(makeTask("b", { priority: 1, created: "2024-01-02" }));
|
||||||
|
store.addTask(makeTask("c", { created: "2024-01-03" })); // no priority
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
// b first (has priority), then a and c by created date
|
||||||
|
expect(ids).toEqual(["b", "a", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should respect dependencies", async () => {
|
||||||
|
// c depends on b
|
||||||
|
store.addTask(makeTask("a", { created: "2024-01-01" }));
|
||||||
|
store.addTask(makeTask("b", { created: "2024-01-02" }));
|
||||||
|
store.addTask(makeTask("c", { deps: ["b"], created: "2024-01-03" }));
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
// a and b can run (a first by created), then c after b
|
||||||
|
expect(ids).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute children before parent", async () => {
|
||||||
|
// Parent b has children b1 and b2
|
||||||
|
store.addTask(makeTask("b", { created: "2024-01-01" }));
|
||||||
|
store.addTask(makeTask("b1", { parentId: "b", created: "2024-01-02" }));
|
||||||
|
store.addTask(makeTask("b2", { parentId: "b", created: "2024-01-03" }));
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store, "b");
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
// Children first, then parent
|
||||||
|
expect(ids).toEqual(["b1", "b2", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex epic with phases and dependencies", async () => {
|
||||||
|
/**
|
||||||
|
* Epic structure:
|
||||||
|
*
|
||||||
|
* epic (root)
|
||||||
|
* ├── phase1 (priority 0)
|
||||||
|
* │ ├── p1-task1
|
||||||
|
* │ └── p1-task2 (depends on p1-task1)
|
||||||
|
* ├── phase2 (depends on phase1)
|
||||||
|
* │ ├── p2-task1
|
||||||
|
* │ └── p2-task2
|
||||||
|
* └── cleanup (no priority, depends on phase2)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Epic root
|
||||||
|
store.addTask(makeTask("epic", { created: "2024-01-01" }));
|
||||||
|
|
||||||
|
// Phase 1 - priority 0 (should run first)
|
||||||
|
store.addTask(
|
||||||
|
makeTask("phase1", {
|
||||||
|
parentId: "epic",
|
||||||
|
priority: 0,
|
||||||
|
created: "2024-01-02",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("p1-task1", { parentId: "phase1", created: "2024-01-03" })
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("p1-task2", {
|
||||||
|
parentId: "phase1",
|
||||||
|
deps: ["p1-task1"],
|
||||||
|
created: "2024-01-04",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase 2 - depends on phase1
|
||||||
|
store.addTask(
|
||||||
|
makeTask("phase2", {
|
||||||
|
parentId: "epic",
|
||||||
|
deps: ["phase1"],
|
||||||
|
created: "2024-01-05",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("p2-task1", { parentId: "phase2", created: "2024-01-06" })
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("p2-task2", { parentId: "phase2", created: "2024-01-07" })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup - no priority, depends on phase2
|
||||||
|
store.addTask(
|
||||||
|
makeTask("cleanup", {
|
||||||
|
parentId: "epic",
|
||||||
|
deps: ["phase2"],
|
||||||
|
created: "2024-01-08",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store, "epic");
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
// Expected order:
|
||||||
|
// 1. p1-task1 (leaf, no deps)
|
||||||
|
// 2. p1-task2 (leaf, deps on p1-task1)
|
||||||
|
// 3. phase1 (parent, children done)
|
||||||
|
// 4. p2-task1, p2-task2 (leaves, phase1 done)
|
||||||
|
// 5. phase2 (parent, children done, dep on phase1 done)
|
||||||
|
// 6. cleanup (dep on phase2 done)
|
||||||
|
// 7. epic (all children done)
|
||||||
|
|
||||||
|
expect(ids).toEqual([
|
||||||
|
"p1-task1",
|
||||||
|
"p1-task2",
|
||||||
|
"phase1",
|
||||||
|
"p2-task1",
|
||||||
|
"p2-task2",
|
||||||
|
"phase2",
|
||||||
|
"cleanup",
|
||||||
|
"epic",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle priority override within same parent", async () => {
|
||||||
|
/**
|
||||||
|
* Parent with children where priority overrides created order:
|
||||||
|
*
|
||||||
|
* parent
|
||||||
|
* ├── child-a (no priority, created first)
|
||||||
|
* ├── child-b (priority 0, created second) <- should run first
|
||||||
|
* └── child-c (priority 1, created third)
|
||||||
|
*/
|
||||||
|
|
||||||
|
store.addTask(makeTask("parent", { created: "2024-01-01" }));
|
||||||
|
store.addTask(
|
||||||
|
makeTask("child-a", { parentId: "parent", created: "2024-01-02" })
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("child-b", {
|
||||||
|
parentId: "parent",
|
||||||
|
priority: 0,
|
||||||
|
created: "2024-01-03",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("child-c", {
|
||||||
|
parentId: "parent",
|
||||||
|
priority: 1,
|
||||||
|
created: "2024-01-04",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store, "parent");
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
// b first (priority 0), c second (priority 1), a last (no priority), then parent
|
||||||
|
expect(ids).toEqual(["child-b", "child-c", "child-a", "parent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should place done tasks first in historical order", async () => {
|
||||||
|
store.addTask(
|
||||||
|
makeTask("done-later", { status: "done", created: "2024-01-03" })
|
||||||
|
);
|
||||||
|
store.addTask(
|
||||||
|
makeTask("done-first", { status: "done", created: "2024-01-01" })
|
||||||
|
);
|
||||||
|
store.addTask(makeTask("pending", { created: "2024-01-02" }));
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
// Done tasks first (by created), then pending
|
||||||
|
expect(ids).toEqual(["done-first", "done-later", "pending"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark tasks with unresolvable deps as blocked", async () => {
|
||||||
|
// b depends on external-dep which doesn't exist in scope
|
||||||
|
store.addTask(makeTask("a", { created: "2024-01-01" }));
|
||||||
|
store.addTask(
|
||||||
|
makeTask("b", { deps: ["external-dep"], created: "2024-01-02" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
|
||||||
|
expect(result.timeline.map((t) => t.id)).toEqual(["a"]);
|
||||||
|
expect(result.blocked.has("b")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect circular dependencies as blocked", async () => {
|
||||||
|
// a depends on b, b depends on a
|
||||||
|
store.addTask(makeTask("a", { deps: ["b"], created: "2024-01-01" }));
|
||||||
|
store.addTask(makeTask("b", { deps: ["a"], created: "2024-01-02" }));
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
|
||||||
|
expect(result.timeline).toEqual([]);
|
||||||
|
expect(result.blocked.has("a")).toBe(true);
|
||||||
|
expect(result.blocked.has("b")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle in_progress tasks same as open", async () => {
|
||||||
|
store.addTask(makeTask("a", { status: "in_progress", created: "2024-01-01" }));
|
||||||
|
store.addTask(makeTask("b", { status: "open", created: "2024-01-02" }));
|
||||||
|
|
||||||
|
const result = await computeExecutionOrder(store);
|
||||||
|
const ids = result.timeline.map((t) => t.id);
|
||||||
|
|
||||||
|
expect(ids).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSortedChildrenMap", () => {
|
||||||
|
it("should sort children by execution order", () => {
|
||||||
|
const tasks: Task[] = [
|
||||||
|
makeTask("parent"),
|
||||||
|
makeTask("child-a", { parentId: "parent" }),
|
||||||
|
makeTask("child-b", { parentId: "parent" }),
|
||||||
|
makeTask("child-c", { parentId: "parent" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Execution order: child-c (0), child-a (1), child-b (2)
|
||||||
|
const orderMap = new Map<string, number>([
|
||||||
|
["child-c", 0],
|
||||||
|
["child-a", 1],
|
||||||
|
["child-b", 2],
|
||||||
|
["parent", 3],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const childrenMap = buildSortedChildrenMap(tasks, orderMap);
|
||||||
|
const children = childrenMap.get("parent")!;
|
||||||
|
|
||||||
|
expect(children.map((t) => t.id)).toEqual(["child-c", "child-a", "child-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tasks not in order map (put at end)", () => {
|
||||||
|
const tasks: Task[] = [
|
||||||
|
makeTask("parent"),
|
||||||
|
makeTask("child-a", { parentId: "parent" }),
|
||||||
|
makeTask("child-b", { parentId: "parent" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only child-b in order map
|
||||||
|
const orderMap = new Map<string, number>([["child-b", 0]]);
|
||||||
|
|
||||||
|
const childrenMap = buildSortedChildrenMap(tasks, orderMap);
|
||||||
|
const children = childrenMap.get("parent")!;
|
||||||
|
|
||||||
|
// child-b first (has order), child-a last (Infinity)
|
||||||
|
expect(children.map((t) => t.id)).toEqual(["child-b", "child-a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { Task, TaskStore } from "./types.js";
|
||||||
|
|
||||||
|
function sortByPriorityThenCreated(a: Task, b: Task): number {
|
||||||
|
if (a.priority !== undefined && b.priority === undefined) return -1;
|
||||||
|
if (a.priority === undefined && b.priority !== undefined) return 1;
|
||||||
|
if (a.priority !== undefined && b.priority !== undefined) {
|
||||||
|
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||||
|
}
|
||||||
|
return a.created.localeCompare(b.created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionOrderResult {
|
||||||
|
/** Tasks in execution order (done first, then pending) */
|
||||||
|
timeline: Task[];
|
||||||
|
/** Map from task ID to execution order index */
|
||||||
|
orderMap: Map<string, number>;
|
||||||
|
/** Task IDs that are blocked/unreachable */
|
||||||
|
blocked: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes execution order for tasks within a scope.
|
||||||
|
* Simulates running `task ready` repeatedly until no tasks remain.
|
||||||
|
*
|
||||||
|
* @param store - Task store for fetching tasks
|
||||||
|
* @param scopeId - Optional scope task ID (if omitted, uses all tasks)
|
||||||
|
* @returns Execution order result with timeline, order map, and blocked tasks
|
||||||
|
*/
|
||||||
|
export async function computeExecutionOrder(
|
||||||
|
store: TaskStore,
|
||||||
|
scopeId?: string
|
||||||
|
): Promise<ExecutionOrderResult> {
|
||||||
|
const allTasks = await store.list();
|
||||||
|
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
// Get scoped tasks
|
||||||
|
let candidates: Task[];
|
||||||
|
if (scopeId) {
|
||||||
|
const scopeTask = await store.get(scopeId);
|
||||||
|
const descendants = await store.getDescendants(scopeId);
|
||||||
|
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||||
|
} else {
|
||||||
|
candidates = allTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build children map
|
||||||
|
const childrenMap = new Map<string, Task[]>();
|
||||||
|
for (const t of allTasks) {
|
||||||
|
if (t.parentId) {
|
||||||
|
const siblings = childrenMap.get(t.parentId) ?? [];
|
||||||
|
siblings.push(t);
|
||||||
|
childrenMap.set(t.parentId, siblings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateIds = new Set(candidates.map((t) => t.id));
|
||||||
|
|
||||||
|
// Simulate execution: track done status
|
||||||
|
// Include all done tasks (not just in scope) for dep resolution
|
||||||
|
const simDone = new Set(
|
||||||
|
allTasks.filter((t) => t.status === "done").map((t) => t.id)
|
||||||
|
);
|
||||||
|
const remaining = new Set(
|
||||||
|
candidates
|
||||||
|
.filter((t) => t.status === "open" || t.status === "in_progress")
|
||||||
|
.map((t) => t.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isReady = (taskId: string): boolean => {
|
||||||
|
const task = taskMap.get(taskId);
|
||||||
|
if (!task) return false;
|
||||||
|
// All deps done (deps can be outside scope)
|
||||||
|
const depsOk = task.deps.every((depId) => simDone.has(depId));
|
||||||
|
// All children done (only consider children in scope)
|
||||||
|
const children = (childrenMap.get(taskId) ?? []).filter((c) =>
|
||||||
|
candidateIds.has(c.id)
|
||||||
|
);
|
||||||
|
const childrenOk = children.every((c) => simDone.has(c.id));
|
||||||
|
return depsOk && childrenOk;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeline: Task[] = [];
|
||||||
|
const orderMap = new Map<string, number>();
|
||||||
|
let orderIdx = 0;
|
||||||
|
|
||||||
|
// Done tasks first (by created date = historical execution order)
|
||||||
|
const done = candidates
|
||||||
|
.filter((t) => t.status === "done")
|
||||||
|
.sort((a, b) => a.created.localeCompare(b.created));
|
||||||
|
for (const t of done) {
|
||||||
|
timeline.push(t);
|
||||||
|
orderMap.set(t.id, orderIdx++);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then pending tasks in execution order
|
||||||
|
while (remaining.size > 0) {
|
||||||
|
const readyNow = [...remaining]
|
||||||
|
.filter(isReady)
|
||||||
|
.map((tid) => taskMap.get(tid)!)
|
||||||
|
.sort(sortByPriorityThenCreated);
|
||||||
|
|
||||||
|
if (readyNow.length === 0) break; // No more can be done (cycle or blocked)
|
||||||
|
|
||||||
|
const next = readyNow[0];
|
||||||
|
timeline.push(next);
|
||||||
|
orderMap.set(next.id, orderIdx++);
|
||||||
|
simDone.add(next.id);
|
||||||
|
remaining.delete(next.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining are blocked/unreachable
|
||||||
|
const blocked = remaining;
|
||||||
|
|
||||||
|
return { timeline, orderMap, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a map of parent ID to children sorted by execution order.
|
||||||
|
*/
|
||||||
|
export function buildSortedChildrenMap(
|
||||||
|
tasks: Task[],
|
||||||
|
orderMap: Map<string, number>
|
||||||
|
): Map<string, Task[]> {
|
||||||
|
const childrenMap = new Map<string, Task[]>();
|
||||||
|
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (t.parentId) {
|
||||||
|
const siblings = childrenMap.get(t.parentId) ?? [];
|
||||||
|
siblings.push(t);
|
||||||
|
childrenMap.set(t.parentId, siblings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each group by execution order
|
||||||
|
for (const [parentId, children] of childrenMap) {
|
||||||
|
children.sort((a, b) => {
|
||||||
|
const orderA = orderMap.get(a.id) ?? Infinity;
|
||||||
|
const orderB = orderMap.get(b.id) ?? Infinity;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
childrenMap.set(parentId, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return childrenMap;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user