Skip to content

Commit fc93701

Browse files
committed
feat: add --session-id and JSON output support in exec mode
1 parent 9e2e08f commit fc93701

11 files changed

Lines changed: 445 additions & 20 deletions

File tree

‎packages/cli/src/cli-args.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,19 @@ import Yargs from "yargs";
88
import { getCliVersion } from "./utils/version";
99
import { writeStderrLine } from "./utils/stdio-helpers";
1010
import { hideBin } from "yargs/helpers";
11+
import { isValidSessionId } from "@vegamo/deepcode-core";
1112

12-
// UUID v4 regex pattern for validation
13-
const SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
14-
15-
/**
16-
* Validates if a string is a valid session ID format.
17-
*/
18-
export function isValidSessionId(value: string): boolean {
19-
return SESSION_ID_REGEX.test(value);
20-
}
13+
export { isValidSessionId };
2114

2215
export interface ParsedCliArgs {
2316
/** Prompt text from -p / --prompt */
2417
prompt: string | undefined;
2518
/** Run one prompt without starting the interactive TUI. */
2619
exec: boolean;
20+
/** UUID to use when creating a new session in exec mode. */
21+
sessionId: string | undefined;
22+
/** Output the final assistant message as a single JSON object. */
23+
json: boolean;
2724
/**
2825
* Resume session identifier:
2926
* - `undefined` — --resume was not used
@@ -100,6 +97,16 @@ async function configureYargs(argv?: string[]) {
10097
type: "string",
10198
describe: "Resume a specific session by its ID. Use without an ID to show session picker.",
10299
})
100+
.option("session-id", {
101+
type: "string",
102+
requiresArg: true,
103+
describe: "Adopt the persisted Session with this id",
104+
})
105+
.option("json", {
106+
type: "boolean",
107+
default: false,
108+
describe: "Output a single JSON object instead of human-readable text.",
109+
})
103110
.option("fork", {
104111
alias: "f",
105112
type: "string",
@@ -116,6 +123,18 @@ async function configureYargs(argv?: string[]) {
116123
const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query;
117124
const prompt = argv["prompt"] as string | undefined;
118125
const exec = argv["exec"] === true;
126+
const sessionId = argv["session-id"] as string | undefined;
127+
128+
if (sessionId !== undefined) {
129+
if (!exec) return "--session-id requires --exec / -x.";
130+
if (argv["resume"] !== undefined || argv["fork"] !== undefined || argv["last"] === true) {
131+
return "Cannot use --session-id together with --resume, --fork, or --last.";
132+
}
133+
if (!isValidSessionId(sessionId)) {
134+
return `Invalid session ID: "${sessionId}". Must be a valid UUID.`;
135+
}
136+
}
137+
if (argv["json"] === true && !exec) return "--json requires --exec / -x.";
119138

120139
if (prompt && hasPositionalQuery) {
121140
return "Cannot use both a positional prompt and the --prompt (-p) flag together";
@@ -212,6 +231,8 @@ export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
212231
return {
213232
prompt: parsed.prompt as string | undefined,
214233
exec: parsed.exec === true,
234+
sessionId: (parsed["session-id"] as string | undefined)?.toLowerCase(),
235+
json: parsed.json === true,
215236
resume,
216237
fork,
217238
version: parsed.version === true,

‎packages/cli/src/cli.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ async function main(): Promise<void> {
7474
process.exitCode = await runExecMode({
7575
prompt: parsed.prompt!,
7676
projectRoot,
77+
sessionId: parsed.sessionId,
78+
json: parsed.json,
7779
resumeSessionId: typeof resumeSessionId === "string" ? resumeSessionId : undefined,
7880
forkSessionId: typeof forkSessionId === "string" ? forkSessionId : undefined,
7981
});

‎packages/cli/src/exec-runner.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import {
66
type AskPermissionScope,
77
type PermissionSettings,
88
type SessionManagerOptions,
9+
type SessionMessage,
910
} from "@vegamo/deepcode-core";
1011
import { buildExecPrompt, type ExecInputStream } from "./exec-input";
1112
import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers";
13+
import { formatSessionMessageJson } from "./session-message-json";
1214

1315
type ExecSessionManager = Pick<
1416
SessionManager,
@@ -30,6 +32,8 @@ interface SignalTarget {
3032
export interface ExecRunnerOptions {
3133
prompt: string;
3234
projectRoot: string;
35+
sessionId?: string;
36+
json?: boolean;
3337
resumeSessionId?: string;
3438
forkSessionId?: string;
3539
input?: ExecInputStream;
@@ -61,6 +65,7 @@ export async function runExecMode(
6165
const deps = { ...defaultDependencies, ...dependencies };
6266
let manager: ExecSessionManager | null = null;
6367
let interrupted = false;
68+
const result: { message?: SessionMessage } = {};
6469

6570
const handleSigint = (): void => {
6671
interrupted = true;
@@ -76,7 +81,10 @@ export async function runExecMode(
7681
getResolvedSettings: () => deps.resolveSettings(options.projectRoot),
7782
renderMarkdown: (text) => text,
7883
nonInteractive: true,
79-
onAssistantMessage: () => {},
84+
onAssistantMessage: (message, shouldConnect) => {
85+
// Ignore transient notices and tool messages; retain model responses.
86+
if (shouldConnect && message.role === "assistant") result.message = message;
87+
},
8088
});
8189

8290
await manager.initMcpServers(settings.mcpServers);
@@ -108,7 +116,7 @@ export async function runExecMode(
108116
manager.setActiveSessionId(options.resumeSessionId);
109117
}
110118

111-
await manager.handleUserPrompt({ text: prompt });
119+
await manager.handleUserPrompt({ text: prompt }, { sessionId: options.sessionId });
112120
const sessionId = manager.getActiveSessionId();
113121
const session = sessionId ? manager.getSession(sessionId) : null;
114122

@@ -137,7 +145,15 @@ export async function runExecMode(
137145
return 1;
138146
}
139147

140-
deps.writeStdoutLine(session.assistantReply ?? "");
148+
if (options.json) {
149+
if (!result.message || result.message.sessionId !== sessionId) {
150+
deps.writeStderrLine("Execution completed without a final assistant message.");
151+
return 1;
152+
}
153+
deps.writeStdoutLine(formatSessionMessageJson(result.message));
154+
} else {
155+
deps.writeStdoutLine(session.assistantReply ?? "");
156+
}
141157
return 0;
142158
} catch (error) {
143159
if (interrupted) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { SessionMessage } from "@vegamo/deepcode-core";
2+
3+
export function formatSessionMessageJson(message: SessionMessage): string {
4+
return JSON.stringify(omitEmptyValues(message));
5+
}
6+
7+
function omitEmptyValues(value: unknown): unknown {
8+
if (value === null || value === undefined || value === "") return undefined;
9+
if (Array.isArray(value)) {
10+
const items = value.map(omitEmptyValues).filter((item) => item !== undefined);
11+
return items.length ? items : undefined;
12+
}
13+
if (typeof value === "object") {
14+
const entries = Object.entries(value)
15+
.map(([key, item]) => [key, omitEmptyValues(item)] as const)
16+
.filter(([, item]) => item !== undefined);
17+
return entries.length ? Object.fromEntries(entries) : undefined;
18+
}
19+
return value;
20+
}

‎packages/cli/src/tests/cli-args.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,63 @@ test("parseArguments returns prompt after -p", async () => {
2121
assert.ok(!("message" in r));
2222
assert.equal(r.prompt, "hello world");
2323
assert.equal(r.exec, false);
24-
});
24+
assert.equal(r.sessionId, undefined);
25+
assert.equal(r.json, false);
26+
});
27+
28+
test("parseArguments accepts a custom UUID and JSON output in exec mode", async () => {
29+
const id = "0A5CB7A5-C39D-4C39-A11B-05F8B22B8DF6";
30+
const parsed = await parseArguments(["--exec", "--prompt", "hello", "--session-id", id, "--json"]);
31+
assert.equal(parsed.sessionId, id.toLowerCase());
32+
assert.equal(parsed.json, true);
33+
});
34+
35+
test("isValidSessionId accepts standard UUID versions and rejects invalid variants", () => {
36+
for (let version = 1; version <= 8; version++) {
37+
assert.ok(isValidSessionId(`0a5cb7a5-c39d-${version}c39-a11b-05f8b22b8df6`));
38+
}
39+
assert.ok(isValidSessionId("00000000-0000-0000-0000-000000000000"));
40+
assert.ok(isValidSessionId("ffffffff-ffff-ffff-ffff-ffffffffffff"));
41+
assert.ok(!isValidSessionId("0a5cb7a5-c39d-4c39-711b-05f8b22b8df6"));
42+
assert.ok(!isValidSessionId("0a5cb7a5-c39d-9c39-a11b-05f8b22b8df6"));
43+
});
44+
45+
for (const flags of [
46+
["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"],
47+
["--fork", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"],
48+
["--fork"],
49+
["--last"],
50+
]) {
51+
test(`parseArguments accepts --json with exec and ${flags.join(" ")}`, async () => {
52+
const parsed = await parseArguments(["-x", "-p", "hello", "--json", ...flags]);
53+
assert.equal(parsed.json, true);
54+
});
55+
}
56+
57+
for (const args of [
58+
["--session-id", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"],
59+
["--json"],
60+
["-x", "-p", "hello", "--session-id"],
61+
["-x", "-p", "hello", "--session-id", ""],
62+
["-x", "-p", "hello", "--session-id", "not-a-uuid"],
63+
["-x", "--json"],
64+
["-x", "--session-id", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"],
65+
...["-r", "--resume", "-f", "--fork", "-l", "--last"].map((flag) => [
66+
"-x",
67+
"-p",
68+
"hello",
69+
"--session-id",
70+
"0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6",
71+
flag,
72+
]),
73+
]) {
74+
test(`parseArguments rejects ${args.join(" ")}`, async () => {
75+
await withMockedExit(async (exitSpy) => {
76+
await assert.rejects(parseArguments(args), /process.exit\(1\)/);
77+
assert.ok(exitSpy.calls.includes(1));
78+
});
79+
});
80+
}
2581

2682
test("parseArguments enables exec mode with -x", async () => {
2783
const r = await parseArguments(["-x", "-p", "hello world"]);

‎packages/cli/src/tests/exec-runner.test.ts‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ResolvedDeepcodingSettings,
66
SessionEntry,
77
SessionManagerOptions,
8+
SessionMessage,
89
SessionStatus,
910
UserPromptContent,
1011
} from "@vegamo/deepcode-core";
@@ -84,6 +85,7 @@ function pipedInput(content: string): ExecInputStream {
8485
type ManagerScenario = {
8586
finalStatus?: SessionStatus;
8687
finalReply?: string | null;
88+
finalMessageParams?: unknown;
8789
failReason?: string | null;
8890
resumeExists?: boolean;
8991
throwFromPrompt?: Error;
@@ -127,10 +129,10 @@ function createHarness(scenario: ManagerScenario = {}) {
127129
entry = createEntry(activeId, "completed");
128130
return activeId;
129131
},
130-
handleUserPrompt: async (prompt) => {
132+
handleUserPrompt: async (prompt, promptOptions) => {
131133
submitted.push(prompt);
132134
if (scenario.throwFromPrompt) throw scenario.throwFromPrompt;
133-
activeId ??= "new-session";
135+
activeId ??= promptOptions?.sessionId ?? "new-session";
134136
options.onSessionEntryUpdated?.(createEntry(activeId, "processing"));
135137
options.onAssistantMessage(
136138
{
@@ -154,6 +156,21 @@ function createHarness(scenario: ManagerScenario = {}) {
154156
failReason: scenario.failReason ?? null,
155157
askPermissions: scenario.askPermissions,
156158
});
159+
const finalMessage: SessionMessage = {
160+
id: "final-message",
161+
sessionId: activeId,
162+
role: "assistant",
163+
content: entry.assistantReply,
164+
contentParams: null,
165+
messageParams: scenario.finalMessageParams ?? null,
166+
compacted: false,
167+
visible: Boolean(entry.assistantReply),
168+
createTime: entry.createTime,
169+
updateTime: entry.updateTime,
170+
};
171+
options.onAssistantMessage(finalMessage, true);
172+
options.onAssistantMessage({ ...finalMessage, id: "notice", content: "diagnostic" }, false);
173+
options.onAssistantMessage({ ...finalMessage, id: "tool", role: "tool", content: "tool output" }, true);
157174
options.onSessionEntryUpdated?.(entry);
158175
},
159176
initMcpServers: async (servers) => {
@@ -224,6 +241,76 @@ test("runExecMode submits piped stdin in the persisted user prompt", async () =>
224241
assert.deepEqual(harness.stderr, []);
225242
});
226243

244+
for (const sessionOptions of [
245+
{ sessionId: RESUME_ID },
246+
{ resumeSessionId: RESUME_ID },
247+
{ forkSessionId: RESUME_ID },
248+
{},
249+
]) {
250+
test(`runExecMode outputs only the final message as JSON with ${JSON.stringify(sessionOptions)}`, async () => {
251+
const harness = createHarness({ resumeExists: true, finalMessageParams: { reasoning_content: "thoughts" } });
252+
const code = await runExecMode(
253+
{ prompt: "task", projectRoot: "/tmp/project", input: ttyInput(), json: true, ...sessionOptions },
254+
harness.dependencies
255+
);
256+
assert.equal(code, 0);
257+
assert.equal(harness.stdout.length, 1);
258+
assert.deepEqual(JSON.parse(harness.stdout[0]), {
259+
id: "final-message",
260+
sessionId: sessionOptions.forkSessionId
261+
? "forked-session"
262+
: (sessionOptions.sessionId ?? sessionOptions.resumeSessionId ?? "new-session"),
263+
role: "assistant",
264+
content: "final answer",
265+
messageParams: { reasoning_content: "thoughts" },
266+
compacted: false,
267+
visible: true,
268+
createTime: "2026-01-01T00:00:00.000Z",
269+
updateTime: "2026-01-01T00:00:00.000Z",
270+
});
271+
assert.deepEqual(harness.stderr, []);
272+
assert.equal(harness.disposed, 1);
273+
});
274+
}
275+
276+
test("runExecMode outputs JSON even when the final message has no text", async () => {
277+
const harness = createHarness({ finalReply: null });
278+
const code = await runExecMode(
279+
{ prompt: "task", projectRoot: "/tmp/project", input: ttyInput(), json: true },
280+
harness.dependencies
281+
);
282+
assert.equal(code, 0);
283+
const message = JSON.parse(harness.stdout[0]);
284+
assert.equal(message.id, "final-message");
285+
assert.equal(message.visible, false);
286+
assert.equal("content" in message, false);
287+
});
288+
289+
for (const finalStatus of ["failed", "ask_permission", "waiting_for_user", "interrupted"] as const) {
290+
test(`runExecMode keeps stderr diagnostics and exit status in JSON mode for ${finalStatus}`, async () => {
291+
const harness = createHarness({ finalStatus });
292+
const code = await runExecMode(
293+
{ prompt: "task", projectRoot: "/tmp/project", input: ttyInput(), json: true },
294+
harness.dependencies
295+
);
296+
assert.equal(code, 1);
297+
assert.deepEqual(harness.stdout, []);
298+
assert.equal(harness.stderr.length, 1);
299+
assert.equal(harness.disposed, 1);
300+
});
301+
}
302+
303+
test("runExecMode keeps stdout empty on SIGINT in JSON mode", async () => {
304+
const harness = createHarness({ duringPrompt: () => harness.emitSigint() });
305+
const code = await runExecMode(
306+
{ prompt: "task", projectRoot: "/tmp/project", input: ttyInput(), json: true },
307+
harness.dependencies
308+
);
309+
assert.equal(code, 130);
310+
assert.deepEqual(harness.stdout, []);
311+
assert.equal(harness.disposed, 1);
312+
});
313+
227314
test("runExecMode resumes a validated session before submitting", async () => {
228315
const harness = createHarness({ resumeExists: true, finalReply: "continued" });
229316
const code = await runExecMode(

0 commit comments

Comments
 (0)