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 });
|
||||
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,
|
||||
denyPermission,
|
||||
waitForAgentIdle,
|
||||
getToolCallCount,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
@@ -37,6 +38,10 @@ test.describe('permission prompts', () => {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// Verify exactly one tool call is visible (no duplicate permission badge)
|
||||
const toolCallCount = await getToolCallCount(page);
|
||||
expect(toolCallCount).toBe(1);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
@@ -61,6 +66,10 @@ test.describe('permission prompts', () => {
|
||||
await waitForAgentIdle(page);
|
||||
|
||||
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 {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -1067,6 +1067,7 @@ interface ExpandableBadgeProps {
|
||||
isError?: boolean;
|
||||
isLastInSequence?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const ExpandableBadge = memo(function ExpandableBadge({
|
||||
@@ -1081,6 +1082,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
isError = false,
|
||||
isLastInSequence = false,
|
||||
disableOuterSpacing,
|
||||
testID,
|
||||
}: ExpandableBadgeProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
@@ -1140,7 +1142,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[expandableBadgeStylesheet.container, style]}>
|
||||
<View style={[expandableBadgeStylesheet.container, style]} testID={testID}>
|
||||
<Pressable
|
||||
onPress={hasDetails ? onToggle : undefined}
|
||||
disabled={!hasDetails}
|
||||
@@ -1495,6 +1497,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={toolName}
|
||||
secondaryLabel={principalParam}
|
||||
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 {
|
||||
return {
|
||||
type: "timeline",
|
||||
@@ -643,21 +630,7 @@ function testHydratedUserMessagesPersist() {
|
||||
});
|
||||
}
|
||||
|
||||
// Test 8: Permission tool calls should not show in the timeline
|
||||
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
|
||||
// Test 8: Todo lists should consolidate into a single entry and update completions
|
||||
function testTodoListConsolidation() {
|
||||
const timestamp1 = new Date('2025-01-01T12:30: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('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
|
||||
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
|
||||
it('filters permission tool calls from the stream', testPermissionToolCallFiltering);
|
||||
it('consolidates todo list updates', testTodoListConsolidation);
|
||||
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
|
||||
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
|
||||
|
||||
@@ -480,14 +480,6 @@ function appendAgentToolCall(
|
||||
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 =
|
||||
/fail|error|deny|reject|cancel|abort|exception|refus/;
|
||||
const COMPLETED_STATUS_PATTERN =
|
||||
@@ -762,9 +754,6 @@ export function reduceStreamUpdate(
|
||||
case "reasoning":
|
||||
return appendThought(state, item.text, timestamp);
|
||||
case "tool_call": {
|
||||
if (isPermissionToolCall(item)) {
|
||||
return state;
|
||||
}
|
||||
nextState = appendAgentToolCall(
|
||||
state,
|
||||
{
|
||||
|
||||
@@ -1228,9 +1228,11 @@ export class DaemonClientV2 {
|
||||
pendingPermissionIds.add(request.id);
|
||||
}
|
||||
}
|
||||
let sawRunning =
|
||||
current?.status === "running" ||
|
||||
pendingPermissionIds.size > 0;
|
||||
|
||||
// Track whether we've seen a running state. Important: we only set this
|
||||
// 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;
|
||||
|
||||
const updatePendingPermissions = (msg: SessionOutboundMessage): void => {
|
||||
@@ -1267,10 +1269,11 @@ export class DaemonClientV2 {
|
||||
(msg.payload.pendingPermissions?.length ?? 0) > 0 ||
|
||||
pendingPermissionIds.size > 0;
|
||||
if (status === "running" || hasPendingPermissions) {
|
||||
sawRunning = true;
|
||||
sawRunningInQueue = true;
|
||||
queuedIdle = null; // Reset: any previous idle was before this run
|
||||
}
|
||||
if (
|
||||
sawRunning &&
|
||||
sawRunningInQueue &&
|
||||
(status === "idle" || status === "error") &&
|
||||
!hasPendingPermissions
|
||||
) {
|
||||
@@ -1281,6 +1284,15 @@ export class DaemonClientV2 {
|
||||
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(
|
||||
(msg) => {
|
||||
updatePendingPermissions(msg);
|
||||
|
||||
@@ -1013,6 +1013,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -504,14 +504,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawResolvedAllow).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "permission" &&
|
||||
item.status === "granted"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
@@ -578,14 +570,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
try {
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawResolvedDeny).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "permission" &&
|
||||
item.status === "denied"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
@@ -657,14 +641,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawResolvedInterrupt).toBe(true);
|
||||
expect(sawTerminalEvent).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "permission" &&
|
||||
item.status === "denied"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
|
||||
@@ -532,6 +532,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
_options?: AgentRunOptions
|
||||
): 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.
|
||||
// This prevents race conditions where an interrupted turn's events get mixed with the new turn.
|
||||
const turnId = ++this.currentTurnId;
|
||||
@@ -584,8 +585,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.logger.error({ err: error }, "Unexpected error in forwardPromptEvents");
|
||||
});
|
||||
|
||||
let eventCount = 0;
|
||||
try {
|
||||
console.error(`[CLAUDE-AGENT:stream] starting for-await-of loop on 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;
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
@@ -593,9 +598,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
finishedNaturally = true;
|
||||
console.error(`[CLAUDE-AGENT:stream] terminal event reached: ${event.type}, total events: ${eventCount}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.error(`[CLAUDE-AGENT:stream] queue iteration completed naturally, eventCount=${eventCount}`);
|
||||
} finally {
|
||||
if (!finishedNaturally && !cancelIssued) {
|
||||
requestCancel();
|
||||
@@ -679,12 +686,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.pushToolCall({
|
||||
name: "permission",
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
input: pending.request.input,
|
||||
});
|
||||
} else {
|
||||
const result: PermissionResult = {
|
||||
behavior: "deny",
|
||||
@@ -692,12 +693,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
interrupt: response.interrupt,
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.pushToolCall({
|
||||
name: "permission",
|
||||
status: "denied",
|
||||
callId: pending.request.id,
|
||||
input: pending.request.input,
|
||||
});
|
||||
}
|
||||
|
||||
this.pushEvent({
|
||||
@@ -967,6 +962,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
queue: Pushable<AgentStreamEvent>,
|
||||
turnId: number
|
||||
) {
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] START turnId=${turnId}`);
|
||||
// Create a turn-local context to track streaming state.
|
||||
// This prevents race conditions when a new stream() call interrupts a running one.
|
||||
const turnContext: TurnContext = {
|
||||
@@ -974,21 +970,27 @@ class ClaudeAgentSession implements AgentSession {
|
||||
streamedReasoningThisTurn: false,
|
||||
};
|
||||
let completedNormally = false;
|
||||
let eventCount = 0;
|
||||
try {
|
||||
for await (const sdkEvent of this.processPrompt(message, turnId)) {
|
||||
eventCount++;
|
||||
// Check if this turn has been superseded before pushing events
|
||||
if (this.currentTurnId !== turnId) {
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] turn superseded, breaking. currentTurnId=${this.currentTurnId} ourTurnId=${turnId}`);
|
||||
break;
|
||||
}
|
||||
const events = this.translateMessageToEvents(sdkEvent, turnContext);
|
||||
for (const event of events) {
|
||||
queue.push(event);
|
||||
if (event.type === "turn_completed") {
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] turn_completed pushed`);
|
||||
completedNormally = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] processPrompt loop done, eventCount=${eventCount} completedNormally=${completedNormally}`);
|
||||
} catch (error) {
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] ERROR: ${error}`);
|
||||
if (!this.turnCancelRequested && this.currentTurnId === turnId) {
|
||||
queue.push({
|
||||
type: "turn_failed",
|
||||
@@ -1001,7 +1003,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// Use turn_canceled (not turn_failed) to distinguish intentional interruption from errors.
|
||||
// Only emit if not already emitted by requestCancel() (indicated by turnCancelRequested).
|
||||
const wasSuperseded = this.currentTurnId !== turnId;
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] FINALLY wasSuperseded=${wasSuperseded} completedNormally=${completedNormally} turnCancelRequested=${this.turnCancelRequested}`);
|
||||
if (wasSuperseded && !completedNormally && !this.turnCancelRequested) {
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] pushing turn_canceled for superseded turn`);
|
||||
queue.push({
|
||||
type: "turn_canceled",
|
||||
provider: "claude",
|
||||
@@ -1009,6 +1013,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
this.turnCancelRequested = false;
|
||||
console.error(`[CLAUDE-AGENT:forwardPromptEvents] calling queue.end()`);
|
||||
queue.end();
|
||||
}
|
||||
}
|
||||
@@ -1016,15 +1021,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async interruptActiveTurn(): Promise<void> {
|
||||
const queryToInterrupt = this.query;
|
||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||
this.logger.info("interruptActiveTurn: no query to interrupt");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.logger.info("interruptActiveTurn: calling query.interrupt()...");
|
||||
const t0 = Date.now();
|
||||
await queryToInterrupt.interrupt();
|
||||
this.logger.info({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
|
||||
// After interrupt(), the query iterator is done (returns done: true).
|
||||
// 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.
|
||||
this.input?.end();
|
||||
this.logger.info("interruptActiveTurn: calling query.return()...");
|
||||
const t1 = Date.now();
|
||||
await queryToInterrupt.return?.();
|
||||
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
} catch (error) {
|
||||
@@ -1178,13 +1190,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||
};
|
||||
|
||||
this.pushToolCall({
|
||||
name: "permission",
|
||||
status: "requested",
|
||||
callId: requestId,
|
||||
input,
|
||||
});
|
||||
|
||||
this.pushEvent({ type: "permission_requested", provider: "claude", request });
|
||||
|
||||
return await new Promise<PermissionResult>((resolve, reject) => {
|
||||
@@ -1203,12 +1208,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.pendingPermissions.delete(requestId);
|
||||
cleanup();
|
||||
const error = new Error("Permission request timed out");
|
||||
this.pushToolCall({
|
||||
name: "permission",
|
||||
status: "denied",
|
||||
callId: requestId,
|
||||
input,
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
|
||||
@@ -16,6 +16,10 @@ import type {
|
||||
|
||||
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 {
|
||||
return mkdtempSync(path.join(os.tmpdir(), "codex-mcp-e2e-"));
|
||||
}
|
||||
@@ -406,6 +410,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
} satisfies AgentSessionConfig;
|
||||
@@ -474,6 +480,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
} satisfies AgentSessionConfig;
|
||||
@@ -502,6 +510,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
} satisfies AgentSessionConfig;
|
||||
@@ -530,7 +540,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
if (event.item.type === "reasoning") {
|
||||
sawReasoning = true;
|
||||
}
|
||||
if (event.item.type === "tool_call" && event.item.name !== "permission") {
|
||||
if (event.item.type === "tool_call") {
|
||||
toolCalls.push(event.item);
|
||||
}
|
||||
}
|
||||
@@ -604,6 +614,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const nodeModulesPath = resolveNodeModulesPath();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
extra: {
|
||||
@@ -750,6 +762,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const nodeModulesPath = resolveNodeModulesPath();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
extra: {
|
||||
@@ -783,7 +797,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -894,6 +908,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
} satisfies AgentSessionConfig;
|
||||
@@ -950,6 +966,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
} satisfies AgentSessionConfig;
|
||||
@@ -1027,6 +1045,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
} satisfies AgentSessionConfig;
|
||||
@@ -1063,6 +1083,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
@@ -1107,14 +1129,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(
|
||||
timelineItems.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "permission" &&
|
||||
item.status === "granted"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timelineItems.some(
|
||||
(item) => item.type === "tool_call" && item.name === "shell"
|
||||
@@ -1140,6 +1154,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "read-only",
|
||||
approvalPolicy: "on-request",
|
||||
@@ -1171,14 +1187,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
}
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(
|
||||
timelineItems.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "permission" &&
|
||||
item.status === "requested"
|
||||
)
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await session?.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
@@ -1197,6 +1205,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
@@ -1242,14 +1252,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
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);
|
||||
} finally {
|
||||
await session?.close();
|
||||
@@ -1269,6 +1271,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
@@ -1323,14 +1327,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
expect(
|
||||
timelineItems.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "permission" &&
|
||||
item.status === "denied"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(sawTurnFailed).toBe(true);
|
||||
const message = failureMessage ? failureMessage : "";
|
||||
expect(message).toMatch(/aborted|interrupted/i);
|
||||
@@ -1353,6 +1349,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
@@ -1428,6 +1426,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const client = new CodexMcpAgentClient();
|
||||
const config = {
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
|
||||
@@ -2555,6 +2555,11 @@ function buildCodexMcpConfig(
|
||||
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: {
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
@@ -2579,6 +2584,7 @@ function buildCodexMcpConfig(
|
||||
configPayload.model = config.model;
|
||||
}
|
||||
|
||||
|
||||
// Add developer instructions for session resume context
|
||||
if (developerInstructions) {
|
||||
configPayload["developer-instructions"] = developerInstructions;
|
||||
@@ -3113,17 +3119,6 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(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({
|
||||
type: "permission_resolved",
|
||||
@@ -3453,16 +3448,6 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
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({
|
||||
type: "permission_requested",
|
||||
provider: CODEX_PROVIDER,
|
||||
|
||||
@@ -26,6 +26,10 @@ function tmpCwd(): string {
|
||||
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>(
|
||||
timeoutMs: number,
|
||||
setup: (
|
||||
@@ -151,7 +155,9 @@ describe("daemon client v2 E2E", () => {
|
||||
});
|
||||
|
||||
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,
|
||||
title: "Daemon Client V2",
|
||||
requestId: createRequestId,
|
||||
@@ -192,7 +198,9 @@ describe("daemon client v2 E2E", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
title: "Should Fail",
|
||||
requestId: failRequestId,
|
||||
@@ -401,7 +409,9 @@ describe("daemon client v2 E2E", () => {
|
||||
const filePath = path.join(cwd, "permission.txt");
|
||||
|
||||
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,
|
||||
title: "Permission Test",
|
||||
modeId: "auto",
|
||||
@@ -475,7 +485,9 @@ describe("daemon client v2 E2E", () => {
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
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,
|
||||
title: "Raw Events Test",
|
||||
});
|
||||
@@ -656,7 +668,9 @@ describe("daemon client v2 E2E", () => {
|
||||
writeFileSync(downloadFile, downloadContents, "utf-8");
|
||||
|
||||
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,
|
||||
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) {
|
||||
console.error(`[INTERRUPT:${agentId.substring(0, 8)}] not running, skipping. lifecycle=${snapshot.lifecycle} pendingRun=${!!snapshot.pendingRun}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId },
|
||||
`Interrupting active run for agent ${agentId}`
|
||||
);
|
||||
console.error(`[INTERRUPT:${agentId.substring(0, 8)}] starting interrupt. lifecycle=${snapshot.lifecycle} pendingRun=${!!snapshot.pendingRun}`);
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const cancelled = await this.agentManager.cancelAgentRun(agentId);
|
||||
console.error(`[INTERRUPT] cancelAgentRun returned cancelled=${cancelled} in ${Date.now() - t0}ms`);
|
||||
if (!cancelled) {
|
||||
this.sessionLogger.warn(
|
||||
{ agentId },
|
||||
`Agent ${agentId} reported running but no active run was cancelled`
|
||||
);
|
||||
console.error(`[INTERRUPT] WARNING: reported running but no active run was cancelled`);
|
||||
}
|
||||
|
||||
// Wait for the agent to become idle after cancellation
|
||||
// cancelAgentRun only initiates cancellation; doesn't wait for generator to terminate
|
||||
const maxWaitMs = 5000;
|
||||
const pollIntervalMs = 50;
|
||||
const startTime = Date.now();
|
||||
@@ -392,15 +388,17 @@ export class Session {
|
||||
throw new Error(`Agent ${agentId} not found during cancellation wait`);
|
||||
}
|
||||
if (current.lifecycle !== "running" && !current.pendingRun) {
|
||||
console.error(`[INTERRUPT] agent became idle after ${Date.now() - startTime}ms`);
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
`Failed to interrupt agent ${agentId}`
|
||||
);
|
||||
console.error(`[INTERRUPT] ERROR:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -409,6 +407,7 @@ export class Session {
|
||||
* Start streaming an agent run and forward results via the websocket broadcast
|
||||
*/
|
||||
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(
|
||||
{ agentId },
|
||||
`Starting agent stream for ${agentId}`
|
||||
@@ -1007,6 +1006,7 @@ export class Session {
|
||||
agentId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
console.error(`[DELETE_AGENT] handleDeleteAgentRequest called for ${agentId} requestId=${requestId}`, new Error().stack);
|
||||
this.sessionLogger.info(
|
||||
{ agentId },
|
||||
`Deleting agent ${agentId} from registry`
|
||||
@@ -1060,14 +1060,18 @@ export class Session {
|
||||
messageId?: string,
|
||||
images?: Array<{ data: string; mimeType: string }>
|
||||
): Promise<void> {
|
||||
console.error(`[SESSION:handleSendAgentMessage] ENTERED agentId=${agentId.substring(0, 8)} text="${text.substring(0, 40)}"`);
|
||||
this.sessionLogger.info(
|
||||
{ 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)` : ''}`
|
||||
);
|
||||
|
||||
try {
|
||||
console.error(`[SESSION:handleSendAgentMessage] calling ensureAgentLoaded...`);
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
console.error(`[SESSION:handleSendAgentMessage] ensureAgentLoaded done`);
|
||||
} catch (error) {
|
||||
console.error(`[SESSION:handleSendAgentMessage] ensureAgentLoaded FAILED:`, error);
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
@@ -1077,8 +1081,12 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshotBeforeInterrupt = this.agentManager.getAgent(agentId);
|
||||
console.error(`[SESSION:handleSendAgentMessage] before interrupt: lifecycle=${snapshotBeforeInterrupt?.lifecycle} pendingRun=${!!snapshotBeforeInterrupt?.pendingRun}`);
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
console.error(`[SESSION:handleSendAgentMessage] interrupt done`);
|
||||
} catch (error) {
|
||||
console.error(`[SESSION:handleSendAgentMessage] interrupt FAILED:`, error);
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
@@ -1088,6 +1096,7 @@ export class Session {
|
||||
}
|
||||
|
||||
const prompt = this.buildAgentPrompt(text, images);
|
||||
console.error(`[SESSION:handleSendAgentMessage] calling recordUserMessage...`);
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, text, { messageId });
|
||||
@@ -1098,6 +1107,7 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
console.error(`[SESSION:handleSendAgentMessage] calling startAgentStream...`);
|
||||
this.startAgentStream(agentId, prompt);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { spawnSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { FileTaskStore } from "./task-store.js";
|
||||
import {
|
||||
computeExecutionOrder,
|
||||
buildSortedChildrenMap,
|
||||
} from "./execution-order.js";
|
||||
import type { AgentType, ModelName, Task } from "./types.js";
|
||||
|
||||
const TASKS_DIR = resolve(process.cwd(), ".tasks");
|
||||
@@ -65,11 +69,6 @@ Examples:
|
||||
# See completed work
|
||||
task closed --scope abc123
|
||||
|
||||
# Run agent loop on an epic
|
||||
task run abc123
|
||||
task run abc123 --agent codex
|
||||
task run --watch
|
||||
|
||||
Body vs Notes:
|
||||
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.
|
||||
@@ -77,6 +76,89 @@ Body vs Notes:
|
||||
- While defining a task: edit the body with "task update <id> --body ..."
|
||||
- While implementing: add notes with "task note <id> ..."
|
||||
- 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
|
||||
.command("tree <id>")
|
||||
.description("Show task hierarchy with dependencies")
|
||||
.action(async (id) => {
|
||||
const root = await store.get(id);
|
||||
if (!root) {
|
||||
process.stderr.write(`Task not found: ${id}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build a map of all tasks for dependency lookups
|
||||
.command("plan [scope]")
|
||||
.alias("tree")
|
||||
.description("Show execution timeline (tree view by default)")
|
||||
.option("--flat", "Show flat list instead of tree")
|
||||
.option("-d, --depth <n>", "Limit tree depth (0 = root only)")
|
||||
.action(async (scopeId: string | undefined, opts) => {
|
||||
const { timeline, orderMap, blocked } = await computeExecutionOrder(
|
||||
store,
|
||||
scopeId
|
||||
);
|
||||
const allTasks = await store.list();
|
||||
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 printTask = (task: Task, prefix: string, connector: string) => {
|
||||
const assignee = task.assignee ? ` @${task.assignee}` : "";
|
||||
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`);
|
||||
}
|
||||
const formatDeps = (task: Task): string => {
|
||||
if (task.deps.length === 0) return "";
|
||||
return ` (deps: ${task.deps.join(", ")})`;
|
||||
};
|
||||
|
||||
// Print root task
|
||||
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
|
||||
const rootPriority = root.priority !== undefined ? ` !${root.priority}` : "";
|
||||
process.stdout.write(`${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}\n`);
|
||||
if (root.deps.length > 0) {
|
||||
const depNames = root.deps
|
||||
.map((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep ? `${dep.title} (${depId})` : depId;
|
||||
})
|
||||
.join(", ");
|
||||
process.stdout.write(`→ depends on: ${depNames}\n`);
|
||||
if (opts.flat) {
|
||||
// Flat list view
|
||||
const printTask = (t: Task, idx: number) => {
|
||||
const priority = t.priority !== undefined ? `!${t.priority} ` : "";
|
||||
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
||||
const num = String(idx + 1).padStart(3, " ");
|
||||
const mark = t.status === "done" ? "✓" : " ";
|
||||
const deps = formatDeps(t);
|
||||
process.stdout.write(
|
||||
`${mark}${num}. ${priority}${t.id} ${t.title}${assignee}${deps}\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)
|
||||
const printChildren = async (parentId: string, prefix: string) => {
|
||||
const children = await store.getChildren(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);
|
||||
await printChildren(child.id, childPrefix);
|
||||
}
|
||||
};
|
||||
|
||||
await printChildren(id, "");
|
||||
if (blocked.size > 0) {
|
||||
process.stdout.write(`\n... +${blocked.size} blocked/unreachable\n`);
|
||||
}
|
||||
if (timeline.length === 0) {
|
||||
process.stdout.write("No tasks.\n");
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
@@ -590,7 +710,7 @@ ${task.body || "(no body)"}
|
||||
${acceptanceCriteriaText}${notesText}`;
|
||||
}
|
||||
|
||||
function makePlannerPrompt(task: Task, context: string, iteration: number): string {
|
||||
function makePlannerPrompt(task: Task, context: string, reason: string): string {
|
||||
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.
|
||||
|
||||
Current iteration: ${iteration}
|
||||
## Why you were called
|
||||
|
||||
${reason}
|
||||
|
||||
## 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.
|
||||
|
||||
## Task CLI Commands
|
||||
- \`task show ${task.id}\` - view task details with parent context
|
||||
- \`task list\` - see all tasks
|
||||
- \`task tree ${task.id}\` - see full task hierarchy
|
||||
- \`task children ${task.id}\` - list subtasks
|
||||
- \`task ready\` - see what's ready to work on
|
||||
- \`task create "title" --parent <id> --body "..." --accept "criterion"\` - create task (any level)
|
||||
- \`task update <id> --title "..." --body "..." --accept "criterion"\` - update task
|
||||
|
||||
### Understanding the plan
|
||||
- \`task plan ${task.id}\` - **START HERE**: shows full execution timeline (done ✓ then pending in order)
|
||||
- \`task tree ${task.id}\` - see task hierarchy with dependencies (parent → children)
|
||||
- \`task show <id>\` - view task details, body, acceptance criteria, and notes
|
||||
- \`task children <id>\` - list direct children of a 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 delete <id>\` - remove a task that's no longer needed
|
||||
- \`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
|
||||
|
||||
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
|
||||
- Reordered/reprioritized
|
||||
- Updated with better acceptance criteria
|
||||
- **Deleted** if no longer relevant (use \`task delete <id>\`)
|
||||
- **Reprioritized** to change execution order (use \`task update <id> -p <n>\`)
|
||||
- **Broken down** into subtasks
|
||||
- **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
|
||||
|
||||
@@ -660,6 +824,33 @@ Good examples:
|
||||
- "File src/utils/parser.ts exports parseConfig function"
|
||||
- "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
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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> => {
|
||||
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
|
||||
const context = await buildTaskContext(task, scopeId);
|
||||
const plannerPrompt = makePlannerPrompt(task, context, 1);
|
||||
const plannerPrompt = makePlannerPrompt(task, context, reason);
|
||||
runAgentWithModel(plannerPrompt, plannerModel, logFile);
|
||||
|
||||
// Check if planner created subtasks for this task
|
||||
@@ -895,7 +1097,13 @@ program
|
||||
}
|
||||
|
||||
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} ===`);
|
||||
await store.start(task.id);
|
||||
@@ -904,27 +1112,36 @@ program
|
||||
let iteration = 1;
|
||||
let taskDone = false;
|
||||
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) {
|
||||
const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`;
|
||||
log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`);
|
||||
|
||||
// Check for new steering notes before worker runs
|
||||
const preTask = await store.get(task.id);
|
||||
if (!preTask) break;
|
||||
const currentSteerCount = preTask.notes.filter(n => n.content.startsWith("STEER:")).length;
|
||||
if (enablePlanner && currentSteerCount > lastSeenSteerCount) {
|
||||
const newSteers = preTask.notes.filter(n => n.content.startsWith("STEER:")).slice(lastSeenSteerCount);
|
||||
log(logFile, `[STEER] New steering note detected - triggering planner`);
|
||||
lastSeenSteerCount = currentSteerCount;
|
||||
const shouldRestart = await runPlanner(preTask, `steering: ${newSteers.map(n => n.content).join("; ")}`);
|
||||
if (shouldRestart) {
|
||||
const updatedTask = await store.get(task.id);
|
||||
if (updatedTask && updatedTask.status === "in_progress") {
|
||||
await store.update(task.id, { status: "open" });
|
||||
// Check for new steering notes on scope before worker runs
|
||||
if (enablePlanner && scopeId) {
|
||||
const freshScope = await store.get(scopeId);
|
||||
if (freshScope) {
|
||||
const currentSteerCount = freshScope.notes.filter(n => n.content.startsWith("STEER:")).length;
|
||||
if (currentSteerCount > lastSeenSteerCount) {
|
||||
const newSteers = freshScope.notes
|
||||
.filter(n => n.content.startsWith("STEER:"))
|
||||
.slice(lastSeenSteerCount)
|
||||
.map(n => n.content.replace(/^STEER:\s*/, ""));
|
||||
log(logFile, `[STEER] New steering note detected - triggering planner`);
|
||||
lastSeenSteerCount = currentSteerCount;
|
||||
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;
|
||||
} else {
|
||||
consecutiveNotDone++;
|
||||
// Only replan after 5 consecutive NOT_DONEs
|
||||
if (enablePlanner && consecutiveNotDone >= 5) {
|
||||
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE - triggering planner`);
|
||||
const currentTask = await store.get(task.id);
|
||||
if (currentTask) {
|
||||
const shouldRestart = await runPlanner(currentTask, `${consecutiveNotDone} consecutive NOT_DONE`);
|
||||
// Upgrade to opus on first NOT_DONE (if not already using opus)
|
||||
if (canUpgrade && workerModel === "sonnet") {
|
||||
workerModel = "opus";
|
||||
log(logFile, `[WORKER] Upgrading to opus after 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;
|
||||
if (shouldRestart) {
|
||||
// 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