mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user