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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,7 @@ skills-lock.json

# Secrets / credentials
telegram-pair-code.txt
/.contex
/.agent-memory
/.playwright-mcp
/.worktrees
82 changes: 82 additions & 0 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { BashTool } from "../tools/bash";
import { type ScheduleDaemonStatus, ScheduleManager, type StoredSchedule } from "../tools/schedule";
import type {
AgentMode,
AgentProcessPhase,
ChatEntry,
Plan,
SessionInfo,
Expand All @@ -64,6 +65,7 @@ import type {
SubagentStatus,
TaskRequest,
ToolCall,
ToolExecutionPhase,
ToolResult,
UsageSource,
VerifyRecipe,
Expand Down Expand Up @@ -148,7 +150,22 @@ export interface ProcessMessageError {
timestamp: number;
}

export interface ProcessMessageProcessPhase {
phase: AgentProcessPhase;
detail?: string;
timestamp: number;
}

export interface ProcessMessageToolPhase {
phase: ToolExecutionPhase;
toolCall: ToolCall;
detail?: string;
timestamp: number;
}

export interface ProcessMessageObserver {
onProcessPhase?(info: ProcessMessageProcessPhase): void;
onToolPhase?(info: ProcessMessageToolPhase): void;
onStepStart?(info: ProcessMessageStepStart): void;
onStepFinish?(info: ProcessMessageStepFinish): void;
onToolStart?(info: ProcessMessageToolStart): void;
Expand Down Expand Up @@ -1594,6 +1611,7 @@ export class Agent {
const settings = attemptedOverflowRecovery
? relaxCompactionSettings(this.getCompactionSettings())
: this.getCompactionSettings();
yield processPhaseChunk(observer, "inspect", "Preparing batch context and tools");
if (modelInfo) {
await this.compactForContext(
provider,
Expand Down Expand Up @@ -1704,25 +1722,35 @@ export class Agent {
}
this.appendCompletedTurn(userModelMessage, turnMessages);
await this.refreshSessionRecap(signal);
yield processPhaseChunk(observer, "summarize", "Turn complete");
yield { type: "done" };
return;
}

yield processPhaseChunk(observer, "execute_tools", "Executing requested tools");
yield { type: "tool_calls", toolCalls };

const toolParts: ExecutedBatchTool[] = [];
for (const toolCall of toolCalls) {
yield toolPhaseChunk(observer, "queued", toolCall, "Tool queued");
notifyObserver(observer?.onToolStart, {
toolCall,
timestamp: Date.now(),
});
yield toolPhaseChunk(observer, "started", toolCall, "Tool execution started");

const executed = await this.executeBatchToolCall(tools, toolCall, requestMessages, signal);
notifyObserver(observer?.onToolFinish, {
toolCall,
toolResult: executed.result,
timestamp: Date.now(),
});
yield toolPhaseChunk(
observer,
executed.result.success ? "finished" : "failed",
toolCall,
executed.result.success ? "Tool completed" : "Tool failed",
);
yield { type: "tool_result", toolCall, toolResult: executed.result };
toolParts.push({
toolCall,
Expand Down Expand Up @@ -1752,6 +1780,7 @@ export class Agent {
this.recordUsage(totalUsage, "message", runtime.modelId);
}
this.appendCompletedTurn(userModelMessage, turnMessages);
yield processPhaseChunk(observer, "summarize", "Turn failed");
yield { type: "error", content: message };
yield { type: "done" };
return;
Expand All @@ -1778,6 +1807,7 @@ export class Agent {
this.recordUsage(totalUsage, "message", runtime.modelId);
}
this.appendCompletedTurn(userModelMessage, turnMessages);
yield processPhaseChunk(observer, "summarize", "Turn failed");
yield {
type: "error",
content: friendly,
Expand Down Expand Up @@ -1850,6 +1880,13 @@ export class Agent {
await this.fireHook(promptInput, signal).catch(() => {});

await this.consumeBackgroundNotifications();
yield processPhaseChunk(observer, "understand", "Prompt accepted");
if (isReviewRequest(userMessage)) {
yield processPhaseChunk(observer, "review", "Review loop requested");
}
if (isVerifyRequest(userMessage)) {
yield processPhaseChunk(observer, "verify", "Verification requested");
}
const userModelMessages = await buildVisionUserMessages(userMessage, this.bash.getCwd(), signal);
const userModelMessage = userModelMessages[0] ?? ({ role: "user", content: userMessage } satisfies ModelMessage);
this.messages.push(userModelMessage);
Expand Down Expand Up @@ -1907,6 +1944,7 @@ export class Agent {
const settings = attemptedOverflowRecovery
? relaxCompactionSettings(this.getCompactionSettings())
: this.getCompactionSettings();
yield processPhaseChunk(observer, "inspect", "Preparing context and tools");
if (modelInfo) {
await this.compactForContext(
provider,
Expand Down Expand Up @@ -1972,6 +2010,7 @@ export class Agent {
},
});

let emittedExecutePhase = false;
for await (const part of result.fullStream) {
if (signal.aborted) {
yield { type: "content", content: "\n\n[Cancelled]" };
Expand Down Expand Up @@ -1999,11 +2038,17 @@ export class Agent {
case "tool-call": {
const tc = toToolCall(part);
activeToolCalls.push(tc);
if (!emittedExecutePhase) {
emittedExecutePhase = true;
yield processPhaseChunk(observer, "execute_tools", "Executing requested tools");
}
notifyObserver(observer?.onToolStart, {
toolCall: tc,
timestamp: Date.now(),
});
yield { type: "tool_calls", toolCalls: [tc] };
yield toolPhaseChunk(observer, "queued", tc, "Tool queued");
yield toolPhaseChunk(observer, "started", tc, "Tool execution started");
break;
}

Expand All @@ -2019,6 +2064,12 @@ export class Agent {
toolResult: tr,
timestamp: Date.now(),
});
yield toolPhaseChunk(
observer,
tr.success ? "finished" : "failed",
tc,
tr.success ? "Tool completed" : "Tool failed",
);
yield { type: "tool_result", toolCall: tc, toolResult: tr };
break;
}
Expand Down Expand Up @@ -2156,6 +2207,7 @@ export class Agent {
};
await this.fireHook(stopInput, signal).catch(() => {});

yield processPhaseChunk(observer, "summarize", "Turn complete");
yield { type: "done" };
return;
} catch (err: unknown) {
Expand Down Expand Up @@ -2194,6 +2246,7 @@ export class Agent {
};
await this.fireHook(stopFailureInput, signal).catch(() => {});

yield processPhaseChunk(observer, "summarize", "Turn failed");
yield { type: "done" };
return;
} finally {
Expand Down Expand Up @@ -2589,6 +2642,35 @@ function notifyObserver<T>(listener: ((payload: T) => void) | undefined, payload
}
}

function processPhaseChunk(
observer: ProcessMessageObserver | undefined,
phase: AgentProcessPhase,
detail?: string,
): StreamChunk {
const timestamp = Date.now();
notifyObserver(observer?.onProcessPhase, { phase, detail, timestamp });
return { type: "process_phase", processPhase: phase, detail };
}

function toolPhaseChunk(
observer: ProcessMessageObserver | undefined,
phase: ToolExecutionPhase,
toolCall: ToolCall,
detail?: string,
): StreamChunk {
const timestamp = Date.now();
notifyObserver(observer?.onToolPhase, { phase, toolCall, detail, timestamp });
return { type: "tool_phase", toolPhase: phase, toolCall, detail };
}

function isReviewRequest(message: string): boolean {
return /^\s*review\b/i.test(message) || message.includes("Review Report");
}
Comment on lines +2666 to +2668

function isVerifyRequest(message: string): boolean {
return /^\s*(\/verify|run a local verification pass)\b/i.test(message);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isVerifyRequest never matches the actual verify prompt

Medium Severity

isVerifyRequest checks for messages starting with /verify or "run a local verification pass", but every caller that triggers a verify (/verify slash command, --verify CLI flag) expands the prompt via buildVerifyPrompt() before passing it to processMessage. That expanded prompt starts with "Verify this project locally…", which matches neither pattern. As a result, the "verify" process phase event is never emitted — unlike isReviewRequest, which correctly matches the expanded REVIEW_PROMPT because it starts with "Review".

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f4923b9. Configure here.


function getStepNumber(event: unknown, fallback: number): number {
if (event && typeof event === "object" && "stepNumber" in event && typeof event.stepNumber === "number") {
return event.stepNumber;
Expand Down
44 changes: 44 additions & 0 deletions src/headless/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ describe("headless output helpers", () => {
});
});

it("renders process and tool phases for text mode", () => {
expect(
renderHeadlessChunk({ type: "process_phase", processPhase: "inspect", detail: "Preparing context" }),
).toEqual({
stderr: "\u001b[2m• inspect: Preparing context\u001b[0m\n",
});
expect(renderHeadlessChunk({ type: "tool_phase", toolPhase: "started", detail: "Tool execution started" })).toEqual(
{
stderr: "\u001b[2m• tool started: Tool execution started\u001b[0m\n",
},
);
});

it("emits semantic JSONL for a single step with text and tool (json emitter)", () => {
const sessionId = "jsonl-test-session";
const tc = toolCall("bash");
Expand Down Expand Up @@ -149,6 +162,37 @@ describe("headless output helpers", () => {
});
});

it("emits process and tool phase JSONL events from observer hooks", () => {
const sessionId = "phase-session";
const tc = toolCall("bash");
const { observer, flush } = createHeadlessJsonlEmitter(sessionId);

observer.onProcessPhase?.({ phase: "inspect", detail: "Preparing context", timestamp: 10 });
observer.onToolPhase?.({ phase: "started", toolCall: tc, detail: "Tool execution started", timestamp: 20 });

const events = (flush().stdout ?? "")
.trim()
.split("\n")
.map((l) => JSON.parse(l));

expect(events.map((e) => e.type)).toEqual(["process_phase", "tool_phase"]);
expect(events[0]).toMatchObject({
type: "process_phase",
sessionID: sessionId,
phase: "inspect",
detail: "Preparing context",
timestamp: 10,
});
expect(events[1]).toMatchObject({
type: "tool_phase",
sessionID: sessionId,
phase: "started",
toolCall: tc,
detail: "Tool execution started",
timestamp: 20,
});
});

it("does not emit empty text events at step_finish when tools already flushed assistant text", () => {
const sessionId = "sess-2";
const tc = toolCall("bash");
Expand Down
64 changes: 63 additions & 1 deletion src/headless/output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ProcessMessageObserver, ProcessMessageStepFinish, ProcessMessageStepStart } from "../agent/agent";
import type { StreamChunk, ToolCall, ToolResult } from "../types";
import type { AgentProcessPhase, StreamChunk, ToolCall, ToolExecutionPhase, ToolResult } from "../types";

export type HeadlessOutputFormat = "text" | "json";

Expand Down Expand Up @@ -37,6 +37,21 @@ export type HeadlessJsonEvent =
durationMs?: number;
};
}
| {
type: "process_phase";
sessionID?: string;
phase: AgentProcessPhase;
detail?: string;
timestamp: number;
}
| {
type: "tool_phase";
sessionID?: string;
phase: ToolExecutionPhase;
toolCall: ToolCall;
detail?: string;
timestamp: number;
}
| {
type: "step_finish";
sessionID?: string;
Expand Down Expand Up @@ -104,6 +119,16 @@ export function renderHeadlessChunk(chunk: StreamChunk): HeadlessWrites {
return { stderr: `${stderr}\n` };
}

case "process_phase":
return chunk.processPhase
? { stderr: `\x1b[2m• ${formatProcessPhase(chunk.processPhase)}${formatDetail(chunk.detail)}\x1b[0m\n` }
: {};

case "tool_phase":
return chunk.toolPhase
? { stderr: `\x1b[2m• ${formatToolPhase(chunk.toolPhase)}${formatDetail(chunk.detail)}\x1b[0m\n` }
: {};

case "error":
return chunk.content ? { stderr: `\x1b[31m${chunk.content}\x1b[0m\n` } : {};

Expand Down Expand Up @@ -144,6 +169,18 @@ function formatToolCallLabel(tc: ToolCall): string {
return name;
}

function formatProcessPhase(phase: AgentProcessPhase): string {
return phase.replace(/_/g, " ");
}

function formatToolPhase(phase: ToolExecutionPhase): string {
return `tool ${phase}`;
}

function formatDetail(detail: string | undefined): string {
return detail ? `: ${detail}` : "";
}

function jsonLine(event: HeadlessJsonEvent): string {
return `${JSON.stringify(event)}\n`;
}
Expand Down Expand Up @@ -212,6 +249,27 @@ export function createHeadlessJsonlEmitter(sessionId?: string): {
const prev = toolTiming.get(info.toolCall.id) ?? {};
toolTiming.set(info.toolCall.id, { ...prev, finishedAt: info.timestamp });
},
onProcessPhase(info) {
pending += jsonLine(
withSession({
type: "process_phase",
phase: info.phase,
...(info.detail ? { detail: info.detail } : {}),
timestamp: info.timestamp,
}) as HeadlessJsonEvent,
);
},
onToolPhase(info) {
pending += jsonLine(
withSession({
type: "tool_phase",
phase: info.phase,
toolCall: info.toolCall,
...(info.detail ? { detail: info.detail } : {}),
timestamp: info.timestamp,
}) as HeadlessJsonEvent,
);
},
};

function drainPending(): string {
Expand Down Expand Up @@ -283,6 +341,10 @@ export function createHeadlessJsonlEmitter(sessionId?: string): {
break;
}

case "process_phase":
case "tool_phase":
break;
Comment on lines +344 to +346

case "error":
stdout += jsonLine(
withSession({
Expand Down
Loading
Loading