Skip to content

Commit cc631f8

Browse files
committed
feat(cli): headless session resume — -p --continue chains multi-turn runs
- headless runs persist their finished RunState in the TUI's chat format ({projectDataDir}/chats/<id>/) — sessions are interchangeable between the TUI and headless surfaces - levelcode -p --continue [id] resumes the most recent session (or a given session_id) by passing previousRun to the agent; TUI conversations can be continued headlessly and vice versa - success result events gain session_id so CI can chain: run 1 → capture session_id → run 2 --continue <id> - missing session → exit 2 with an actionable message - 7 new tests (store round-trip, resume passthrough, session_id emission, no-save-on-failure)
1 parent e539c94 commit cc631f8

5 files changed

Lines changed: 280 additions & 3 deletions

File tree

‎cli/src/headless/__tests__/run-headless.test.ts‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { describe, it, expect } from 'bun:test'
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2+
import * as fs from 'fs'
3+
import * as os from 'os'
4+
import * as path from 'path'
25

36
import { runHeadless } from '../run-headless'
7+
import { loadHeadlessRunState, saveHeadlessRunState } from '../session-store'
8+
import { setProjectRoot } from '../../project-files'
49

510
import type { PrintModeEvent } from '@levelcode/common/types/print-mode'
6-
import type { LevelCodeClient } from '@levelcode/sdk'
11+
import type { LevelCodeClient, RunState } from '@levelcode/sdk'
712

813
type Sink = { stdout: string[]; stderr: string[] }
914

@@ -162,6 +167,108 @@ describe('runHeadless', () => {
162167
})
163168
})
164169

170+
describe('runHeadless session resume', () => {
171+
let tmpDir: string
172+
beforeEach(() => {
173+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hl-resume-'))
174+
setProjectRoot(tmpDir)
175+
})
176+
afterEach(() => {
177+
fs.rmSync(tmpDir, { recursive: true, force: true })
178+
})
179+
180+
it('errors with exit code 2 when --continue finds no session', async () => {
181+
const { capture } = makeSink()
182+
const { exitCode, result } = await runHeadless({
183+
prompt: 'next turn',
184+
outputFormat: 'json',
185+
agentOverride: null,
186+
continueChat: true,
187+
client: fakeClient([]),
188+
sink: capture,
189+
})
190+
191+
expect(exitCode).toBe(2)
192+
expect(result.is_error).toBe(true)
193+
expect(String(result.message)).toContain('No resumable session')
194+
})
195+
196+
it('passes the loaded RunState as previousRun to the client', async () => {
197+
const state = { sessionState: { mainAgentState: { messageHistory: [] } } } as unknown as RunState
198+
const chatId = saveHeadlessRunState(state)
199+
expect(chatId).toBeTruthy()
200+
201+
let sawPreviousRun: unknown
202+
const client = {
203+
run: async (opts: any) => {
204+
sawPreviousRun = opts.previousRun
205+
await opts.handleEvent?.({ type: 'text', text: 'resumed' })
206+
return state
207+
},
208+
} as unknown as LevelCodeClient
209+
210+
const { exitCode, result } = await runHeadless({
211+
prompt: 'next turn',
212+
outputFormat: 'json',
213+
agentOverride: null,
214+
continueChat: true,
215+
continueId: chatId!,
216+
client,
217+
sink: makeSink().capture,
218+
})
219+
220+
expect(exitCode).toBe(0)
221+
// Saved state round-trips through JSON, so compare by structure.
222+
expect(sawPreviousRun).toEqual(state)
223+
expect(result.result).toBe('resumed')
224+
})
225+
226+
it('saves the finished run and reports session_id on success', async () => {
227+
const state = { sessionState: { mainAgentState: { messageHistory: [] } } } as unknown as RunState
228+
const client = {
229+
run: async ({ handleEvent }: any) => {
230+
await handleEvent?.({ type: 'text', text: 'done' })
231+
return state
232+
},
233+
} as unknown as LevelCodeClient
234+
235+
const { capture } = makeSink()
236+
const { exitCode, result } = await runHeadless({
237+
prompt: 'first turn',
238+
outputFormat: 'json',
239+
agentOverride: null,
240+
client,
241+
sink: capture,
242+
})
243+
244+
expect(exitCode).toBe(0)
245+
expect(result.session_id).toMatch(/^[0-9a-f-]{36}$/)
246+
247+
// The saved session is resumable
248+
const loaded = loadHeadlessRunState(result.session_id as string)
249+
expect(loaded).not.toBeNull()
250+
})
251+
252+
it('does not save a session after a failed run', async () => {
253+
const client = {
254+
run: async () => {
255+
throw new Error('boom')
256+
},
257+
} as unknown as LevelCodeClient
258+
259+
const { result } = await runHeadless({
260+
prompt: 'failing',
261+
outputFormat: 'json',
262+
agentOverride: null,
263+
client,
264+
sink: makeSink().capture,
265+
})
266+
267+
expect(result.is_error).toBe(true)
268+
expect(result.session_id).toBeUndefined()
269+
})
270+
})
271+
165272
describe('runHeadless failure contract', () => {
166273
it('reports failure when the run finishes without any output or finish event', async () => {
167274
const { sink, capture } = makeSink()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2+
import * as fs from 'fs'
3+
import * as os from 'os'
4+
import * as path from 'path'
5+
6+
import { saveHeadlessRunState, loadHeadlessRunState } from '../session-store'
7+
import { setProjectRoot, getProjectDataDir } from '../../project-files'
8+
9+
import type { RunState } from '@levelcode/sdk'
10+
11+
const makeRunState = (marker: string): RunState =>
12+
({
13+
sessionState: {
14+
mainAgentState: { messageHistory: [{ role: 'user', content: marker }] },
15+
},
16+
output: { type: 'lastMessage', value: [] },
17+
}) as unknown as RunState
18+
19+
describe('headless session store', () => {
20+
let tmpDir: string
21+
22+
beforeEach(() => {
23+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hl-session-'))
24+
setProjectRoot(tmpDir)
25+
})
26+
27+
afterEach(() => {
28+
fs.rmSync(tmpDir, { recursive: true, force: true })
29+
})
30+
31+
it('save returns a chat id and the state round-trips by id', () => {
32+
const state = makeRunState('resume-me')
33+
const chatId = saveHeadlessRunState(state)
34+
35+
expect(chatId).toBeTruthy()
36+
expect(chatId).toMatch(/^[0-9a-f-]{36}$/)
37+
38+
const loaded = loadHeadlessRunState(chatId!)
39+
expect(loaded).not.toBeNull()
40+
expect(
41+
(loaded as unknown as { sessionState: { mainAgentState: { messageHistory: Array<{ content: string }> } } })
42+
.sessionState.mainAgentState.messageHistory[0]!.content,
43+
).toBe('resume-me')
44+
45+
// Both files exist (TUI-compatible format)
46+
const chatDir = path.join(getProjectDataDir(), 'chats', chatId!)
47+
expect(fs.existsSync(path.join(chatDir, 'run-state.json'))).toBe(true)
48+
expect(fs.existsSync(path.join(chatDir, 'chat-messages.json'))).toBe(true)
49+
})
50+
51+
it('load without an id returns the most recent saved session', () => {
52+
const first = saveHeadlessRunState(makeRunState('first'))
53+
const second = saveHeadlessRunState(makeRunState('second'))
54+
55+
expect(first).toBeTruthy()
56+
expect(second).toBeTruthy()
57+
58+
const loaded = loadHeadlessRunState()
59+
expect(
60+
(loaded as unknown as { sessionState: { mainAgentState: { messageHistory: Array<{ content: string }> } } })
61+
.sessionState.mainAgentState.messageHistory[0]!.content,
62+
).toBe('second')
63+
})
64+
65+
it('returns null when no sessions exist', () => {
66+
expect(loadHeadlessRunState()).toBeNull()
67+
})
68+
69+
it('returns null for an unknown chat id', () => {
70+
expect(loadHeadlessRunState('no-such-id')).toBeNull()
71+
})
72+
})

‎cli/src/headless/run-headless.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getProjectRoot } from '../project-files'
2222
import { initializeApp } from '../init/init-app'
2323
import { getLevelCodeClient } from '../utils/levelcode-client'
2424
import { isSensitiveFile } from '../utils/create-run-config'
25+
import { saveHeadlessRunState, loadHeadlessRunState } from './session-store'
2526

2627
import type { PrintModeEvent } from '@levelcode/common/types/print-mode'
2728
import type { FileFilter, LevelCodeClient } from '@levelcode/sdk'
@@ -38,6 +39,9 @@ export type HeadlessOptions = {
3839
lenientExit?: boolean
3940
/** Test seam: run against this client instead of the real one. */
4041
client?: LevelCodeClient
42+
/** Resume the most recent conversation (or continueId if provided). */
43+
continueChat?: boolean
44+
continueId?: string | null
4145
/** Test seam: capture output instead of writing to the process streams. */
4246
sink?: {
4347
stdout: (chunk: string) => void
@@ -142,10 +146,30 @@ export async function runHeadless(options: HeadlessOptions): Promise<HeadlessRes
142146
}
143147
}
144148

149+
let previousRun: unknown
150+
if (options.continueChat) {
151+
const loaded = loadHeadlessRunState(options.continueId ?? undefined)
152+
if (!loaded) {
153+
const id = options.continueId ?? '(most recent)'
154+
const message = `No resumable session found for ${id}. Run levelcode -p without --continue first, or pass a valid session_id.`
155+
if (outputFormat === 'text') {
156+
err(`error: ${message}
157+
`)
158+
return { exitCode: 2, result: { type: 'result', subtype: 'error_during_execution' as const, is_error: true, message } }
159+
}
160+
emit({ type: 'system', subtype: 'init', ok: false })
161+
emit({ type: 'result', subtype: 'error_during_execution' as const, is_error: true, message })
162+
return { exitCode: 2, result: { type: 'result', subtype: 'error_during_execution' as const, is_error: true, message } }
163+
}
164+
previousRun = loaded
165+
}
166+
167+
let finishedRun: unknown
145168
try {
146-
await client.run({
169+
finishedRun = await client.run({
147170
agent: agentOverride ?? 'base2',
148171
prompt,
172+
...(previousRun ? { previousRun: previousRun as never } : {}),
149173
handleEvent,
150174
maxAgentSteps: 100,
151175
fileFilter: ((filePath: string) => {
@@ -169,6 +193,14 @@ export async function runHeadless(options: HeadlessOptions): Promise<HeadlessRes
169193
sawError = true
170194
}
171195

196+
// Persist the session for --continue chaining (best-effort).
197+
let sessionId: string | null = null
198+
if (!sawError && finishedRun) {
199+
try {
200+
sessionId = saveHeadlessRunState(finishedRun as never)
201+
} catch { /* persistence is best-effort */ }
202+
}
203+
172204
const result: Record<string, unknown> = {
173205
type: 'result',
174206
subtype: sawError
@@ -180,6 +212,7 @@ export async function runHeadless(options: HeadlessOptions): Promise<HeadlessRes
180212
num_tool_calls: numToolCalls,
181213
result: finalText,
182214
project: path.basename(projectRoot),
215+
...(sessionId ? { session_id: sessionId } : {}),
183216
}
184217

185218
if (outputFormat === 'stream-json') {

‎cli/src/headless/session-store.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Headless session persistence — a thin, explicit wrapper over the TUI's
3+
* chat-state storage format ({projectDataDir}/chats/<id>/run-state.json).
4+
*
5+
* Headless runs save their RunState after completion so a later run can
6+
* resume them via `levelcode -p --continue [id]`, and the saved format is
7+
* the same one the TUI writes — sessions are interchangeable between the
8+
* two surfaces.
9+
*/
10+
11+
import crypto from 'crypto'
12+
import fs from 'fs'
13+
import path from 'path'
14+
15+
import { getProjectDataDir } from '../project-files'
16+
import { loadMostRecentChatState } from '../utils/run-state-storage'
17+
18+
import type { RunState } from '@levelcode/sdk'
19+
20+
const RUN_STATE_FILENAME = 'run-state.json'
21+
const CHAT_MESSAGES_FILENAME = 'chat-messages.json'
22+
23+
/**
24+
* Save a finished headless RunState as a new chat directory.
25+
* Returns the chat id (also emitted in the headless `result` event as
26+
* session_id) so callers can chain runs with --continue <id>.
27+
*/
28+
export function saveHeadlessRunState(runState: RunState): string | null {
29+
try {
30+
const chatId = crypto.randomUUID()
31+
const chatDir = path.join(getProjectDataDir(), 'chats', chatId)
32+
fs.mkdirSync(chatDir, { recursive: true })
33+
fs.writeFileSync(
34+
path.join(chatDir, RUN_STATE_FILENAME),
35+
JSON.stringify(runState, null, 2),
36+
'utf-8',
37+
)
38+
// Headless has no transcript view model; an empty messages array keeps
39+
// the storage format compatible with loadMostRecentChatState (which
40+
// requires both files) and with TUI resume (context comes from runState).
41+
fs.writeFileSync(
42+
path.join(chatDir, CHAT_MESSAGES_FILENAME),
43+
'[]',
44+
'utf-8',
45+
)
46+
return chatId
47+
} catch {
48+
return null
49+
}
50+
}
51+
52+
/**
53+
* Load the RunState to resume: the given chat id, or the most recent chat
54+
* in this project (TUI sessions included).
55+
*/
56+
export function loadHeadlessRunState(chatId?: string): RunState | null {
57+
const loaded = loadMostRecentChatState(chatId)
58+
return loaded?.runState ?? null
59+
}

‎cli/src/index.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const options = program.opts<{
2121
agent?: string
2222
cwd?: string
2323
worktree?: string
24+
continue?: boolean | string
2425
}>()
2526

2627
// `doctor` runs from node builtins only — never load the heavy runtime.
@@ -65,6 +66,11 @@ if (argv1 === 'doctor') {
6566
outputFormat: format as 'text' | 'json' | 'stream-json',
6667
agentOverride: options.agent?.trim() || null,
6768
cwdOverride,
69+
continueChat: Boolean(options.continue),
70+
continueId:
71+
typeof options.continue === 'string' && options.continue.trim().length > 0
72+
? options.continue.trim()
73+
: null,
6874
})
6975
process.exit(exitCode)
7076
})

0 commit comments

Comments
 (0)