diff --git a/.gitignore b/.gitignore index 59a8831d..76eb0898 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,7 @@ skills-lock.json # Secrets / credentials telegram-pair-code.txt +/.contex +/.agent-memory +/.playwright-mcp +/.worktrees diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 51b06643..09ffefff 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -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, @@ -64,6 +65,7 @@ import type { SubagentStatus, TaskRequest, ToolCall, + ToolExecutionPhase, ToolResult, UsageSource, VerifyRecipe, @@ -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; @@ -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, @@ -1704,18 +1722,22 @@ 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, { @@ -1723,6 +1745,12 @@ export class Agent { 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, @@ -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; @@ -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, @@ -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); @@ -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, @@ -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]" }; @@ -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; } @@ -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; } @@ -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) { @@ -2194,6 +2246,7 @@ export class Agent { }; await this.fireHook(stopFailureInput, signal).catch(() => {}); + yield processPhaseChunk(observer, "summarize", "Turn failed"); yield { type: "done" }; return; } finally { @@ -2589,6 +2642,35 @@ function notifyObserver(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"); +} + +function isVerifyRequest(message: string): boolean { + return /^\s*(\/verify|run a local verification pass)\b/i.test(message); +} + function getStepNumber(event: unknown, fallback: number): number { if (event && typeof event === "object" && "stepNumber" in event && typeof event.stepNumber === "number") { return event.stepNumber; diff --git a/src/headless/output.test.ts b/src/headless/output.test.ts index 3f4e5579..498cbaf7 100644 --- a/src/headless/output.test.ts +++ b/src/headless/output.test.ts @@ -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"); @@ -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"); diff --git a/src/headless/output.ts b/src/headless/output.ts index 9bb59384..f05f4d2a 100644 --- a/src/headless/output.ts +++ b/src/headless/output.ts @@ -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"; @@ -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; @@ -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` } : {}; @@ -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`; } @@ -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 { @@ -283,6 +341,10 @@ export function createHeadlessJsonlEmitter(sessionId?: string): { break; } + case "process_phase": + case "tool_phase": + break; + case "error": stdout += jsonLine( withSession({ diff --git a/src/types/index.ts b/src/types/index.ts index cc6e4685..5c30da9a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -188,8 +188,11 @@ export interface ToolCall { }; } +export type AgentProcessPhase = "understand" | "inspect" | "execute_tools" | "review" | "verify" | "summarize"; +export type ToolExecutionPhase = "queued" | "started" | "finished" | "failed"; + export interface ChatEntry { - type: "user" | "assistant" | "tool_call" | "tool_result"; + type: "user" | "assistant" | "tool_call" | "tool_result" | "phase"; content: string; timestamp: Date; modeColor?: string; @@ -199,6 +202,7 @@ export interface ChatEntry { toolCalls?: ToolCall[]; toolCall?: ToolCall; toolResult?: ToolResult; + phase?: AgentProcessPhase | ToolExecutionPhase; } export interface PaymentPrecheck { @@ -212,11 +216,23 @@ export interface PaymentPrecheck { } export interface StreamChunk { - type: "content" | "tool_calls" | "tool_result" | "tool_approval_request" | "done" | "error" | "reasoning"; + type: + | "content" + | "tool_calls" + | "tool_result" + | "tool_approval_request" + | "process_phase" + | "tool_phase" + | "done" + | "error" + | "reasoning"; content?: string; toolCalls?: ToolCall[]; toolCall?: ToolCall; toolResult?: ToolResult; + processPhase?: AgentProcessPhase; + toolPhase?: ToolExecutionPhase; + detail?: string; approvalId?: string; paymentPrecheck?: PaymentPrecheck; isAuthError?: boolean; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 6c2eab44..6f733777 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -22,6 +22,7 @@ import { createTurnCoordinator } from "../telegram/turn-coordinator"; import type { ScheduleDaemonStatus, StoredSchedule } from "../tools/schedule"; import type { AgentMode, + AgentProcessPhase, ChatEntry, FileDiff, ModelInfo, @@ -30,6 +31,7 @@ import type { ReasoningEffort, SubagentStatus, ToolCall, + ToolExecutionPhase, ToolResult, } from "../types/index"; import { MODES } from "../types/index"; @@ -86,6 +88,7 @@ import { buildScheduleBrowseRows, ScheduleBrowserModal } from "./schedule-modal" import { filterSlashMenuItems, SLASH_MENU_ITEMS, type SlashMenuItem } from "./slash-menu"; import { buildAssistantEntry, + buildPhaseEntry, buildToolResultEntry, buildUserEntry, decorateTelegramEntries, @@ -292,23 +295,27 @@ const _LINE = { rightT: "━", }; -const REVIEW_PROMPT = `Review all current changes in this repository. Follow these steps: +const REVIEW_PROMPT = `Review all current changes in this repository using the agentic review loop. -1. Run \`git status\` to see which files have been modified, staged, or are untracked. -2. Run \`git diff\` to see unstaged changes and \`git diff --cached\` to see staged changes. -3. If there are no changes at all, say so and stop. -4. Read any changed files in full if needed for context. +Process phases: +1. Understand: run \`git status\` and identify staged, unstaged, and untracked changes. +2. Inspect: run \`git diff\` and \`git diff --cached\`; read changed files in full when needed for context. +3. Review: prioritize bugs, regressions, security issues, missing error handling, and missing tests. +4. Verify: run focused checks or tests when they are discoverable and reasonably scoped. +5. Summarize: report findings first, then residual risks and verification. + +Use the task tool for a focused review pass when the change spans multiple files or would benefit from a second read. If there are no changes at all, say so and stop. Then produce a **Review Report** in this exact structure: -## Summary -One paragraph overview of what changed and why (inferred from the diff). +## Findings +List issues first, ordered by severity, with file and line references where possible. If none, say "No issues found." ## Files Changed For each changed file, list the filename and a brief description of the change. -## Issues Found -List any bugs, logic errors, security concerns, missing error handling, or correctness problems. If none, say "No issues found." +## Verification +List commands/checks run and their results. If no checks were run, explain why. ## Suggestions Code quality, naming, performance, and best-practice improvements. If none, say "No suggestions." @@ -1508,6 +1515,25 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) [scrollToBottom], ); + const appendLivePhase = useCallback( + (content: string, phase?: ChatEntry["phase"]) => { + const activeTurn = activeTurnRef.current; + if (!activeTurn) return; + + flushPendingAssistantMessage(); + setMessages((prev) => [ + ...prev, + buildPhaseEntry(phase ?? "understand", content, { + modeColor: activeTurn.modeColor, + remoteKey: activeTurn.remoteKey, + sourceLabel: activeTurn.sourceLabel, + }), + ]); + setTimeout(scrollToBottom, 10); + }, + [flushPendingAssistantMessage, scrollToBottom], + ); + const syncTelegramTurnEntries = useCallback((activeTurn: ActiveTurnState) => { if (activeTurn.kind !== "telegram" || activeTurn.userId === undefined || !activeTurn.remoteKey) return; @@ -2086,6 +2112,16 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) appendLiveToolResult(chunk.toolCall, chunk.toolResult); } break; + case "process_phase": + if (chunk.processPhase) { + appendLivePhase(formatProcessPhaseEntry(chunk.processPhase, chunk.detail), chunk.processPhase); + } + break; + case "tool_phase": + if (chunk.toolPhase) { + appendLivePhase(formatToolPhaseEntry(chunk.toolPhase, chunk.toolCall, chunk.detail), chunk.toolPhase); + } + break; case "tool_approval_request": if (chunk.toolCall && chunk.approvalId) { let args: Record = {}; @@ -2151,6 +2187,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) [ agent, appendLiveToolResult, + appendLivePhase, applyLocalAssistantDelta, beginLiveTurn, finalizeActiveTurn, @@ -4284,6 +4321,16 @@ function MessageView({ ); + case "phase": + return ( + + + {"• "} + {entry.content} + + + ); + case "tool_result": { const name = entry.toolCall?.function.name || "tool"; const args = toolArgs(entry.toolCall); @@ -5834,6 +5881,17 @@ function toolLabel(tc: ToolCall): string { if (tc.function.name === "generate_plan") return "Generating plan..."; return trunc(`${tc.function.name} ${args}`, 80); } + +function formatProcessPhaseEntry(phase: AgentProcessPhase, detail?: string): string { + const label = phase.replace(/_/g, " "); + return detail ? `${label}: ${detail}` : label; +} + +function formatToolPhaseEntry(phase: ToolExecutionPhase, toolCall?: ToolCall, detail?: string): string { + const label = toolCall ? toolLabel(toolCall) : "tool"; + return detail ? `${label}: ${detail}` : `${label}: ${phase}`; +} + function sanitizeContent(raw: string): string { let s = raw.replace(/^[\s\n]*assistant:\s*/gi, ""); s = s.replace(/\{"success"\s*:\s*(true|false)\s*,\s*"output"\s*:\s*"[\s\S]*$/m, ""); diff --git a/src/ui/telegram-turn-ui.test.ts b/src/ui/telegram-turn-ui.test.ts index 8a0d2198..839f66aa 100644 --- a/src/ui/telegram-turn-ui.test.ts +++ b/src/ui/telegram-turn-ui.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ChatEntry, ToolCall } from "../types/index"; import { + buildPhaseEntry, buildToolResultEntry, decorateTelegramEntries, getTelegramSourceLabel, @@ -45,6 +46,18 @@ describe("telegram turn ui helpers", () => { }); }); + it("builds phase entries with decoration metadata", () => { + expect( + buildPhaseEntry("inspect", "inspect: Preparing context", { modeColor: "#abc", remoteKey: "turn-1" }), + ).toMatchObject({ + type: "phase", + phase: "inspect", + content: "inspect: Preparing context", + modeColor: "#abc", + remoteKey: "turn-1", + }); + }); + it("replaces only the temporary entries for the finished telegram turn", () => { const before: ChatEntry[] = [ { type: "assistant", content: "local session", timestamp: new Date() }, diff --git a/src/ui/telegram-turn-ui.ts b/src/ui/telegram-turn-ui.ts index e8a82e0b..11e694b9 100644 --- a/src/ui/telegram-turn-ui.ts +++ b/src/ui/telegram-turn-ui.ts @@ -1,4 +1,4 @@ -import type { ChatEntry, ToolCall, ToolResult } from "../types/index"; +import type { AgentProcessPhase, ChatEntry, ToolCall, ToolExecutionPhase, ToolResult } from "../types/index"; export interface EntryDecoration { modeColor?: string; @@ -49,6 +49,22 @@ export function buildToolResultEntry( }; } +export function buildPhaseEntry( + phase: AgentProcessPhase | ToolExecutionPhase, + content: string, + decoration: EntryDecoration = {}, +): ChatEntry { + return { + type: "phase", + content, + timestamp: new Date(), + modeColor: decoration.modeColor, + remoteKey: decoration.remoteKey, + sourceLabel: decoration.sourceLabel, + phase, + }; +} + export function getUnflushedTelegramAssistantContent(fullContent: string, flushedChars: number): string { const safeStart = Math.max(0, Math.min(flushedChars, fullContent.length)); return fullContent.slice(safeStart);