diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-sandbox-store.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-sandbox-store.client.test.ts index 414bc1081..c1f33a887 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-sandbox-store.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-sandbox-store.client.test.ts @@ -484,6 +484,69 @@ describe('createSandboxStore', () => { ]); }); + it('reconstructs persisted queued messages separately from delivered prompts', () => { + const store = createAcpStore(); + const queuedUpdate = acpQueuedMessagesUpdate( + [ + { + id: 'queued-1', + text: 'queued follow-up', + clientMessageId: 'client-message-1', + timestamp: 5001, + }, + ], + { + cause: 'enqueue', + sessionId: 'session-queue-history', + sequence: 1, + ts: 5001, + }, + ); + + loadAcpHistory(store, acpEnvelope(queuedUpdate)); + + expect(store.getState().queuedMessages).toEqual([ + { + id: 'queued-1', + text: 'queued follow-up', + clientMessageId: 'client-message-1', + timestamp: 5001, + }, + ]); + expect(store.getState().messages).toEqual([]); + + const dequeuedUpdate = acpQueuedMessagesUpdate([], { + cause: 'dequeue', + sessionId: 'session-queue-history', + sequence: 2, + ts: 5002, + }); + const deliveredPrompt = acpUserPrompt('queued follow-up', { + id: 'persisted:client-message-1', + sessionId: 'session-queue-history', + sequence: 3, + ts: 5003, + text: 'queued follow-up', + clientMessageId: 'client-message-1', + }); + + loadAcpHistory( + store, + acpEnvelope(queuedUpdate), + acpEnvelope(dequeuedUpdate), + acpEnvelope(deliveredPrompt), + ); + + expect(store.getState().queuedMessages).toEqual([]); + expect(store.getState().messages).toMatchObject([ + expect.objectContaining({ + id: 'persisted:client-message-1', + text: 'queued follow-up', + clientMessageId: 'client-message-1', + }), + ]); + }); + it.each(['delete', 'clear'] as const)( 'drops acknowledged optimistic queued messages when the runtime queue is %s', (cause) => { diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts index b451ab22d..ce356a6c1 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts @@ -2393,6 +2393,182 @@ describe('OpenCodeServerHarness', () => { } }); + it('persists an ordinary follow-up as queued until the next turn begins', async () => { + const { client, harness } = createHarness(); + const persistedEnvelopes: AcpPersistedEnvelope[] = []; + + harness.subscribeRuntimePersistedEnvelope((envelope) => + persistedEnvelopes.push(envelope), + ); + + try { + await connectHarness(harness, client); + + harness.sendCommand({ + commandName: TaskCommandName.StartNewTask, + data: { text: 'Start work.', visibleInTranscript: true }, + }); + await vi.waitFor(() => { + expect(client.promptAsync).toHaveBeenCalledTimes(1); + }); + + harness.sendCommand({ + commandName: TaskCommandName.SendMessage, + data: { + text: 'Handle this after the current turn.', + visibleInTranscript: true, + clientMessageId: 'queued-follow-up', + }, + }); + + expect( + persistedEnvelopes + .filter( + (envelope) => + envelope.eventType === + ACP_ENVELOPE_EVENT_TYPES.QueuedMessagesUpdate, + ) + .at(-1)?.payload, + ).toMatchObject({ + cause: 'enqueue', + queuedMessages: [ + expect.objectContaining({ + text: 'Handle this after the current turn.', + clientMessageId: 'queued-follow-up', + }), + ], + }); + expect( + persistedEnvelopes.filter( + (envelope) => + envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + ), + ).toHaveLength(1); + + client.message.mockResolvedValueOnce(createFinalAssistantMessage()); + await client.emit({ + type: 'message.updated', + properties: { + info: { + id: 'msg_1', + sessionID: 'ses_1', + role: 'assistant', + time: { completed: 1 }, + }, + }, + }); + await client.emit({ + type: 'session.idle', + properties: { sessionID: 'ses_1' }, + }); + + await vi.waitFor(() => { + expect(client.promptAsync).toHaveBeenCalledTimes(2); + }); + expect( + persistedEnvelopes + .filter( + (envelope) => + envelope.eventType === + ACP_ENVELOPE_EVENT_TYPES.QueuedMessagesUpdate, + ) + .at(-1)?.payload, + ).toMatchObject({ cause: 'dequeue', queuedMessages: [] }); + expect( + persistedEnvelopes + .filter( + (envelope) => + envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + ) + .at(-1), + ).toMatchObject({ + contentBlocks: [ + { type: 'text', text: 'Handle this after the current turn.' }, + ], + metadata: { clientMessageId: 'queued-follow-up' }, + }); + } finally { + harness.dispose(); + } + }); + + it('persists a queued follow-up as delivered when explicitly steered', async () => { + const { client, harness } = createHarness(); + const persistedEnvelopes: AcpPersistedEnvelope[] = []; + + harness.subscribeRuntimePersistedEnvelope((envelope) => + persistedEnvelopes.push(envelope), + ); + + try { + await connectHarness(harness, client); + + harness.sendCommand({ + commandName: TaskCommandName.StartNewTask, + data: { text: 'Start work.', visibleInTranscript: true }, + }); + await vi.waitFor(() => { + expect(client.promptAsync).toHaveBeenCalledTimes(1); + }); + + harness.sendCommand({ + commandName: TaskCommandName.SendMessage, + data: { + text: 'Apply this now.', + visibleInTranscript: true, + clientMessageId: 'steered-follow-up', + }, + }); + const queuedMessage = harness.getQueuedMessageSnapshots?.()[0]; + + expect(queuedMessage).toMatchObject({ + text: 'Apply this now.', + clientMessageId: 'steered-follow-up', + }); + expect(queuedMessage?.id).toBeTruthy(); + + harness.sendCommand({ + commandName: TaskCommandName.DeleteQueuedMessage, + data: { id: queuedMessage!.id }, + }); + harness.sendCommand({ + commandName: TaskCommandName.SendMessage, + data: { + text: queuedMessage!.text, + visibleInTranscript: queuedMessage!.visibleInTranscript, + clientMessageId: queuedMessage!.clientMessageId, + autoSteerWhenQueued: true, + }, + }); + + await vi.waitFor(() => { + expect(client.promptAsync).toHaveBeenCalledTimes(2); + }); + expect( + persistedEnvelopes + .filter( + (envelope) => + envelope.eventType === + ACP_ENVELOPE_EVENT_TYPES.QueuedMessagesUpdate, + ) + .at(-1)?.payload, + ).toMatchObject({ cause: 'delete', queuedMessages: [] }); + expect( + persistedEnvelopes + .filter( + (envelope) => + envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + ) + .at(-1), + ).toMatchObject({ + contentBlocks: [{ type: 'text', text: 'Apply this now.' }], + metadata: { clientMessageId: 'steered-follow-up' }, + }); + } finally { + harness.dispose(); + } + }); + it('advertises native turn steering so steerTask avoids terminal cancellation', () => { const { harness } = createHarness(); diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/runtime-prompt-queue.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/runtime-prompt-queue.test.ts index 6550147d6..35a3ae733 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/runtime-prompt-queue.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/runtime-prompt-queue.test.ts @@ -1,15 +1,14 @@ -import type { AcpMessage } from '@roomote/types'; - import { RuntimePromptQueue } from '../runtime-prompt-queue'; +import type { PersistableEnvelope } from '../../runtime-envelope-builder'; function createQueue() { - const emittedEvents: AcpMessage[] = []; + const emittedEvents: PersistableEnvelope[] = []; let sequence = 0; const queue = new RuntimePromptQueue({ getSessionId: () => 'test-session', getNextSequence: () => ++sequence, - emitRuntimeOutput: (event) => emittedEvents.push(event), + emitRuntimeUpdate: (event) => emittedEvents.push(event), }); return { queue, emittedEvents }; @@ -47,6 +46,18 @@ describe('RuntimePromptQueue', () => { expect(a?.id).not.toBe(b?.id); }); + it('uses the monotonic sequence as the durable envelope timestamp', () => { + const { queue, emittedEvents } = createQueue(); + + queue.enqueue({ text: 'first' }); + queue.enqueue({ text: 'second' }); + + expect(emittedEvents.map((event) => event.ts)).toEqual([1, 2]); + expect(emittedEvents.map((event) => event.metadata?.sequence)).toEqual([ + 1, 2, + ]); + }); + it('preserves images in queued messages', () => { const { queue } = createQueue(); diff --git a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts index 6e77a09e1..54ba15e4f 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts @@ -1747,7 +1747,7 @@ export class OpenCodeServerHarness this.prompts = new RuntimePromptQueue({ getSessionId: () => this.sessionId, getNextSequence: () => this.runtimeEvents.nextTs(), - emitRuntimeOutput: (event) => this.emit('runtimeOutput', event), + emitRuntimeUpdate: (event) => this.runtimeEvents.outputAndPersist(event), }); this.stallWatchdogs = new OpenCodeStallWatchdogs({ turnStallTimeoutMs: diff --git a/apps/worker/src/sandbox-server/lib/harnesses/runtime-prompt-queue.ts b/apps/worker/src/sandbox-server/lib/harnesses/runtime-prompt-queue.ts index d03ca31e8..44a88b5a8 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/runtime-prompt-queue.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/runtime-prompt-queue.ts @@ -1,10 +1,7 @@ -import { - type AcpMessage, - ACP_ENVELOPE_EVENT_TYPES, - type TaskGoal, -} from '@roomote/types'; +import { ACP_ENVELOPE_EVENT_TYPES, type TaskGoal } from '@roomote/types'; import type { QueuedPromptMessageSnapshot } from '../harness'; +import type { PersistableEnvelope } from '../runtime-envelope-builder'; import { imageInputToPromptBlock } from './runtime-prompt-utils'; @@ -25,7 +22,7 @@ type RuntimePromptQueueUpdateCause = interface RuntimePromptQueueCallbacks { getSessionId: () => string | undefined; getNextSequence: () => number; - emitRuntimeOutput: (event: AcpMessage) => void; + emitRuntimeUpdate: (event: PersistableEnvelope) => void; } function isHiddenPlatformPrompt(prompt: { @@ -299,12 +296,10 @@ export class RuntimePromptQueue { (message) => !message.queueOnly, ).length; - this.callbacks.emitRuntimeOutput({ - id: `${sessionId}:${sequence}`, - ts: Date.now(), + this.callbacks.emitRuntimeUpdate({ + ts: sequence, eventType: ACP_ENVELOPE_EVENT_TYPES.QueuedMessagesUpdate, role: null, - kind: 'unknown', contentBlocks: [], metadata: { sessionId, sequence }, payload: {