diff --git a/apps/cli/src/runtime/run-agent.test.ts b/apps/cli/src/runtime/run-agent.test.ts index f1599c58c5..dfc62d63a5 100644 --- a/apps/cli/src/runtime/run-agent.test.ts +++ b/apps/cli/src/runtime/run-agent.test.ts @@ -159,6 +159,8 @@ describe("runAgent", () => { outputMocks.writeErr.mockReset(); outputMocks.writeln.mockReset(); outputMocks.emitJsonLine.mockReset(); + outputMocks.getActiveCliSession.mockReset(); + outputMocks.getActiveCliSession.mockReturnValue(undefined); outputMocks.setActiveCliSession.mockReset(); sessionEventsMocks.listener = undefined; sessionEventsMocks.subscribeToAgentEvents.mockClear(); @@ -1289,4 +1291,191 @@ describe("runAgent", () => { expect.any(Object), ); }); + + describe("run_start", () => { + const buildStartedSession = (sessionId: string) => ({ + sessionId, + manifestPath: "/tmp/manifest.json", + messagesPath: "/tmp/messages.json", + manifest: { + session_id: sessionId, + }, + result: { + text: "ok", + usage: { + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalCost: undefined, + }, + messages: [], + toolCalls: [], + iterations: 1, + finishReason: "completed", + model: { + id: "gemini", + provider: "openrouter", + info: {}, + }, + startedAt: new Date("2026-03-22T00:00:00.000Z"), + endedAt: new Date("2026-03-22T00:00:01.000Z"), + durationMs: 1000, + }, + }); + + const buildConfig = (overrides: Record) => + ({ + cwd: process.cwd(), + enableAgentTeams: false, + enableSpawnAgent: false, + enableTools: [], + execution: { + maxConsecutiveMistakes: 3, + }, + logger: undefined, + mode: "yolo", + modelId: "google/gemini-3-flash-preview", + outputMode: "json", + providerId: "openrouter", + systemPrompt: "system", + thinking: false, + toolPolicies: { "*": { autoApprove: true } }, + verbose: false, + workspaceRoot: process.cwd(), + ...overrides, + }) as never; + + const getRunStartEvents = () => + outputMocks.emitJsonLine.mock.calls.filter( + ([, payload]) => + (payload as { type?: string } | undefined)?.type === "run_start", + ); + + const startWithPlannedSessionId = () => + sessionManagerMocks.start.mockImplementation( + async (input: { config: { sessionId: string } }) => + buildStartedSession(input.config.sessionId), + ); + + const getStartedSessionId = () => + ( + sessionManagerMocks.start.mock.calls[0]?.[0] as { + config: { sessionId?: string }; + } + ).config.sessionId; + + it("emits run_start in JSON mode without verbose, bound to the started session id", async () => { + // Simulate a stale active session from an earlier run in this process; + // it must not leak into the new invocation's run_start event. + outputMocks.getActiveCliSession.mockReturnValue({ + manifest: { session_id: "stale-session-from-previous-run" }, + } as never); + startWithPlannedSessionId(); + + const { runAgent } = await import("./run-agent"); + await expect( + runAgent("test prompt", buildConfig({ verbose: false })), + ).resolves.toBeUndefined(); + + const runStartEvents = getRunStartEvents(); + expect(runStartEvents).toHaveLength(1); + const [stream, payload] = runStartEvents[0] as [ + string, + { sessionId?: string }, + ]; + expect(stream).toBe("stdout"); + + const startedSessionId = getStartedSessionId(); + expect(startedSessionId).toEqual(expect.any(String)); + expect(startedSessionId).not.toBe(""); + expect(payload.sessionId).toBe(startedSessionId); + expect(payload.sessionId).not.toBe("stale-session-from-previous-run"); + expect(payload).toMatchObject({ + providerId: "openrouter", + modelId: "google/gemini-3-flash-preview", + mode: "yolo", + }); + + // run_start is emitted before the session is started and before + // run_result is written. + const runStartCallIndex = outputMocks.emitJsonLine.mock.calls.indexOf( + runStartEvents[0], + ); + expect( + outputMocks.emitJsonLine.mock.invocationCallOrder[runStartCallIndex], + ).toBeLessThan(sessionManagerMocks.start.mock.invocationCallOrder[0]); + expect( + outputMocks.emitJsonLine.mock.calls.findIndex( + ([, p]) => + (p as { type?: string } | undefined)?.type === "run_result", + ), + ).toBeGreaterThan(runStartCallIndex); + }); + it("emits a session id even when no global active session exists", async () => { + outputMocks.getActiveCliSession.mockReturnValue(undefined); + startWithPlannedSessionId(); + + const { runAgent } = await import("./run-agent"); + await runAgent("test prompt", buildConfig({ verbose: false })); + + const runStartEvents = getRunStartEvents(); + expect(runStartEvents).toHaveLength(1); + const payload = runStartEvents[0]?.[1] as { sessionId?: string }; + expect(payload.sessionId).toEqual(expect.any(String)); + expect(payload.sessionId).toBe(getStartedSessionId()); + }); + + it("emits exactly one run_start in JSON mode when verbose is also enabled", async () => { + startWithPlannedSessionId(); + + const { runAgent } = await import("./run-agent"); + await runAgent("test prompt", buildConfig({ verbose: true })); + + const runStartEvents = getRunStartEvents(); + expect(runStartEvents).toHaveLength(1); + expect((runStartEvents[0]?.[1] as { sessionId?: string }).sessionId).toBe( + getStartedSessionId(), + ); + expect(outputMocks.writeln).not.toHaveBeenCalledWith( + expect.stringContaining("[model]"), + ); + }); + + it("keeps non-verbose text output free of run_start and model diagnostics", async () => { + sessionManagerMocks.start.mockResolvedValue( + buildStartedSession("session-1"), + ); + + const { runAgent } = await import("./run-agent"); + await runAgent( + "test prompt", + buildConfig({ outputMode: "text", verbose: false }), + ); + + expect(getRunStartEvents()).toHaveLength(0); + expect(outputMocks.writeln).not.toHaveBeenCalledWith( + expect.stringContaining("[model]"), + ); + }); + + it("prints human-readable model diagnostics only for verbose text runs", async () => { + sessionManagerMocks.start.mockResolvedValue( + buildStartedSession("session-1"), + ); + + const { runAgent } = await import("./run-agent"); + await runAgent( + "test prompt", + buildConfig({ outputMode: "text", verbose: true }), + ); + + expect(getRunStartEvents()).toHaveLength(0); + expect(outputMocks.writeln).toHaveBeenCalledWith( + expect.stringContaining( + "[model] provider=openrouter model=google/gemini-3-flash-preview", + ), + ); + }); + }); }); diff --git a/apps/cli/src/runtime/run-agent.ts b/apps/cli/src/runtime/run-agent.ts index a99b80b559..e44f09bbb8 100644 --- a/apps/cli/src/runtime/run-agent.ts +++ b/apps/cli/src/runtime/run-agent.ts @@ -28,7 +28,6 @@ import { c, emitJsonLine, formatUsd, - getActiveCliSession, setActiveCliSession, writeErr, writeln, @@ -46,22 +45,33 @@ import { describeAbortSource, resolveMistakeLimitDecision } from "./format"; import { buildUserInputMessage } from "./prompt"; import { subscribeToAgentEvents } from "./session-events"; +/** + * Emits the JSON `run_start` lifecycle event for a one-shot run. + * + * `sessionId` must be the invocation-bound ID handed to + * `sessionManager.start()`, not global active-session state: the active + * session is only registered after `start()` resolves, so reading it here + * would yield `undefined` or a stale ID from a previous run in this process. + */ +function emitRunStart(config: Config, sessionId: string): void { + const catalog = config.knownModels ? "live" : "bundled"; + const thinking = config.thinking ? "on" : "off"; + const { mode, providerId, modelId } = config; + emitJsonLine("stdout", { + type: "run_start", + providerId, + modelId, + catalog, + thinking, + mode, + sessionId, + }); +} + function printModelProviderInfo(config: Config): void { const catalog = config.knownModels ? "live" : "bundled"; const thinking = config.thinking ? "on" : "off"; const { mode, providerId, modelId } = config; - if (config.outputMode === "json") { - emitJsonLine("stdout", { - type: "run_start", - providerId, - modelId, - catalog, - thinking, - mode, - sessionId: getActiveCliSession()?.manifest.session_id, - }); - return; - } writeln( `${c.dim}[model] provider=${providerId} model=${modelId} catalog=${catalog} thinking=${thinking} mode=${mode}${c.reset}\n`, ); @@ -272,7 +282,9 @@ export async function runAgent( // --- Main execution --- try { - if (config.verbose) { + if (config.outputMode === "json") { + emitRunStart(config, plannedSessionId); + } else if (config.verbose) { printModelProviderInfo(config); } const { diff --git a/docs/cli/cli-reference.mdx b/docs/cli/cli-reference.mdx index 138885d1b6..ee3ab995d2 100644 --- a/docs/cli/cli-reference.mdx +++ b/docs/cli/cli-reference.mdx @@ -344,13 +344,14 @@ When you give Cline a task with `--json`, the CLI prints one JSON object per lin | `type` | Description | |--------|-------------| +| `run_start` | The first line of a run: `sessionId` is the ID of the session created for this invocation, alongside `providerId`, `modelId`, `mode`, `catalog` and `thinking` | | `agent_event` | An event from the agent, in `event`. `event.type` is one of the [agent events](/sdk/reference/events#agentevent): `content_start`, `content_update`, `content_end`, `iteration_start`, `iteration_end`, `usage`, `notice`, `done` or `error` | | `hook_event` | A lifecycle hook ran (`hookEventName`, `agentId`, `taskId`) | | `team_event` | An event from an [agent team](/cli/agent-teams), in `event` | | `run_result` | The run finished: `finishReason`, `iterations`, `usage`, `durationMs`, `model` and the final `text`. A stopped run has `finishReason` `aborted` | | `run_aborted` | Follows `run_result` when you or another client stopped the run (`reason`, `message`) | -Errors, including a `--timeout` that stops the run, are printed to stderr as `{"type":"error","message":...}`. Other line types can appear, such as `run_start` with `--verbose`, so have scripts skip types they don't use. +Errors, including a `--timeout` that stops the run, are printed to stderr as `{"type":"error","message":...}`. Other line types can appear, so have scripts skip types they don't use. To print only the final answer: