From 3c1e71f256f3a8fc6eb351638e81184228b6b946 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:25:01 +0900 Subject: [PATCH] refactor(cli): split doctor/model-resolution and run/events into focused modules Doctor checks: - model-resolution-cache.ts, model-resolution-config.ts - model-resolution-details.ts, model-resolution-effective-model.ts - model-resolution-types.ts, model-resolution-variant.ts Run events: - event-formatting.ts, event-handlers.ts - event-state.ts, event-stream-processor.ts --- src/cli/doctor/checks/index.ts | 6 + .../doctor/checks/model-resolution-cache.ts | 36 ++ .../doctor/checks/model-resolution-config.ts | 34 ++ .../doctor/checks/model-resolution-details.ts | 52 +++ .../model-resolution-effective-model.ts | 27 ++ .../doctor/checks/model-resolution-types.ts | 35 ++ .../doctor/checks/model-resolution-variant.ts | 55 +++ src/cli/doctor/checks/model-resolution.ts | 240 +------------ src/cli/run/event-formatting.ts | 130 +++++++ src/cli/run/event-handlers.ts | 120 +++++++ src/cli/run/event-state.ts | 25 ++ src/cli/run/event-stream-processor.ts | 43 +++ src/cli/run/events.ts | 333 +----------------- src/cli/run/index.ts | 2 + 14 files changed, 577 insertions(+), 561 deletions(-) create mode 100644 src/cli/doctor/checks/model-resolution-cache.ts create mode 100644 src/cli/doctor/checks/model-resolution-config.ts create mode 100644 src/cli/doctor/checks/model-resolution-details.ts create mode 100644 src/cli/doctor/checks/model-resolution-effective-model.ts create mode 100644 src/cli/doctor/checks/model-resolution-types.ts create mode 100644 src/cli/doctor/checks/model-resolution-variant.ts create mode 100644 src/cli/run/event-formatting.ts create mode 100644 src/cli/run/event-handlers.ts create mode 100644 src/cli/run/event-state.ts create mode 100644 src/cli/run/event-stream-processor.ts diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts index 089271055..9135cd1bc 100644 --- a/src/cli/doctor/checks/index.ts +++ b/src/cli/doctor/checks/index.ts @@ -15,6 +15,12 @@ export * from "./opencode" export * from "./plugin" export * from "./config" export * from "./model-resolution" +export * from "./model-resolution-types" +export * from "./model-resolution-cache" +export * from "./model-resolution-config" +export * from "./model-resolution-effective-model" +export * from "./model-resolution-variant" +export * from "./model-resolution-details" export * from "./auth" export * from "./dependencies" export * from "./gh" diff --git a/src/cli/doctor/checks/model-resolution-cache.ts b/src/cli/doctor/checks/model-resolution-cache.ts new file mode 100644 index 000000000..9628db9e5 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-cache.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { AvailableModelsInfo } from "./model-resolution-types" + +function getOpenCodeCacheDir(): string { + const xdgCache = process.env.XDG_CACHE_HOME + if (xdgCache) return join(xdgCache, "opencode") + return join(homedir(), ".cache", "opencode") +} + +export function loadAvailableModelsFromCache(): AvailableModelsInfo { + const cacheFile = join(getOpenCodeCacheDir(), "models.json") + + if (!existsSync(cacheFile)) { + return { providers: [], modelCount: 0, cacheExists: false } + } + + try { + const content = readFileSync(cacheFile, "utf-8") + const data = JSON.parse(content) as Record }> + + const providers = Object.keys(data) + let modelCount = 0 + for (const providerId of providers) { + const models = data[providerId]?.models + if (models && typeof models === "object") { + modelCount += Object.keys(models).length + } + } + + return { providers, modelCount, cacheExists: true } + } catch { + return { providers: [], modelCount: 0, cacheExists: false } + } +} diff --git a/src/cli/doctor/checks/model-resolution-config.ts b/src/cli/doctor/checks/model-resolution-config.ts new file mode 100644 index 000000000..e84853ee4 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-config.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { detectConfigFile, parseJsonc } from "../../../shared" +import type { OmoConfig } from "./model-resolution-types" + +const PACKAGE_NAME = "oh-my-opencode" +const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") +const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME) +const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) + +export function loadOmoConfig(): OmoConfig | null { + const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) + if (projectDetected.format !== "none") { + try { + const content = readFileSync(projectDetected.path, "utf-8") + return parseJsonc(content) + } catch { + return null + } + } + + const userDetected = detectConfigFile(USER_CONFIG_BASE) + if (userDetected.format !== "none") { + try { + const content = readFileSync(userDetected.path, "utf-8") + return parseJsonc(content) + } catch { + return null + } + } + + return null +} diff --git a/src/cli/doctor/checks/model-resolution-details.ts b/src/cli/doctor/checks/model-resolution-details.ts new file mode 100644 index 000000000..7489a2c24 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-details.ts @@ -0,0 +1,52 @@ +import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types" +import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant" + +export function buildModelResolutionDetails(options: { + info: ModelResolutionInfo + available: AvailableModelsInfo + config: OmoConfig +}): string[] { + const details: string[] = [] + + details.push("═══ Available Models (from cache) ═══") + details.push("") + if (options.available.cacheExists) { + details.push(` Providers in cache: ${options.available.providers.length}`) + details.push( + ` Sample: ${options.available.providers.slice(0, 6).join(", ")}${options.available.providers.length > 6 ? "..." : ""}` + ) + details.push(` Total models: ${options.available.modelCount}`) + details.push(` Cache: ~/.cache/opencode/models.json`) + details.push(` ℹ Runtime: only connected providers used`) + details.push(` Refresh: opencode models --refresh`) + } else { + details.push(" ⚠ Cache not found. Run 'opencode' to populate.") + } + details.push("") + + details.push("═══ Configured Models ═══") + details.push("") + details.push("Agents:") + for (const agent of options.info.agents) { + const marker = agent.userOverride ? "●" : "○" + const display = formatModelWithVariant( + agent.effectiveModel, + getEffectiveVariant(agent.name, agent.requirement, options.config) + ) + details.push(` ${marker} ${agent.name}: ${display}`) + } + details.push("") + details.push("Categories:") + for (const category of options.info.categories) { + const marker = category.userOverride ? "●" : "○" + const display = formatModelWithVariant( + category.effectiveModel, + getCategoryEffectiveVariant(category.name, category.requirement, options.config) + ) + details.push(` ${marker} ${category.name}: ${display}`) + } + details.push("") + details.push("● = user override, ○ = provider fallback") + + return details +} diff --git a/src/cli/doctor/checks/model-resolution-effective-model.ts b/src/cli/doctor/checks/model-resolution-effective-model.ts new file mode 100644 index 000000000..44df0e8f6 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-effective-model.ts @@ -0,0 +1,27 @@ +import type { ModelRequirement } from "../../../shared/model-requirements" + +function formatProviderChain(providers: string[]): string { + return providers.join(" → ") +} + +export function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string { + if (userOverride) { + return userOverride + } + const firstEntry = requirement.fallbackChain[0] + if (!firstEntry) { + return "unknown" + } + return `${firstEntry.providers[0]}/${firstEntry.model}` +} + +export function buildEffectiveResolution(requirement: ModelRequirement, userOverride?: string): string { + if (userOverride) { + return `User override: ${userOverride}` + } + const firstEntry = requirement.fallbackChain[0] + if (!firstEntry) { + return "No fallback chain defined" + } + return `Provider fallback: ${formatProviderChain(firstEntry.providers)} → ${firstEntry.model}` +} diff --git a/src/cli/doctor/checks/model-resolution-types.ts b/src/cli/doctor/checks/model-resolution-types.ts new file mode 100644 index 000000000..c0396d958 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-types.ts @@ -0,0 +1,35 @@ +import type { ModelRequirement } from "../../../shared/model-requirements" + +export interface AgentResolutionInfo { + name: string + requirement: ModelRequirement + userOverride?: string + userVariant?: string + effectiveModel: string + effectiveResolution: string +} + +export interface CategoryResolutionInfo { + name: string + requirement: ModelRequirement + userOverride?: string + userVariant?: string + effectiveModel: string + effectiveResolution: string +} + +export interface ModelResolutionInfo { + agents: AgentResolutionInfo[] + categories: CategoryResolutionInfo[] +} + +export interface OmoConfig { + agents?: Record + categories?: Record +} + +export interface AvailableModelsInfo { + providers: string[] + modelCount: number + cacheExists: boolean +} diff --git a/src/cli/doctor/checks/model-resolution-variant.ts b/src/cli/doctor/checks/model-resolution-variant.ts new file mode 100644 index 000000000..a9bea3007 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-variant.ts @@ -0,0 +1,55 @@ +import type { ModelRequirement } from "../../../shared/model-requirements" +import type { OmoConfig } from "./model-resolution-types" + +export function formatModelWithVariant(model: string, variant?: string): string { + return variant ? `${model} (${variant})` : model +} + +function getAgentOverride( + agentName: string, + config: OmoConfig +): { variant?: string; category?: string } | undefined { + const agentOverrides = config.agents + if (!agentOverrides) return undefined + + return ( + agentOverrides[agentName] ?? + Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + ) +} + +export function getEffectiveVariant( + agentName: string, + requirement: ModelRequirement, + config: OmoConfig +): string | undefined { + const agentOverride = getAgentOverride(agentName, config) + + if (agentOverride?.variant) { + return agentOverride.variant + } + + const categoryName = agentOverride?.category + if (categoryName) { + const categoryVariant = config.categories?.[categoryName]?.variant + if (categoryVariant) { + return categoryVariant + } + } + + const firstEntry = requirement.fallbackChain[0] + return firstEntry?.variant ?? requirement.variant +} + +export function getCategoryEffectiveVariant( + categoryName: string, + requirement: ModelRequirement, + config: OmoConfig +): string | undefined { + const categoryVariant = config.categories?.[categoryName]?.variant + if (categoryVariant) { + return categoryVariant + } + const firstEntry = requirement.fallbackChain[0] + return firstEntry?.variant ?? requirement.variant +} diff --git a/src/cli/doctor/checks/model-resolution.ts b/src/cli/doctor/checks/model-resolution.ts index 8599803ce..c2f8a77f9 100644 --- a/src/cli/doctor/checks/model-resolution.ts +++ b/src/cli/doctor/checks/model-resolution.ts @@ -1,132 +1,14 @@ -import { readFileSync, existsSync } from "node:fs" import type { CheckResult, CheckDefinition } from "../types" import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { parseJsonc, detectConfigFile } from "../../../shared" import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS, - type ModelRequirement, } from "../../../shared/model-requirements" -import { homedir } from "node:os" -import { join } from "node:path" - -function getOpenCodeCacheDir(): string { - const xdgCache = process.env.XDG_CACHE_HOME - if (xdgCache) return join(xdgCache, "opencode") - return join(homedir(), ".cache", "opencode") -} - -function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } { - const cacheFile = join(getOpenCodeCacheDir(), "models.json") - - if (!existsSync(cacheFile)) { - return { providers: [], modelCount: 0, cacheExists: false } - } - - try { - const content = readFileSync(cacheFile, "utf-8") - const data = JSON.parse(content) as Record }> - - const providers = Object.keys(data) - let modelCount = 0 - for (const providerId of providers) { - const models = data[providerId]?.models - if (models && typeof models === "object") { - modelCount += Object.keys(models).length - } - } - - return { providers, modelCount, cacheExists: true } - } catch { - return { providers: [], modelCount: 0, cacheExists: false } - } -} - -const PACKAGE_NAME = "oh-my-opencode" -const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") -const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME) -const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) - -export interface AgentResolutionInfo { - name: string - requirement: ModelRequirement - userOverride?: string - userVariant?: string - effectiveModel: string - effectiveResolution: string -} - -export interface CategoryResolutionInfo { - name: string - requirement: ModelRequirement - userOverride?: string - userVariant?: string - effectiveModel: string - effectiveResolution: string -} - -export interface ModelResolutionInfo { - agents: AgentResolutionInfo[] - categories: CategoryResolutionInfo[] -} - -interface OmoConfig { - agents?: Record - categories?: Record -} - -function loadConfig(): OmoConfig | null { - const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) - if (projectDetected.format !== "none") { - try { - const content = readFileSync(projectDetected.path, "utf-8") - return parseJsonc(content) - } catch { - return null - } - } - - const userDetected = detectConfigFile(USER_CONFIG_BASE) - if (userDetected.format !== "none") { - try { - const content = readFileSync(userDetected.path, "utf-8") - return parseJsonc(content) - } catch { - return null - } - } - - return null -} - -function formatProviderChain(providers: string[]): string { - return providers.join(" → ") -} - -function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string { - if (userOverride) { - return userOverride - } - const firstEntry = requirement.fallbackChain[0] - if (!firstEntry) { - return "unknown" - } - return `${firstEntry.providers[0]}/${firstEntry.model}` -} - -function buildEffectiveResolution( - requirement: ModelRequirement, - userOverride?: string, -): string { - if (userOverride) { - return `User override: ${userOverride}` - } - const firstEntry = requirement.fallbackChain[0] - if (!firstEntry) { - return "No fallback chain defined" - } - return `Provider fallback: ${formatProviderChain(firstEntry.providers)} → ${firstEntry.model}` -} +import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types" +import { loadAvailableModelsFromCache } from "./model-resolution-cache" +import { loadOmoConfig } from "./model-resolution-config" +import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model" +import { buildModelResolutionDetails } from "./model-resolution-details" export function getModelResolutionInfo(): ModelResolutionInfo { const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( @@ -184,116 +66,10 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes return { agents, categories } } -function formatModelWithVariant(model: string, variant?: string): string { - return variant ? `${model} (${variant})` : model -} - -function getAgentOverride( - agentName: string, - config: OmoConfig, -): { variant?: string; category?: string } | undefined { - const agentOverrides = config.agents - if (!agentOverrides) return undefined - - // Direct lookup first, then case-insensitive lookup (matches agent-variant.ts) - return ( - agentOverrides[agentName] ?? - Object.entries(agentOverrides).find( - ([key]) => key.toLowerCase() === agentName.toLowerCase() - )?.[1] - ) -} - -function getEffectiveVariant( - name: string, - requirement: ModelRequirement, - config: OmoConfig, -): string | undefined { - const agentOverride = getAgentOverride(name, config) - - // Priority 1: Agent's direct variant override - if (agentOverride?.variant) { - return agentOverride.variant - } - - // Priority 2: Agent's category -> category's variant (matches agent-variant.ts) - const categoryName = agentOverride?.category - if (categoryName) { - const categoryVariant = config.categories?.[categoryName]?.variant - if (categoryVariant) { - return categoryVariant - } - } - - // Priority 3: Fall back to requirement's fallback chain - const firstEntry = requirement.fallbackChain[0] - return firstEntry?.variant ?? requirement.variant -} - -interface AvailableModelsInfo { - providers: string[] - modelCount: number - cacheExists: boolean -} - -function getCategoryEffectiveVariant( - categoryName: string, - requirement: ModelRequirement, - config: OmoConfig, -): string | undefined { - const categoryVariant = config.categories?.[categoryName]?.variant - if (categoryVariant) { - return categoryVariant - } - const firstEntry = requirement.fallbackChain[0] - return firstEntry?.variant ?? requirement.variant -} - -function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] { - const details: string[] = [] - - details.push("═══ Available Models (from cache) ═══") - details.push("") - if (available.cacheExists) { - details.push(` Providers in cache: ${available.providers.length}`) - details.push(` Sample: ${available.providers.slice(0, 6).join(", ")}${available.providers.length > 6 ? "..." : ""}`) - details.push(` Total models: ${available.modelCount}`) - details.push(` Cache: ~/.cache/opencode/models.json`) - details.push(` ℹ Runtime: only connected providers used`) - details.push(` Refresh: opencode models --refresh`) - } else { - details.push(" ⚠ Cache not found. Run 'opencode' to populate.") - } - details.push("") - - details.push("═══ Configured Models ═══") - details.push("") - details.push("Agents:") - for (const agent of info.agents) { - const marker = agent.userOverride ? "●" : "○" - const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config)) - details.push(` ${marker} ${agent.name}: ${display}`) - } - details.push("") - details.push("Categories:") - for (const category of info.categories) { - const marker = category.userOverride ? "●" : "○" - const display = formatModelWithVariant( - category.effectiveModel, - getCategoryEffectiveVariant(category.name, category.requirement, config) - ) - details.push(` ${marker} ${category.name}: ${display}`) - } - details.push("") - details.push("● = user override, ○ = provider fallback") - - return details -} - export async function checkModelResolution(): Promise { - const config = loadConfig() ?? {} + const config = loadOmoConfig() ?? {} const info = getModelResolutionInfoWithOverrides(config) - const available = loadAvailableModels() + const available = loadAvailableModelsFromCache() const agentCount = info.agents.length const categoryCount = info.categories.length @@ -308,7 +84,7 @@ export async function checkModelResolution(): Promise { name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], status: available.cacheExists ? "pass" : "warn", message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`, - details: buildDetailsArray(info, available, config), + details: buildModelResolutionDetails({ info, available, config }), } } diff --git a/src/cli/run/event-formatting.ts b/src/cli/run/event-formatting.ts new file mode 100644 index 000000000..f9056641e --- /dev/null +++ b/src/cli/run/event-formatting.ts @@ -0,0 +1,130 @@ +import pc from "picocolors" +import type { + RunContext, + EventPayload, + MessageUpdatedProps, + MessagePartUpdatedProps, + ToolExecuteProps, + ToolResultProps, + SessionErrorProps, +} from "./types" + +export function serializeError(error: unknown): string { + if (!error) return "Unknown error" + + if (error instanceof Error) { + const parts = [error.message] + if (error.cause) { + parts.push(`Cause: ${serializeError(error.cause)}`) + } + return parts.join(" | ") + } + + if (typeof error === "string") { + return error + } + + if (typeof error === "object") { + const obj = error as Record + + const messagePaths = [ + obj.message, + obj.error, + (obj.data as Record)?.message, + (obj.data as Record)?.error, + (obj.error as Record)?.message, + ] + + for (const msg of messagePaths) { + if (typeof msg === "string" && msg.length > 0) { + return msg + } + } + + try { + const json = JSON.stringify(error, null, 2) + if (json !== "{}") { + return json + } + } catch (_) { + void _ + } + } + + return String(error) +} + +function getSessionTag(ctx: RunContext, payload: EventPayload): string { + const props = payload.properties as Record | undefined + const info = props?.info as Record | undefined + const sessionID = props?.sessionID ?? info?.sessionID + const isMainSession = sessionID === ctx.sessionID + if (isMainSession) return pc.green("[MAIN]") + if (sessionID) return pc.yellow(`[${String(sessionID).slice(0, 8)}]`) + return pc.dim("[system]") +} + +export function logEventVerbose(ctx: RunContext, payload: EventPayload): void { + const sessionTag = getSessionTag(ctx, payload) + const props = payload.properties as Record | undefined + + switch (payload.type) { + case "session.idle": + case "session.status": { + const status = (props?.status as { type?: string })?.type ?? "idle" + console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`)) + break + } + + case "message.part.updated": { + const partProps = props as MessagePartUpdatedProps | undefined + const part = partProps?.part + if (part?.type === "tool-invocation") { + const toolPart = part as { toolName?: string; state?: string } + console.error(pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)) + } else if (part?.type === "text" && part.text) { + const preview = part.text.slice(0, 80).replace(/\n/g, "\\n") + console.error(pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)) + } + break + } + + case "message.updated": { + const msgProps = props as MessageUpdatedProps | undefined + const role = msgProps?.info?.role ?? "unknown" + const model = msgProps?.info?.modelID + const agent = msgProps?.info?.agent + const details = [role, agent, model].filter(Boolean).join(", ") + console.error(pc.dim(`${sessionTag} message.updated (${details})`)) + break + } + + case "tool.execute": { + const toolProps = props as ToolExecuteProps | undefined + const toolName = toolProps?.name ?? "unknown" + const input = toolProps?.input ?? {} + const inputStr = JSON.stringify(input).slice(0, 150) + console.error(pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)) + console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`)) + break + } + + case "tool.result": { + const resultProps = props as ToolResultProps | undefined + const output = resultProps?.output ?? "" + const preview = output.slice(0, 200).replace(/\n/g, "\\n") + console.error(pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)) + break + } + + case "session.error": { + const errorProps = props as SessionErrorProps | undefined + const errorMsg = serializeError(errorProps?.error) + console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`)) + break + } + + default: + console.error(pc.dim(`${sessionTag} ${payload.type}`)) + } +} diff --git a/src/cli/run/event-handlers.ts b/src/cli/run/event-handlers.ts new file mode 100644 index 000000000..9f1dcabd4 --- /dev/null +++ b/src/cli/run/event-handlers.ts @@ -0,0 +1,120 @@ +import pc from "picocolors" +import type { + RunContext, + EventPayload, + SessionIdleProps, + SessionStatusProps, + SessionErrorProps, + MessageUpdatedProps, + MessagePartUpdatedProps, + ToolExecuteProps, + ToolResultProps, +} from "./types" +import type { EventState } from "./event-state" +import { serializeError } from "./event-formatting" + +export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "session.idle") return + + const props = payload.properties as SessionIdleProps | undefined + if (props?.sessionID === ctx.sessionID) { + state.mainSessionIdle = true + } +} + +export function handleSessionStatus(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "session.status") return + + const props = payload.properties as SessionStatusProps | undefined + if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") { + state.mainSessionIdle = false + } +} + +export function handleSessionError(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "session.error") return + + const props = payload.properties as SessionErrorProps | undefined + if (props?.sessionID === ctx.sessionID) { + state.mainSessionError = true + state.lastError = serializeError(props?.error) + console.error(pc.red(`\n[session.error] ${state.lastError}`)) + } +} + +export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "message.part.updated") return + + const props = payload.properties as MessagePartUpdatedProps | undefined + if (props?.info?.sessionID !== ctx.sessionID) return + if (props?.info?.role !== "assistant") return + + const part = props.part + if (!part) return + + if (part.type === "text" && part.text) { + const newText = part.text.slice(state.lastPartText.length) + if (newText) { + process.stdout.write(newText) + state.hasReceivedMeaningfulWork = true + } + state.lastPartText = part.text + } +} + +export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "message.updated") return + + const props = payload.properties as MessageUpdatedProps | undefined + if (props?.info?.sessionID !== ctx.sessionID) return + if (props?.info?.role !== "assistant") return + + state.hasReceivedMeaningfulWork = true + state.messageCount++ +} + +export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "tool.execute") return + + const props = payload.properties as ToolExecuteProps | undefined + if (props?.sessionID !== ctx.sessionID) return + + const toolName = props?.name || "unknown" + state.currentTool = toolName + + let inputPreview = "" + if (props?.input) { + const input = props.input + if (input.command) { + inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}` + } else if (input.pattern) { + inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}` + } else if (input.filePath) { + inputPreview = ` ${pc.dim(String(input.filePath))}` + } else if (input.query) { + inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}` + } + } + + state.hasReceivedMeaningfulWork = true + process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`) +} + +export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "tool.result") return + + const props = payload.properties as ToolResultProps | undefined + if (props?.sessionID !== ctx.sessionID) return + + const output = props?.output || "" + const maxLen = 200 + const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output + + if (preview.trim()) { + const lines = preview.split("\n").slice(0, 3) + process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`)) + } + + state.currentTool = null + state.lastPartText = "" +} diff --git a/src/cli/run/event-state.ts b/src/cli/run/event-state.ts new file mode 100644 index 000000000..db49f5653 --- /dev/null +++ b/src/cli/run/event-state.ts @@ -0,0 +1,25 @@ +export interface EventState { + mainSessionIdle: boolean + mainSessionError: boolean + lastError: string | null + lastOutput: string + lastPartText: string + currentTool: string | null + /** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */ + hasReceivedMeaningfulWork: boolean + /** Count of assistant messages for the main session */ + messageCount: number +} + +export function createEventState(): EventState { + return { + mainSessionIdle: false, + mainSessionError: false, + lastError: null, + lastOutput: "", + lastPartText: "", + currentTool: null, + hasReceivedMeaningfulWork: false, + messageCount: 0, + } +} diff --git a/src/cli/run/event-stream-processor.ts b/src/cli/run/event-stream-processor.ts new file mode 100644 index 000000000..7f629a041 --- /dev/null +++ b/src/cli/run/event-stream-processor.ts @@ -0,0 +1,43 @@ +import pc from "picocolors" +import type { RunContext, EventPayload } from "./types" +import type { EventState } from "./event-state" +import { logEventVerbose } from "./event-formatting" +import { + handleSessionError, + handleSessionIdle, + handleSessionStatus, + handleMessagePartUpdated, + handleMessageUpdated, + handleToolExecute, + handleToolResult, +} from "./event-handlers" + +export async function processEvents( + ctx: RunContext, + stream: AsyncIterable, + state: EventState +): Promise { + for await (const event of stream) { + if (ctx.abortController.signal.aborted) break + + try { + const payload = event as EventPayload + if (!payload?.type) { + console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`)) + continue + } + + logEventVerbose(ctx, payload) + + handleSessionError(ctx, payload, state) + handleSessionIdle(ctx, payload, state) + handleSessionStatus(ctx, payload, state) + handleMessagePartUpdated(ctx, payload, state) + handleMessageUpdated(ctx, payload, state) + handleToolExecute(ctx, payload, state) + handleToolResult(ctx, payload, state) + } catch (err) { + console.error(pc.red(`[event error] ${err}`)) + } + } +} diff --git a/src/cli/run/events.ts b/src/cli/run/events.ts index ff3af1f73..114b65ae1 100644 --- a/src/cli/run/events.ts +++ b/src/cli/run/events.ts @@ -1,329 +1,4 @@ -import pc from "picocolors" -import type { - RunContext, - EventPayload, - SessionIdleProps, - SessionStatusProps, - SessionErrorProps, - MessageUpdatedProps, - MessagePartUpdatedProps, - ToolExecuteProps, - ToolResultProps, -} from "./types" - -export function serializeError(error: unknown): string { - if (!error) return "Unknown error" - - if (error instanceof Error) { - const parts = [error.message] - if (error.cause) { - parts.push(`Cause: ${serializeError(error.cause)}`) - } - return parts.join(" | ") - } - - if (typeof error === "string") { - return error - } - - if (typeof error === "object") { - const obj = error as Record - - const messagePaths = [ - obj.message, - obj.error, - (obj.data as Record)?.message, - (obj.data as Record)?.error, - (obj.error as Record)?.message, - ] - - for (const msg of messagePaths) { - if (typeof msg === "string" && msg.length > 0) { - return msg - } - } - - try { - const json = JSON.stringify(error, null, 2) - if (json !== "{}") { - return json - } - } catch (_) { - void _ - } - } - - return String(error) -} - -export interface EventState { - mainSessionIdle: boolean - mainSessionError: boolean - lastError: string | null - lastOutput: string - lastPartText: string - currentTool: string | null - /** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */ - hasReceivedMeaningfulWork: boolean - /** Count of assistant messages for the main session */ - messageCount: number -} - -export function createEventState(): EventState { - return { - mainSessionIdle: false, - mainSessionError: false, - lastError: null, - lastOutput: "", - lastPartText: "", - currentTool: null, - hasReceivedMeaningfulWork: false, - messageCount: 0, - } -} - -export async function processEvents( - ctx: RunContext, - stream: AsyncIterable, - state: EventState -): Promise { - for await (const event of stream) { - if (ctx.abortController.signal.aborted) break - - try { - const payload = event as EventPayload - if (!payload?.type) { - console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`)) - continue - } - - logEventVerbose(ctx, payload) - - handleSessionError(ctx, payload, state) - handleSessionIdle(ctx, payload, state) - handleSessionStatus(ctx, payload, state) - handleMessagePartUpdated(ctx, payload, state) - handleMessageUpdated(ctx, payload, state) - handleToolExecute(ctx, payload, state) - handleToolResult(ctx, payload, state) - } catch (err) { - console.error(pc.red(`[event error] ${err}`)) - } - } -} - -function logEventVerbose(ctx: RunContext, payload: EventPayload): void { - const props = payload.properties as Record | undefined - const info = props?.info as Record | undefined - const sessionID = props?.sessionID ?? info?.sessionID - const isMainSession = sessionID === ctx.sessionID - const sessionTag = isMainSession - ? pc.green("[MAIN]") - : sessionID - ? pc.yellow(`[${String(sessionID).slice(0, 8)}]`) - : pc.dim("[system]") - - switch (payload.type) { - case "session.idle": - case "session.status": { - const status = (props?.status as { type?: string })?.type ?? "idle" - console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`)) - break - } - - case "message.part.updated": { - const partProps = props as MessagePartUpdatedProps | undefined - const part = partProps?.part - if (part?.type === "tool-invocation") { - const toolPart = part as { toolName?: string; state?: string } - console.error( - pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`) - ) - } else if (part?.type === "text" && part.text) { - const preview = part.text.slice(0, 80).replace(/\n/g, "\\n") - console.error( - pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`) - ) - } - break - } - - case "message.updated": { - const msgProps = props as MessageUpdatedProps | undefined - const role = msgProps?.info?.role ?? "unknown" - const model = msgProps?.info?.modelID - const agent = msgProps?.info?.agent - const details = [role, agent, model].filter(Boolean).join(", ") - console.error(pc.dim(`${sessionTag} message.updated (${details})`)) - break - } - - case "tool.execute": { - const toolProps = props as ToolExecuteProps | undefined - const toolName = toolProps?.name ?? "unknown" - const input = toolProps?.input ?? {} - const inputStr = JSON.stringify(input).slice(0, 150) - console.error( - pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`) - ) - console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`)) - break - } - - case "tool.result": { - const resultProps = props as ToolResultProps | undefined - const output = resultProps?.output ?? "" - const preview = output.slice(0, 200).replace(/\n/g, "\\n") - console.error( - pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`) - ) - break - } - - case "session.error": { - const errorProps = props as SessionErrorProps | undefined - const errorMsg = serializeError(errorProps?.error) - console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`)) - break - } - - default: - console.error(pc.dim(`${sessionTag} ${payload.type}`)) - } -} - -function handleSessionIdle( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "session.idle") return - - const props = payload.properties as SessionIdleProps | undefined - if (props?.sessionID === ctx.sessionID) { - state.mainSessionIdle = true - } -} - -function handleSessionStatus( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "session.status") return - - const props = payload.properties as SessionStatusProps | undefined - if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") { - state.mainSessionIdle = false - } -} - -function handleSessionError( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "session.error") return - - const props = payload.properties as SessionErrorProps | undefined - if (props?.sessionID === ctx.sessionID) { - state.mainSessionError = true - state.lastError = serializeError(props?.error) - console.error(pc.red(`\n[session.error] ${state.lastError}`)) - } -} - -function handleMessagePartUpdated( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "message.part.updated") return - - const props = payload.properties as MessagePartUpdatedProps | undefined - if (props?.info?.sessionID !== ctx.sessionID) return - if (props?.info?.role !== "assistant") return - - const part = props.part - if (!part) return - - if (part.type === "text" && part.text) { - const newText = part.text.slice(state.lastPartText.length) - if (newText) { - process.stdout.write(newText) - state.hasReceivedMeaningfulWork = true - } - state.lastPartText = part.text - } -} - -function handleMessageUpdated( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "message.updated") return - - const props = payload.properties as MessageUpdatedProps | undefined - if (props?.info?.sessionID !== ctx.sessionID) return - if (props?.info?.role !== "assistant") return - - state.hasReceivedMeaningfulWork = true - state.messageCount++ -} - -function handleToolExecute( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "tool.execute") return - - const props = payload.properties as ToolExecuteProps | undefined - if (props?.sessionID !== ctx.sessionID) return - - const toolName = props?.name || "unknown" - state.currentTool = toolName - - let inputPreview = "" - if (props?.input) { - const input = props.input - if (input.command) { - inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}` - } else if (input.pattern) { - inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}` - } else if (input.filePath) { - inputPreview = ` ${pc.dim(String(input.filePath))}` - } else if (input.query) { - inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}` - } - } - - state.hasReceivedMeaningfulWork = true - process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`) -} - -function handleToolResult( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "tool.result") return - - const props = payload.properties as ToolResultProps | undefined - if (props?.sessionID !== ctx.sessionID) return - - const output = props?.output || "" - const maxLen = 200 - const preview = output.length > maxLen - ? output.slice(0, maxLen) + "..." - : output - - if (preview.trim()) { - const lines = preview.split("\n").slice(0, 3) - process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`)) - } - - state.currentTool = null - state.lastPartText = "" -} +export type { EventState } from "./event-state" +export { createEventState } from "./event-state" +export { serializeError } from "./event-formatting" +export { processEvents } from "./event-stream-processor" diff --git a/src/cli/run/index.ts b/src/cli/run/index.ts index 33d9ff9be..8e4528e81 100644 --- a/src/cli/run/index.ts +++ b/src/cli/run/index.ts @@ -4,4 +4,6 @@ export { createServerConnection } from "./server-connection" export { resolveSession } from "./session-resolver" export { createJsonOutputManager } from "./json-output" export { executeOnCompleteHook } from "./on-complete-hook" +export { createEventState, processEvents, serializeError } from "./events" +export type { EventState } from "./events" export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types"