feat: show runtime checklists and support remote Continue

Display native OpenCode and Continue checklists alongside scheduled tasks, preserving request ownership, explicit clears and saved progress. Bundle both remote runtimes and support managed Continue installation, model routing, cancellation and restart continuation.

Validation: real local and Linux remote model checks, full Electron remote Continue flows, typecheck and lint passed. Full suite: 4707 passed, 67 skipped, one OpenCode compaction failure passed targeted rerun.

Release note: Display native execution checklists above conversations and support managed remote Continue projects with saved progress and cancellation recovery.
This commit is contained in:
mesalogo
2026-09-19 10:47:27 +08:00
parent 164e89253a
commit c7a93803c9
78 changed files with 2266 additions and 238 deletions
+170 -4
View File
@@ -1,4 +1,7 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
AgentSideConnection,
PROTOCOL_VERSION,
@@ -35,7 +38,7 @@ import type {
RemotePromptOperationReconciliationResult,
RemoteRuntimeChannel
} from './remote-runtime-channel'
import { MemoryRuntimeSessionBindingStore } from './runtime-session-binding-store'
import { MemoryRuntimeSessionBindingStore, SqliteRuntimeSessionBindingStore, type RuntimeSessionBindingStore } from './runtime-session-binding-store'
import { AssistantDatabase } from '../assistant/assistant-database'
type FakeServer = {
@@ -277,7 +280,7 @@ function runtime(
function factoryRuntime(
factory: (bindingId: string) => Promise<RemoteRuntimeChannel>,
store = new MemoryRuntimeSessionBindingStore(),
store: RuntimeSessionBindingStore = new MemoryRuntimeSessionBindingStore(),
overrides: Partial<AcpRemoteRuntimeOptions> = {}
): AcpRemoteRuntime {
return new AcpRemoteRuntime({
@@ -1459,7 +1462,7 @@ describe('AcpRemoteRuntime', () => {
'text',
'reasoning',
'tool',
'status',
'checklist',
'context-metrics',
'model-usage',
'done'
@@ -2591,6 +2594,8 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
}
function ownedChannel(options?: {
runtimeId?: 'opencode' | 'continue'
bindingStore?: RuntimeSessionBindingStore
updates?: SessionUpdate[]
state?: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
terminalPayload?: unknown
@@ -2803,11 +2808,12 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
})),
close: vi.fn(async () => {})
}
const store = new MemoryRuntimeSessionBindingStore()
const store = options?.bindingStore ?? new MemoryRuntimeSessionBindingStore()
const instance = factoryRuntime(
async () => channel,
store,
{
runtimeId: options?.runtimeId ?? 'opencode',
modelBridgePolicy,
modelProfile: ownedModelProfile
}
@@ -2825,6 +2831,166 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
}
}
it.each(['continue', 'opencode'] as const)('continues %s after reopening the desktop binding database', async (runtimeId) => {
const directory = mkdtempSync(join(tmpdir(), 'remote-runtime-restart-'))
const databasePath = join(directory, 'bindings.sqlite')
let store = new SqliteRuntimeSessionBindingStore(databasePath)
const first = ownedChannel({ runtimeId, bindingStore: store })
let replacement: AcpRemoteRuntime | undefined
try {
await collect(first.instance.run(request, new AbortController().signal))
const original = (await store.getByConversation(request.conversationId))!
await first.instance.dispose()
store.close()
store = new SqliteRuntimeSessionBindingStore(databasePath)
const next = ownedChannel({ runtimeId, bindingStore: store })
replacement = next.instance
if (runtimeId === 'continue') {
next.startOwnedPrompt.mockImplementationOnce(async input => {
if ('acpSessionId' in input) throw new Error('Continue Session is unavailable')
return { bindingId: input.bindingId, operationId: input.operationId, requestId: input.requestId,
sessionId: 'owned-session', state: 'running', latestSemanticSequence: '0' }
})
}
const history = [{ role: 'user' as const, content: 'previous question' },
{ role: 'assistant' as const, content: 'previous answer' }]
const events = await collect(replacement.run({ ...request, requestId: 'second', history }, new AbortController().signal))
expect(events.some(event => event.type === 'done')).toBe(true)
const resumed = (await store.getByConversation(request.conversationId))!
const start = next.startOwnedPrompt.mock.calls[0]![0]
if (runtimeId === 'continue') {
expect(resumed.bindingId).not.toBe(original.bindingId)
expect(await store.getById(original.bindingId)).toMatchObject({ state: 'closed' })
expect(start).not.toHaveProperty('acpSessionId')
expect(JSON.stringify(start)).toContain('previous answer')
expect(resumed.promptSequence).toBe(0)
} else {
expect(resumed.bindingId).toBe(original.bindingId)
expect(start).toHaveProperty('acpSessionId', original.acpSessionId)
expect(resumed.promptSequence).toBe(1)
}
await collect(replacement.run({ ...request, requestId: 'third' }, new AbortController().signal))
expect(await store.getByConversation(request.conversationId)).toMatchObject({
bindingId: resumed.bindingId, state: 'ready', promptSequence: resumed.promptSequence + 1
})
expect(next.startOwnedPrompt).toHaveBeenCalledTimes(2)
expect(next.startOwnedPrompt.mock.calls[1]![0]).toHaveProperty('acpSessionId', resumed.acpSessionId)
expect(next.attachOwnedPrompt).not.toHaveBeenCalled()
} finally {
await replacement?.dispose()
await first.instance.dispose()
store.close()
rmSync(directory, { recursive: true, force: true })
}
})
it.each([true, false])('attaches a cached request after start rejection without advancing its prompt sequence (available: %s)', async available => {
const fixture = ownedChannel({ runtimeId: 'continue' })
fixture.startOwnedPrompt.mockRejectedValueOnce(new Error('Remote Runtime runtime/startPrompt rejected RPC -32602'))
try {
await expect(collect(fixture.instance.run(request, new AbortController().signal))).rejects.toThrow('runtime/startPrompt')
const pending = (await fixture.store.getByConversation(request.conversationId))!
fixture.attachOwnedPrompt.mockImplementationOnce(async input => {
expect(fixture.channel.close).not.toHaveBeenCalled()
if (!available) throw Object.assign(new Error('Remote Runtime runtime/attachPrompt rejected RPC -32602'), {
remoteRequestOutcome: 'rejected', remoteMethod: 'runtime/attachPrompt'
})
return { ...input, sessionId: 'owned-session', state: 'running', latestSemanticSequence: '0' }
})
const recovery = collect(fixture.instance.run({ ...request, remoteRecoveryOnly: true }, new AbortController().signal))
if (available) {
expect((await recovery).some(event => event.type === 'done')).toBe(true)
} else {
await expect(recovery).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
}
expect(await fixture.store.getByConversation(request.conversationId)).toMatchObject({
bindingId: pending.bindingId, state: available ? 'ready' : 'prompt-running', promptSequence: pending.promptSequence
})
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.attachOwnedPrompt).toHaveBeenCalledOnce()
} finally {
await fixture.instance.dispose()
}
})
it('replays complete native checklists and clears with stable provenance without starting another prompt', async () => {
const items = [{ content: 'full content '.repeat(500).trim(), status: 'cancelled', priority: 'low' }]
const native = (todos: unknown, sessionID = 'owned-session'): SessionUpdate => ({
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
_meta: { goodbuddyTodoEvent: { type: 'todo.updated', properties: { sessionID, todos } } }
})
const fixture = ownedChannel({ updates: [
native(items), native([], 'child-session'), native([{ content: 'bad', status: 'failed' }]),
{ sessionUpdate: 'tool_call', toolCallId: 'todo-tool', title: 'todowrite', status: 'completed', rawInput: { todos: [] } },
native([])
] })
const first: RuntimeEvent[] = []
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
first.push(event)
if (event.type === 'done') break
}
const replacement = factoryRuntime(async () => fixture.channel, fixture.store, { modelBridgePolicy, modelProfile: ownedModelProfile })
try {
const replay = await collect(replacement.run({ ...request, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '0' }, new AbortController().signal))
const checklists = first.filter(event => event.type === 'checklist')
expect(checklists).toHaveLength(2)
expect(checklists[0]).toMatchObject({ requestId: request.requestId, checklist: { source: 'opencode', items } })
expect(checklists[1]).toMatchObject({ checklist: { source: 'opencode', items: [] } })
expect(replay.filter(event => event.type === 'checklist')).toEqual(checklists)
expect(first.filter(event => event.type === 'tool')).toHaveLength(1)
expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(1)
expect(fixture.attachOwnedPrompt).toHaveBeenCalledTimes(1)
} finally { await replacement.dispose(); await fixture.instance.dispose() }
})
it('replays complete Continue checklists and ignores wrong-source and malformed updates', async () => {
const items = [{ content: 'complete checklist content '.repeat(300).trim(), status: 'pending' }]
const native = (checklist: unknown): SessionUpdate => ({
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-checklist',
_meta: { goodbuddyChecklist: checklist as never }
})
const fixture = ownedChannel({ runtimeId: 'continue', updates: [
native({ source: 'continue', items }), native({ source: 'opencode', items: [] }),
native({ source: 'continue', items: [{ content: 'bad', status: 'failed' }] }),
{ sessionUpdate: 'tool_call', toolCallId: 'native-tool', title: 'Checklist', status: 'completed', rawInput: { checklist: '' } },
native({ source: 'continue', items: [] })
] })
const first: RuntimeEvent[] = []
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
first.push(event)
if (event.type === 'done') break
}
const replacement = factoryRuntime(async () => fixture.channel, fixture.store, {
runtimeId: 'continue', modelBridgePolicy, modelProfile: ownedModelProfile
})
try {
const replay = await collect(replacement.run({ ...request, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '0' }, new AbortController().signal))
const checklists = first.filter(event => event.type === 'checklist')
expect(checklists).toHaveLength(2)
expect(checklists[0]).toMatchObject({ checklist: { source: 'continue', items } })
expect(checklists[1]).toMatchObject({ checklist: { source: 'continue', items: [] } })
expect(replay.filter(event => event.type === 'checklist')).toEqual(checklists)
expect(first.filter(event => event.type === 'tool')).toHaveLength(1)
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
} finally { await replacement.dispose(); await fixture.instance.dispose() }
})
it('keeps unconfirmed plan variants as activity, not checklist replacement', async () => {
const fixture = ownedChannel({ updates: [
{ sessionUpdate: 'plan', entries: [] },
{ sessionUpdate: 'plan', entries: [null] } as unknown as SessionUpdate,
{ sessionUpdate: 'plan', entries: [{ content: 'bad', status: 'failed', priority: 'high' }] } as unknown as SessionUpdate,
{ sessionUpdate: 'plan_removed', id: 'plan' },
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '- [x] not a native checklist' } }
] })
const events = await collect(fixture.instance.run(request, new AbortController().signal))
expect(events.filter(event => event.type === 'checklist')).toEqual([
expect.objectContaining({ checklist: { source: 'opencode', items: [] } })
])
await fixture.instance.dispose()
})
it('starts once, maps multiple rounds, and ACKs only after consumption', async () => {
const fixture = ownedChannel({
includePermissionCheckpoint: true
+39 -2
View File
@@ -1,6 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'
import { remoteImageToolMcpName } from '../../shared/remote-image-tool-node'
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
import { runtimeChecklistSchema } from '../../shared/runtime-checklist'
import type { AgentQuestionAnswer } from '../../shared/contracts'
import { isDeepStrictEqual } from 'node:util'
import {
@@ -1404,7 +1405,9 @@ export class AcpRemoteRuntime implements AgentRuntime {
let bindingId = binding?.bindingId ?? randomUUID()
if (
binding?.state === 'ready' &&
!this.bindingIdentityMatches(binding)
// Continue helper sessions live only in the remote process. A cold open
// must seed a new session from conversation history after that process exits.
(this.options.runtimeId === 'continue' || !this.bindingIdentityMatches(binding))
) {
await this.closeIdleBinding(binding, signal)
binding = undefined
@@ -1743,6 +1746,16 @@ export class AcpRemoteRuntime implements AgentRuntime {
signal: AbortSignal
): Promise<SessionRecord> {
let current = this.sessions.get(conversationId)
if (
current?.binding.activePromptOperationId &&
this.options.modelProfile !== undefined &&
!current.ownedPromptAttached
) {
// A failed start has no confirmed attachment. Reopen through attach using
// the persisted operation, without advancing its sequence or closing its channel.
this.sessions.delete(conversationId)
current = undefined
}
if (
current?.binding.activePromptOperationId &&
this.options.modelProfile === undefined
@@ -2153,8 +2166,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
if (update._meta?.goodbuddyChecklist !== undefined) {
const checklist = runtimeChecklistSchema.safeParse(update._meta.goodbuddyChecklist)
if (this.options.runtimeId !== 'continue' || !prompt.open || !checklist.success || checklist.data.source !== 'continue') return undefined
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
}
const todoEvent = update._meta?.goodbuddyTodoEvent
if (todoEvent !== undefined) {
if (this.options.runtimeId !== 'opencode' || !prompt.open ||
!todoEvent || typeof todoEvent !== 'object') return undefined
const native = todoEvent as { type?: unknown; properties?: { sessionID?: unknown; todos?: unknown } }
if (native.type !== 'todo.updated' || native.properties?.sessionID !== prompt.sessionId) return undefined
const checklist = runtimeChecklistSchema.safeParse({ source: 'opencode', items: native.properties.todos })
if (!checklist.success) return undefined
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
}
const extension = update._meta?.goodbuddyQuestion
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
if ((this.options.runtimeId === 'opencode' || this.options.runtimeId === 'continue') && extension && typeof extension === 'object' &&
prompt.open && prompt.context.channel.respondToQuestion) {
const value = extension as { question?: unknown; childCallId?: unknown }
const question = remoteQuestionSchema.parse(value.question)
@@ -2348,6 +2376,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
}
}
if (update.sessionUpdate === 'plan') {
if (this.options.runtimeId === 'opencode') {
if (!Array.isArray(update.entries) || update.entries.some(entry => !entry || typeof entry !== 'object')) return undefined
const checklist = runtimeChecklistSchema.safeParse({
source: 'opencode',
items: update.entries.map(({ content, status, priority }) => ({ content, status, priority }))
})
if (!checklist.success) return undefined
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
}
return {
requestId: prompt.requestId,
type: 'status',
@@ -117,6 +117,44 @@ afterEach(async () => {
})
describe('ContinueHostAdapter', () => {
it('commits complete raw checklists once on success, retaining candidates and excluding old history', async () => {
const distribution = await createDistribution()
const longContent = 'task'.repeat(1500)
const checklist = { source: 'continue' as const, items: [{ content: longContent, status: 'pending' as const }] }
const historyTool = (id: string, text: string, status = 'done') => ({
toolCallId: id, toolCall: { function: { name: 'Checklist', arguments: JSON.stringify({ checklist: text }) } }, status
})
const old = { toolCallStates: [historyTool('old', '- [ ] old')] }
let polls = 0
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
if (!String(input).endsWith('/state')) return Response.json({})
polls++
return Response.json({
session: { history: polls === 1 ? [old] : [old, {
message: { role: 'assistant', content: 'OK' },
toolCallStates: polls < 3 ? [] : [historyTool('long', `- [ ] ${longContent}`), historyTool('failed', '- [x] bad', 'failed'), ...(polls >= 4 ? [historyTool('empty', '')] : [])]
}] },
isProcessing: polls < 5, messageQueueLength: 0, pendingPermission: null,
goodbuddyEvents: polls === 2 ? [
{ type: 'tool', callId: 'long', name: 'Checklist', state: 'running', input: 'truncated', runtimeChecklist: checklist },
{ type: 'tool', callId: 'failed', name: 'Checklist', state: 'running', runtimeChecklist: checklist }
] : polls === 3 ? [
{ type: 'tool', callId: 'long', name: 'Checklist', state: 'completed' },
{ type: 'tool', callId: 'failed', name: 'Checklist', state: 'failed' }
] : []
})
}))
const events: unknown[] = []
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath, configPath: 'test.yaml', workspace: process.cwd(), cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({ exitCode: null, killed: false, stderr: null, once: () => undefined, kill: () => true })
})
try {
await adapter.run('test', new AbortController().signal, async () => 'once', { onEvent: event => { if (event.type === 'checklist') events.push(event) } })
expect(events).toEqual([{ type: 'checklist', checklist }, { type: 'checklist', checklist: { source: 'continue', items: [] } }])
} finally { await adapter.dispose() }
})
it('creates a versioned authenticated loopback host copy', async () => {
const distribution = await createDistribution()
const adapter = new ContinueHostAdapter({
+15 -1
View File
@@ -1,4 +1,5 @@
import spawn from 'cross-spawn'
import { parseContinueChecklist, runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto'
import {
@@ -113,6 +114,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(),
runtimeChecklist: runtimeChecklistSchema.optional(),
summary: z.string().max(240).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional()
@@ -179,6 +181,7 @@ export type ContinueHostUsage = {
}
export type ContinueHostTool = {
runtimeChecklist?: RuntimeChecklist
callId: string
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
@@ -197,6 +200,7 @@ export type ContinueHostRunResult = {
}
export type ContinueHostStreamEvent =
| { type: 'checklist'; checklist: RuntimeChecklist }
| { type: 'text'; delta: string }
| { type: 'tool'; tool: ContinueHostTool }
| {
@@ -729,6 +733,7 @@ function extractContinueTools(
? boundedToolDetail(state.output, 16_000)
: undefined
tools.set(callId, {
runtimeChecklist: parseContinueChecklist(name, (toolFunction as Record<string, unknown>).arguments),
callId,
name: name.trim().slice(0, 200),
state: normalizedState,
@@ -756,6 +761,7 @@ function mergeContinueTools(
...previous,
...tool,
input: tool.input ?? previous?.input,
runtimeChecklist: tool.runtimeChecklist ?? previous?.runtimeChecklist,
summary: tool.summary ?? previous?.summary,
output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error
@@ -987,7 +993,7 @@ export class ContinueHostAdapter {
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
`state:"running",runtimeChecklist:(${parseContinueChecklist.toString()})(u,l),summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly(
patched,
@@ -1600,6 +1606,7 @@ export class ContinueHostAdapter {
let streamTruncated = false
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
const submittedChecklists = new Set<string>()
try {
const initialState = await this.waitForStartup(
child,
@@ -1692,6 +1699,7 @@ export class ContinueHostAdapter {
continue
}
const tool: ContinueHostTool = {
runtimeChecklist: event.runtimeChecklist,
callId: event.callId,
name: event.name,
state: event.state,
@@ -1712,6 +1720,12 @@ export class ContinueHostAdapter {
tool: observedTools.find((item) => item.callId === tool.callId)!
})
}
for (const tool of observedTools) {
if (tool.name === 'Checklist' && tool.state === 'completed' && tool.runtimeChecklist && !submittedChecklists.has(tool.callId)) {
submittedChecklists.add(tool.callId)
await runOptions.onEvent?.({ type: 'checklist', checklist: tool.runtimeChecklist })
}
}
const pendingQuestion = state.goodbuddyQuestion
if (
pendingQuestion &&
+16
View File
@@ -492,6 +492,22 @@ describe('ContinueAgentRuntime', () => {
} finally { await runtime.dispose() }
})
it('forwards committed checklist replacements with the request identity including clears', async () => {
mocks.runHost.mockImplementation(async (_prompt, _signal, _authorize, options) => {
options.onEvent({ type: 'checklist', checklist: { source: 'continue', items: [{ content: 'task', status: 'pending' }] } })
options.onEvent({ type: 'checklist', checklist: { source: 'continue', items: [] } })
return { text: 'OK' }
})
const runtime = createRuntime()
try {
const events = await collectEvents(runtime, 'execute')
expect(events.filter(event => event.type === 'checklist')).toEqual([
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'continue', items: [{ content: 'task', status: 'pending' }] } },
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'continue', items: [] } }
])
} finally { await runtime.dispose() }
})
it('adds assigned Skill instructions to the Continue prompt', async () => {
let hostOptions: ContinueHostAdapterOptions | undefined
const runtime = new ContinueAgentRuntime({
+2
View File
@@ -571,6 +571,8 @@ export class ContinueAgentRuntime implements AgentRuntime {
type: 'text',
delta: event.delta
}
} else if (event.type === 'checklist') {
yield { type: 'checklist', requestId: request.requestId, checklist: event.checklist }
} else if (event.type === 'tool') {
yield toContinueToolEvent(
request.requestId,
+40
View File
@@ -288,6 +288,45 @@ async function collectRun(
}
describe("OpenCodeRuntime embedded launcher", () => {
it('attributes todos to the current parent message and running call in reused sessions', async () => {
const setup = runClient([]);
let previousMessage = 'old-message';
const items = [{ content: 'task', status: 'pending', priority: 'low' }];
vi.mocked(setup.event.subscribe).mockImplementation(async () => ({
stream: (async function* () {
const current = vi.mocked(setup.session.promptAsync).mock.calls.at(-1)![0]!.messageID!;
const update = (parentID: string) => ({ type: 'message.updated', properties: { sessionID: 'session-1', info: { id: parentID + '-assistant', sessionID: 'session-1', role: 'assistant', parentID, time: { created: Date.now() } } } });
const tool = (parentID: string, todos: unknown[], status = 'running', callID = 'call') => ({ type: 'message.part.updated', properties: { sessionID: 'session-1', part: { id: 'part', callID, sessionID: 'session-1', messageID: parentID + '-assistant', type: 'tool', tool: 'todowrite', state: { status, input: { todos } } } } });
const todo = (sessionID: string, todos: unknown[]) => ({ type: 'todo.updated', properties: { sessionID, todos } });
yield update(previousMessage);
yield tool(previousMessage, items);
yield todo('session-1', items);
yield update(current);
yield tool(current, [], 'pending');
yield todo('session-1', items);
yield tool(current, items);
yield todo('child-session', items);
yield todo('session-1', [{ content: 'late old task', status: 'pending' }]);
yield todo('session-1', items);
yield completedToolEvent('call', 'todowrite');
yield tool(current, [], 'running', 'clear');
yield todo('session-1', []);
yield completedToolEvent('clear', 'todowrite');
previousMessage = current;
yield { type: 'session.idle', properties: { sessionID: 'session-1' } };
})()
}) as never);
const runtime = embeddedRuntime(setup.client);
try {
for (let round = 0; round < 2; round++) {
const events = await collectRun(runtime);
expect(events.filter(event => event.type === 'checklist')).toEqual([
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items } },
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items: [] } }
]);
}
} finally { await runtime.dispose(); }
});
it.each(["native", "profile"] as const)(
"disables unused automatic Git snapshots with %s model configuration",
async (configuration) => {
@@ -4831,6 +4870,7 @@ describe("OpenCodeRuntime native customization", () => {
command: "review",
arguments: "--staged",
agent: "build",
messageID: expect.stringMatching(/^msg_/u),
},
expect.objectContaining({
signal: expect.any(AbortSignal),
+47
View File
@@ -1,3 +1,4 @@
import { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist';
import {
createOpencodeClient,
type AssistantMessage,
@@ -2445,6 +2446,13 @@ export class OpenCodeRuntime implements AgentRuntime {
};
};
let hasResponseTextAfterFailure = false;
// Match OC's sortable 48-bit timestamp prefix so reused-session history stays ordered.
const messageTime = ((BigInt(Date.now()) << 12n) & 0xffffffffffffn).toString(16).padStart(12, '0');
const promptMessageId = `msg_${messageTime}${crypto.randomUUID().replaceAll('-', '').slice(0, 14)}`;
const checklistMessageIds = new Set<string>();
const checklistCalls = new Map<string, unknown>();
const submittedChecklistCalls = new Set<string>();
const pendingChecklistUpdates: RuntimeChecklist[] = [];
try {
const promptText = promptWithUntrustedConversationHistory(
request,
@@ -2462,6 +2470,7 @@ export class OpenCodeRuntime implements AgentRuntime {
sessionID: sessionId,
directory,
command: selectedCommand.name,
messageID: promptMessageId,
arguments: selectedCommand.arguments,
...(selectedAgent ? { agent: selectedAgent } : {}),
...(this.options.modelProfile
@@ -2482,6 +2491,7 @@ export class OpenCodeRuntime implements AgentRuntime {
{
sessionID: sessionId,
directory,
messageID: promptMessageId,
model: this.options.modelProfile
? {
providerID: resolveOpenCodeProvider(
@@ -2538,6 +2548,43 @@ export class OpenCodeRuntime implements AgentRuntime {
subscription.stream,
subscriptionController,
)) {
if (signal.aborted) break;
if (event.type === 'message.updated' &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === 'assistant' &&
event.properties.info.parentID === promptMessageId) {
checklistMessageIds.add(event.properties.info.id);
}
if (event.type === 'message.part.updated') {
const part = event.properties.part;
if (part.sessionID === sessionId && part.type === 'tool' &&
part.tool === 'todowrite' && checklistMessageIds.has(part.messageID)) {
if (part.state.status === 'pending' && !submittedChecklistCalls.has(part.callID)) {
checklistCalls.set(part.callID, undefined);
} else if (part.state.status === 'running' && !submittedChecklistCalls.has(part.callID)) {
checklistCalls.set(part.callID, part.state.input.todos);
} else {
checklistCalls.delete(part.callID);
}
}
}
if (event.type === 'todo.updated' && event.properties.sessionID === sessionId) {
const parsed = runtimeChecklistSchema.safeParse({ source: 'opencode', items: event.properties.todos });
if (parsed.success && checklistCalls.size > 0) pendingChecklistUpdates.push(parsed.data);
}
// OC 1.18.29 publishes todo.updated before the tool's running input frame.
for (const [callId, items] of checklistCalls) {
const candidate = runtimeChecklistSchema.safeParse({ source: 'opencode', items });
if (!candidate.success) continue;
const index = pendingChecklistUpdates.findIndex(update => JSON.stringify(update) === JSON.stringify(candidate.data));
if (index >= 0) {
const checklist = pendingChecklistUpdates.splice(index, 1)[0]!;
submittedChecklistCalls.add(callId);
checklistCalls.delete(callId);
yield { type: 'checklist', requestId: request.requestId, checklist };
}
}
if (checklistCalls.size === 0) pendingChecklistUpdates.length = 0;
const childProgress = subagentProgress.update(event);
if (
childProgress &&