mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
905 lines
25 KiB
TypeScript
905 lines
25 KiB
TypeScript
/**
|
|
* JsonEventEmitter - Handles structured JSON output for the CLI
|
|
*
|
|
* This class transforms internal CLI events (ClineMessage, state changes, etc.)
|
|
* into structured JSON events and outputs them to stdout.
|
|
*
|
|
* Supports two output modes:
|
|
* - "stream-json": NDJSON format (one JSON object per line) for real-time streaming
|
|
* - "json": Single JSON object at the end with accumulated events
|
|
*
|
|
* Schema is optimized for efficiency with high message volume:
|
|
* - Minimal fields per event
|
|
* - No redundant wrappers
|
|
* - `done` flag instead of partial:false
|
|
*/
|
|
|
|
import type { ClineMessage } from "@roo-code/types"
|
|
|
|
import type { JsonEvent, JsonEventCost, JsonEventQueueItem, JsonFinalOutput } from "@/types/json-events.js"
|
|
|
|
import type { ExtensionClient } from "./extension-client.js"
|
|
import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js"
|
|
import { AgentLoopState } from "./agent-state.js"
|
|
|
|
/**
|
|
* Options for JsonEventEmitter.
|
|
*/
|
|
export interface JsonEventEmitterOptions {
|
|
/** Output mode: "json" or "stream-json" */
|
|
mode: "json" | "stream-json"
|
|
/** Output stream (defaults to process.stdout) */
|
|
stdout?: NodeJS.WriteStream
|
|
/** Optional request id provider for correlating stream events */
|
|
requestIdProvider?: () => string | undefined
|
|
/** Transport schema version emitted in system:init */
|
|
schemaVersion?: number
|
|
/** Transport protocol identifier emitted in system:init */
|
|
protocol?: string
|
|
/** Supported stdin protocol capabilities emitted in system:init */
|
|
capabilities?: string[]
|
|
}
|
|
|
|
/**
|
|
* Parse tool information from a ClineMessage text field.
|
|
* Tool messages are JSON with a `tool` field containing the tool name.
|
|
*/
|
|
function parseToolInfo(text: string | undefined): { name: string; input: Record<string, unknown> } | null {
|
|
if (!text) return null
|
|
try {
|
|
const parsed = JSON.parse(text)
|
|
return parsed.tool ? { name: parsed.tool, input: parsed } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse API request cost information from api_req_started message text.
|
|
*/
|
|
function parseApiReqCost(text: string | undefined): JsonEventCost | undefined {
|
|
if (!text) return undefined
|
|
try {
|
|
const parsed = JSON.parse(text)
|
|
return parsed.cost !== undefined
|
|
? {
|
|
totalCost: parsed.cost,
|
|
inputTokens: parsed.tokensIn,
|
|
outputTokens: parsed.tokensOut,
|
|
cacheWrites: parsed.cacheWrites,
|
|
cacheReads: parsed.cacheReads,
|
|
}
|
|
: undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Internal events that should not be emitted */
|
|
const SKIP_SAY_TYPES = new Set([
|
|
"api_req_finished",
|
|
"api_req_retried",
|
|
"api_req_retry_delayed",
|
|
"api_req_rate_limit_wait",
|
|
"api_req_deleted",
|
|
"checkpoint_saved",
|
|
"condense_context",
|
|
"condense_context_error",
|
|
"sliding_window_truncation",
|
|
])
|
|
|
|
/** Key offset for reasoning content to avoid collision with text content delta tracking */
|
|
const REASONING_KEY_OFFSET = 1_000_000_000
|
|
/** Grace period to wait for final say:command_output after status:exited */
|
|
const COMMAND_OUTPUT_EXIT_GRACE_MS = 250
|
|
|
|
export class JsonEventEmitter {
|
|
private mode: "json" | "stream-json"
|
|
private stdout: NodeJS.WriteStream
|
|
private events: JsonEvent[] = []
|
|
private unsubscribers: (() => void)[] = []
|
|
private pendingWrites = new Set<Promise<void>>()
|
|
private lastCost: JsonEventCost | undefined
|
|
private requestIdProvider: () => string | undefined
|
|
private schemaVersion: number
|
|
private protocol: string
|
|
private capabilities: string[]
|
|
private seenMessageIds = new Set<number>()
|
|
// Track previous content for delta computation
|
|
private previousContent = new Map<number, string>()
|
|
// Track previous tool-use content for structured (non-append-only) delta computation.
|
|
private previousToolUseContent = new Map<number, string>()
|
|
// Track the currently active execute_command tool_use id for command_output correlation.
|
|
private activeCommandToolUseId: number | undefined
|
|
// Track command output snapshots by command tool-use id for delta computation.
|
|
private previousCommandOutputByToolUseId = new Map<number, string>()
|
|
// Track command ids whose output is being streamed from commandExecutionStatus updates.
|
|
private statusDrivenCommandOutputIds = new Set<number>()
|
|
// Track command ids that already emitted a terminal command_output done event.
|
|
private completedCommandOutputIds = new Set<number>()
|
|
// Track exited commands awaiting final say:command_output completion.
|
|
private pendingCommandCompletionByToolUseId = new Map<number, { exitCode?: number; timer: NodeJS.Timeout }>()
|
|
// Track the completion result content
|
|
private completionResultContent: string | undefined
|
|
// Track the latest assistant text as a fallback for result.content.
|
|
private lastAssistantText: string | undefined
|
|
// The first non-partial "say:text" per task is the echoed user prompt.
|
|
private expectPromptEchoAsUser = true
|
|
|
|
constructor(options: JsonEventEmitterOptions) {
|
|
this.mode = options.mode
|
|
this.stdout = options.stdout ?? process.stdout
|
|
this.requestIdProvider = options.requestIdProvider ?? (() => undefined)
|
|
this.schemaVersion = options.schemaVersion ?? 1
|
|
this.protocol = options.protocol ?? "roo-cli-stream"
|
|
this.capabilities = options.capabilities ?? [
|
|
"stdin:start",
|
|
"stdin:message",
|
|
"stdin:cancel",
|
|
"stdin:ping",
|
|
"stdin:shutdown",
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Attach to an ExtensionClient and subscribe to its events.
|
|
*/
|
|
attachToClient(client: ExtensionClient): void {
|
|
// Subscribe to message events
|
|
const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false))
|
|
const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true))
|
|
const unsubStateChange = client.on("stateChange", (event) => this.handleStateChange(event))
|
|
const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event))
|
|
const unsubError = client.on("error", (error) => this.handleError(error))
|
|
|
|
this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubStateChange, unsubTaskCompleted, unsubError)
|
|
|
|
// Emit init event
|
|
this.emitEvent({
|
|
type: "system",
|
|
subtype: "init",
|
|
content: "Task started",
|
|
schemaVersion: this.schemaVersion,
|
|
protocol: this.protocol,
|
|
capabilities: this.capabilities,
|
|
})
|
|
}
|
|
|
|
emitControl(event: {
|
|
subtype: "ack" | "done" | "error"
|
|
requestId?: string
|
|
command?: JsonEvent["command"]
|
|
taskId?: string
|
|
content?: string
|
|
success?: boolean
|
|
code?: string
|
|
}): void {
|
|
this.emitEvent({
|
|
type: "control",
|
|
subtype: event.subtype,
|
|
requestId: event.requestId,
|
|
command: event.command,
|
|
taskId: event.taskId,
|
|
content: event.content,
|
|
success: event.success,
|
|
code: event.code,
|
|
done: event.subtype === "done" ? true : undefined,
|
|
})
|
|
}
|
|
|
|
emitQueue(event: {
|
|
subtype: "snapshot" | "enqueued" | "dequeued" | "drained" | "updated"
|
|
taskId?: string
|
|
content?: string
|
|
queueDepth: number
|
|
queue: JsonEventQueueItem[]
|
|
}): void {
|
|
this.emitEvent({
|
|
type: "queue",
|
|
subtype: event.subtype,
|
|
taskId: event.taskId,
|
|
content: event.content,
|
|
queueDepth: event.queueDepth,
|
|
queue: event.queue,
|
|
})
|
|
}
|
|
|
|
private handleStateChange(event: AgentStateChangeEvent): void {
|
|
// Only treat the next say:text as a prompt echo when a new task starts.
|
|
if (
|
|
event.previousState.state === AgentLoopState.NO_TASK &&
|
|
event.currentState.state !== AgentLoopState.NO_TASK
|
|
) {
|
|
this.expectPromptEchoAsUser = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detach from the client and clean up subscriptions.
|
|
*/
|
|
detach(): void {
|
|
for (const unsub of this.unsubscribers) {
|
|
unsub()
|
|
}
|
|
this.unsubscribers = []
|
|
}
|
|
|
|
/**
|
|
* Compute the delta (new content) for a streaming message.
|
|
* Returns null if there's no new content.
|
|
*/
|
|
private computeDelta(msgId: number, fullContent: string | undefined): string | null {
|
|
if (!fullContent) return null
|
|
|
|
const previous = this.previousContent.get(msgId) || ""
|
|
if (fullContent === previous) return null
|
|
|
|
this.previousContent.set(msgId, fullContent)
|
|
// If content is appended, return only the new part
|
|
return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent
|
|
}
|
|
|
|
/**
|
|
* Compute a compact delta for structured strings (for tool_use snapshots).
|
|
*
|
|
* Unlike append-only text streams, tool-use payloads are often full snapshots
|
|
* where edits happen before a stable suffix (e.g., inside JSON strings). This
|
|
* extracts the inserted segment when possible; otherwise it falls back to the
|
|
* full snapshot so consumers can recover.
|
|
*/
|
|
private computeStructuredDelta(msgId: number, fullContent: string | undefined): string | null {
|
|
if (!fullContent) {
|
|
return null
|
|
}
|
|
|
|
const previous = this.previousToolUseContent.get(msgId) || ""
|
|
|
|
if (fullContent === previous) {
|
|
return null
|
|
}
|
|
|
|
this.previousToolUseContent.set(msgId, fullContent)
|
|
|
|
if (previous.length === 0) {
|
|
return fullContent
|
|
}
|
|
|
|
if (fullContent.startsWith(previous)) {
|
|
return fullContent.slice(previous.length)
|
|
}
|
|
|
|
let prefix = 0
|
|
|
|
while (prefix < previous.length && prefix < fullContent.length && previous[prefix] === fullContent[prefix]) {
|
|
prefix++
|
|
}
|
|
|
|
let suffix = 0
|
|
|
|
while (
|
|
suffix < previous.length - prefix &&
|
|
suffix < fullContent.length - prefix &&
|
|
previous[previous.length - 1 - suffix] === fullContent[fullContent.length - 1 - suffix]
|
|
) {
|
|
suffix++
|
|
}
|
|
|
|
const isPureInsertion = fullContent.length >= previous.length && prefix + suffix >= previous.length
|
|
|
|
if (isPureInsertion) {
|
|
return fullContent.slice(prefix, fullContent.length - suffix)
|
|
}
|
|
|
|
return fullContent
|
|
}
|
|
|
|
/**
|
|
* Check if this is a streaming partial message with no new content.
|
|
*/
|
|
private isEmptyStreamingDelta(content: string | null): boolean {
|
|
return this.mode === "stream-json" && content === null
|
|
}
|
|
|
|
private computeCommandOutputDelta(commandId: number, fullOutput: string | undefined): string | null {
|
|
const normalized = fullOutput ?? ""
|
|
const previous = this.previousCommandOutputByToolUseId.get(commandId) || ""
|
|
|
|
if (normalized === previous) {
|
|
return null
|
|
}
|
|
|
|
this.previousCommandOutputByToolUseId.set(commandId, normalized)
|
|
return normalized.startsWith(previous) ? normalized.slice(previous.length) : normalized
|
|
}
|
|
|
|
private emitCommandOutputEvent(
|
|
commandId: number,
|
|
fullOutput: string | undefined,
|
|
isDone: boolean,
|
|
exitCode?: number,
|
|
): void {
|
|
if (this.mode === "stream-json") {
|
|
const outputDelta = this.computeCommandOutputDelta(commandId, fullOutput)
|
|
const event: JsonEvent = {
|
|
type: "tool_result",
|
|
id: commandId,
|
|
subtype: "command",
|
|
tool_result: { name: "execute_command" },
|
|
}
|
|
|
|
if (outputDelta !== null && outputDelta.length > 0) {
|
|
event.tool_result = { name: "execute_command", output: outputDelta }
|
|
}
|
|
|
|
if (isDone && exitCode !== undefined) {
|
|
event.tool_result = {
|
|
...(event.tool_result ?? { name: "execute_command" }),
|
|
exitCode,
|
|
}
|
|
}
|
|
|
|
if (isDone) {
|
|
event.done = true
|
|
this.clearPendingCommandCompletion(commandId)
|
|
this.previousCommandOutputByToolUseId.delete(commandId)
|
|
this.statusDrivenCommandOutputIds.delete(commandId)
|
|
this.completedCommandOutputIds.add(commandId)
|
|
if (this.activeCommandToolUseId === commandId) {
|
|
this.activeCommandToolUseId = undefined
|
|
}
|
|
}
|
|
|
|
// Suppress empty partial updates that carry no delta.
|
|
if (!isDone && outputDelta === null) {
|
|
return
|
|
}
|
|
|
|
this.emitEvent(event)
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_result",
|
|
id: commandId,
|
|
subtype: "command",
|
|
tool_result: {
|
|
name: "execute_command",
|
|
output: fullOutput,
|
|
...(isDone && exitCode !== undefined ? { exitCode } : {}),
|
|
},
|
|
...(isDone ? { done: true } : {}),
|
|
})
|
|
|
|
if (isDone) {
|
|
this.clearPendingCommandCompletion(commandId)
|
|
this.previousCommandOutputByToolUseId.delete(commandId)
|
|
this.statusDrivenCommandOutputIds.delete(commandId)
|
|
this.completedCommandOutputIds.add(commandId)
|
|
if (this.activeCommandToolUseId === commandId) {
|
|
this.activeCommandToolUseId = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
public emitCommandOutputChunk(outputSnapshot: string): void {
|
|
const commandId = this.activeCommandToolUseId
|
|
if (commandId === undefined) {
|
|
return
|
|
}
|
|
|
|
this.statusDrivenCommandOutputIds.add(commandId)
|
|
this.emitCommandOutputEvent(commandId, outputSnapshot, false)
|
|
}
|
|
|
|
public markCommandOutputExited(exitCode?: number): void {
|
|
const commandId = this.activeCommandToolUseId
|
|
if (commandId === undefined) {
|
|
return
|
|
}
|
|
|
|
this.statusDrivenCommandOutputIds.add(commandId)
|
|
this.clearPendingCommandCompletion(commandId)
|
|
|
|
const timer = setTimeout(() => {
|
|
// Fallback close if final say:command_output never arrives.
|
|
if (!this.pendingCommandCompletionByToolUseId.has(commandId)) {
|
|
return
|
|
}
|
|
this.pendingCommandCompletionByToolUseId.delete(commandId)
|
|
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
|
|
}, COMMAND_OUTPUT_EXIT_GRACE_MS)
|
|
timer.unref?.()
|
|
|
|
this.pendingCommandCompletionByToolUseId.set(commandId, { exitCode, timer })
|
|
}
|
|
|
|
public emitCommandOutputDone(exitCode?: number): void {
|
|
const commandId = this.activeCommandToolUseId
|
|
if (commandId === undefined) {
|
|
return
|
|
}
|
|
|
|
this.statusDrivenCommandOutputIds.add(commandId)
|
|
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
|
|
}
|
|
|
|
private clearPendingCommandCompletion(commandId: number): void {
|
|
const pending = this.pendingCommandCompletionByToolUseId.get(commandId)
|
|
if (!pending) {
|
|
return
|
|
}
|
|
clearTimeout(pending.timer)
|
|
this.pendingCommandCompletionByToolUseId.delete(commandId)
|
|
}
|
|
|
|
/**
|
|
* Get content to send for a message (delta for streaming, full for json mode).
|
|
*/
|
|
private getContentToSend(msgId: number, text: string | undefined, isPartial: boolean): string | null {
|
|
if (this.mode === "stream-json" && isPartial) {
|
|
return this.computeDelta(msgId, text)
|
|
}
|
|
|
|
return text ?? null
|
|
}
|
|
|
|
/**
|
|
* Build a base event with optional done flag.
|
|
*/
|
|
private buildTextEvent(
|
|
type: "assistant" | "thinking" | "user",
|
|
id: number,
|
|
content: string | null,
|
|
isDone: boolean,
|
|
subtype?: string,
|
|
): JsonEvent {
|
|
const event: JsonEvent = { type, id }
|
|
|
|
if (content !== null) {
|
|
event.content = content
|
|
}
|
|
|
|
if (subtype) {
|
|
event.subtype = subtype
|
|
}
|
|
|
|
if (isDone) {
|
|
event.done = true
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
/**
|
|
* Handle a ClineMessage and emit the appropriate JSON event.
|
|
*/
|
|
private handleMessage(msg: ClineMessage, _isUpdate: boolean): void {
|
|
const isDone = !msg.partial
|
|
|
|
// In json mode, only emit complete (non-partial) messages
|
|
if (this.mode === "json" && msg.partial) {
|
|
return
|
|
}
|
|
|
|
// Skip duplicate complete messages
|
|
if (isDone && this.seenMessageIds.has(msg.ts)) {
|
|
return
|
|
}
|
|
|
|
if (isDone) {
|
|
this.seenMessageIds.add(msg.ts)
|
|
this.previousContent.delete(msg.ts)
|
|
this.previousToolUseContent.delete(msg.ts)
|
|
}
|
|
|
|
if (msg.type === "say" && msg.say) {
|
|
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
|
|
|
|
// Skip if no new content for streaming partial messages
|
|
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
|
|
return
|
|
}
|
|
|
|
this.handleSayMessage(msg, contentToSend, isDone)
|
|
}
|
|
|
|
if (msg.type === "ask" && msg.ask) {
|
|
this.handleAskMessage(msg, isDone)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle "say" type messages.
|
|
*/
|
|
private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void {
|
|
switch (msg.say) {
|
|
case "text":
|
|
if (this.expectPromptEchoAsUser) {
|
|
this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone))
|
|
if (isDone) {
|
|
this.expectPromptEchoAsUser = false
|
|
}
|
|
} else {
|
|
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone))
|
|
if (msg.text) {
|
|
this.lastAssistantText = msg.text
|
|
}
|
|
}
|
|
break
|
|
|
|
case "reasoning":
|
|
this.handleReasoningMessage(msg, isDone)
|
|
break
|
|
|
|
case "error":
|
|
this.emitEvent({ type: "error", id: msg.ts, content: contentToSend ?? undefined })
|
|
break
|
|
|
|
case "command_output":
|
|
this.handleCommandOutputMessage(msg, isDone)
|
|
break
|
|
|
|
case "user_feedback":
|
|
case "user_feedback_diff":
|
|
this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone))
|
|
if (isDone) {
|
|
this.expectPromptEchoAsUser = false
|
|
}
|
|
break
|
|
|
|
case "api_req_started": {
|
|
const cost = parseApiReqCost(msg.text)
|
|
if (cost) {
|
|
this.lastCost = cost
|
|
}
|
|
break
|
|
}
|
|
|
|
case "mcp_server_response":
|
|
this.emitEvent({
|
|
type: "tool_result",
|
|
subtype: "mcp",
|
|
tool_result: { name: "mcp_server", output: msg.text },
|
|
})
|
|
break
|
|
|
|
case "completion_result":
|
|
if (msg.text && !msg.partial) {
|
|
this.completionResultContent = msg.text
|
|
}
|
|
break
|
|
|
|
default:
|
|
if (SKIP_SAY_TYPES.has(msg.say!)) {
|
|
break
|
|
}
|
|
if (msg.text) {
|
|
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.say))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle reasoning/thinking messages with separate delta tracking.
|
|
*/
|
|
private handleReasoningMessage(msg: ClineMessage, isDone: boolean): void {
|
|
const reasoningContent = msg.reasoning || msg.text
|
|
const reasoningKey = msg.ts + REASONING_KEY_OFFSET
|
|
const reasoningDelta = this.getContentToSend(reasoningKey, reasoningContent, msg.partial ?? false)
|
|
|
|
if (msg.partial && this.isEmptyStreamingDelta(reasoningDelta)) {
|
|
return
|
|
}
|
|
|
|
if (!msg.partial) {
|
|
this.previousContent.delete(reasoningKey)
|
|
}
|
|
|
|
this.emitEvent(this.buildTextEvent("thinking", msg.ts, reasoningDelta, isDone))
|
|
}
|
|
|
|
/**
|
|
* Handle "ask" type messages.
|
|
*/
|
|
private handleAskMessage(msg: ClineMessage, isDone: boolean): void {
|
|
switch (msg.ask) {
|
|
case "tool":
|
|
this.handleToolUseAsk(msg, "tool", isDone)
|
|
break
|
|
|
|
case "command":
|
|
this.handleToolUseAsk(msg, "command", isDone)
|
|
break
|
|
|
|
case "use_mcp_server":
|
|
this.handleToolUseAsk(msg, "mcp", isDone)
|
|
break
|
|
|
|
case "followup": {
|
|
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
|
|
|
|
// Skip if no new content for streaming partial messages
|
|
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
|
|
return
|
|
}
|
|
|
|
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup"))
|
|
break
|
|
}
|
|
|
|
case "command_output":
|
|
// Handled in say type
|
|
break
|
|
|
|
case "completion_result":
|
|
if (msg.text && !msg.partial) {
|
|
this.completionResultContent = msg.text
|
|
}
|
|
break
|
|
|
|
default:
|
|
if (msg.text) {
|
|
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
|
|
|
|
// Skip if no new content for streaming partial messages
|
|
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
|
|
return
|
|
}
|
|
|
|
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
private handleToolUseAsk(msg: ClineMessage, subtype: "tool" | "command" | "mcp", isDone: boolean): void {
|
|
const isStreamingPartial = this.mode === "stream-json" && msg.partial === true
|
|
const toolInfo = parseToolInfo(msg.text)
|
|
|
|
if (subtype === "command") {
|
|
if (this.activeCommandToolUseId !== undefined && this.activeCommandToolUseId !== msg.ts) {
|
|
const previousCommandId = this.activeCommandToolUseId
|
|
const pending = this.pendingCommandCompletionByToolUseId.get(previousCommandId)
|
|
if (pending) {
|
|
clearTimeout(pending.timer)
|
|
this.pendingCommandCompletionByToolUseId.delete(previousCommandId)
|
|
this.emitCommandOutputEvent(previousCommandId, undefined, true, pending.exitCode)
|
|
}
|
|
}
|
|
|
|
this.activeCommandToolUseId = msg.ts
|
|
this.completedCommandOutputIds.delete(msg.ts)
|
|
this.clearPendingCommandCompletion(msg.ts)
|
|
|
|
if (isStreamingPartial) {
|
|
const commandDelta = this.computeStructuredDelta(msg.ts, msg.text)
|
|
if (commandDelta === null) {
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_use",
|
|
id: msg.ts,
|
|
subtype: "command",
|
|
content: commandDelta,
|
|
tool_use: { name: "execute_command", input: { command: commandDelta } },
|
|
})
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_use",
|
|
id: msg.ts,
|
|
subtype: "command",
|
|
tool_use: { name: "execute_command", input: { command: msg.text } },
|
|
...(isDone ? { done: true } : {}),
|
|
})
|
|
return
|
|
}
|
|
|
|
if (subtype === "mcp") {
|
|
if (isStreamingPartial) {
|
|
const mcpDelta = this.computeStructuredDelta(msg.ts, msg.text)
|
|
if (mcpDelta === null) {
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_use",
|
|
id: msg.ts,
|
|
subtype: "mcp",
|
|
content: mcpDelta,
|
|
tool_use: { name: "mcp_server" },
|
|
})
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_use",
|
|
id: msg.ts,
|
|
subtype: "mcp",
|
|
tool_use: { name: "mcp_server", input: { raw: msg.text } },
|
|
...(isDone ? { done: true } : {}),
|
|
})
|
|
return
|
|
}
|
|
|
|
if (isStreamingPartial) {
|
|
const toolDelta = this.computeStructuredDelta(msg.ts, msg.text)
|
|
if (toolDelta === null) {
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_use",
|
|
id: msg.ts,
|
|
subtype: "tool",
|
|
content: toolDelta,
|
|
tool_use: { name: toolInfo?.name ?? "unknown_tool" },
|
|
})
|
|
return
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "tool_use",
|
|
id: msg.ts,
|
|
subtype: "tool",
|
|
tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } },
|
|
...(isDone ? { done: true } : {}),
|
|
})
|
|
}
|
|
|
|
private handleCommandOutputMessage(msg: ClineMessage, isDone: boolean): void {
|
|
const commandId = this.activeCommandToolUseId ?? msg.ts
|
|
if (this.completedCommandOutputIds.has(commandId)) {
|
|
return
|
|
}
|
|
|
|
const pending = this.pendingCommandCompletionByToolUseId.get(commandId)
|
|
if (pending) {
|
|
if (!isDone) {
|
|
return
|
|
}
|
|
clearTimeout(pending.timer)
|
|
this.pendingCommandCompletionByToolUseId.delete(commandId)
|
|
this.emitCommandOutputEvent(commandId, msg.text, true, pending.exitCode)
|
|
return
|
|
}
|
|
|
|
if (this.statusDrivenCommandOutputIds.has(commandId)) {
|
|
return
|
|
}
|
|
|
|
this.emitCommandOutputEvent(commandId, msg.text, isDone)
|
|
}
|
|
|
|
/**
|
|
* Handle task completion and emit result event.
|
|
*/
|
|
private handleTaskCompleted(event: TaskCompletedEvent): void {
|
|
// Prefer the completion payload from the current event. If it is empty,
|
|
// fall back to the most recent tracked completion text, then assistant text.
|
|
const resultContent = event.message?.text || this.completionResultContent || this.lastAssistantText
|
|
|
|
this.emitEvent({
|
|
type: "result",
|
|
id: event.message?.ts ?? Date.now(),
|
|
content: resultContent,
|
|
done: true,
|
|
success: event.success,
|
|
cost: this.lastCost,
|
|
})
|
|
|
|
// Prevent stale completion content from leaking into later turns.
|
|
this.completionResultContent = undefined
|
|
this.lastAssistantText = undefined
|
|
|
|
// For "json" mode, output the final accumulated result
|
|
if (this.mode === "json") {
|
|
this.outputFinalResult(event.success, resultContent)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle errors and emit error event.
|
|
*/
|
|
private handleError(error: Error): void {
|
|
this.emitEvent({
|
|
type: "error",
|
|
id: Date.now(),
|
|
content: error.message,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Emit a JSON event.
|
|
* For stream-json mode: immediately output to stdout
|
|
* For json mode: accumulate for final output
|
|
*/
|
|
private emitEvent(event: JsonEvent): void {
|
|
const requestId = event.requestId ?? this.requestIdProvider()
|
|
const payload = requestId ? { ...event, requestId } : event
|
|
|
|
this.events.push(payload)
|
|
|
|
if (this.mode === "stream-json") {
|
|
this.outputLine(payload)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Output a single JSON line (NDJSON format).
|
|
*/
|
|
private outputLine(data: unknown): void {
|
|
this.writeToStdout(JSON.stringify(data) + "\n")
|
|
}
|
|
|
|
/**
|
|
* Output the final accumulated result (for "json" mode).
|
|
*/
|
|
private outputFinalResult(success: boolean, content?: string): void {
|
|
const output: JsonFinalOutput = {
|
|
type: "result",
|
|
success,
|
|
content,
|
|
cost: this.lastCost,
|
|
events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself
|
|
}
|
|
|
|
this.writeToStdout(JSON.stringify(output, null, 2) + "\n")
|
|
}
|
|
|
|
private writeToStdout(content: string): void {
|
|
const writePromise = new Promise<void>((resolve, reject) => {
|
|
this.stdout.write(content, (error?: Error | null) => {
|
|
if (error) {
|
|
reject(error)
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
})
|
|
|
|
this.pendingWrites.add(writePromise)
|
|
|
|
void writePromise.finally(() => {
|
|
this.pendingWrites.delete(writePromise)
|
|
})
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
while (this.pendingWrites.size > 0) {
|
|
await Promise.all([...this.pendingWrites])
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get accumulated events (for testing or external use).
|
|
*/
|
|
getEvents(): JsonEvent[] {
|
|
return [...this.events]
|
|
}
|
|
|
|
/**
|
|
* Clear accumulated events and state.
|
|
*/
|
|
clear(): void {
|
|
this.events = []
|
|
this.lastCost = undefined
|
|
this.seenMessageIds.clear()
|
|
this.previousContent.clear()
|
|
this.previousToolUseContent.clear()
|
|
this.activeCommandToolUseId = undefined
|
|
this.previousCommandOutputByToolUseId.clear()
|
|
this.statusDrivenCommandOutputIds.clear()
|
|
this.completedCommandOutputIds.clear()
|
|
for (const pending of this.pendingCommandCompletionByToolUseId.values()) {
|
|
clearTimeout(pending.timer)
|
|
}
|
|
this.pendingCommandCompletionByToolUseId.clear()
|
|
this.completionResultContent = undefined
|
|
this.lastAssistantText = undefined
|
|
this.expectPromptEchoAsUser = true
|
|
}
|
|
}
|