fix(cli): emit JSON run_start with the invocation's planned session id

In --json mode run_start was only emitted with --verbose and sourced its
sessionId from getActiveCliSession(), which is not set until after
sessionManager.start() resolves (so the field was undefined, or a stale
id from a previous run in the same process).

- Emit run_start whenever outputMode === 'json', independent of verbose
- Populate sessionId from plannedSessionId (the id passed to start())
- Keep the human-readable [model] line gated on verbose text runs
- Add regression tests and document run_start in the CLI reference

Fixes #15157
This commit is contained in:
cline
2026-10-19 15:05:01 +00:00
parent 41e75efc53
commit d0d16170bf
3 changed files with 217 additions and 15 deletions
+189
View File
@@ -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<string, unknown>) =>
({
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",
),
);
});
});
});
+26 -14
View File
@@ -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 {
+2 -1
View File
@@ -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: