diff --git a/apps/worker/src/sandbox-server/lib/harness.ts b/apps/worker/src/sandbox-server/lib/harness.ts index 7c811bd37a..93b4b9ad00 100644 --- a/apps/worker/src/sandbox-server/lib/harness.ts +++ b/apps/worker/src/sandbox-server/lib/harness.ts @@ -78,6 +78,12 @@ export interface QueuedPromptMessageSnapshot { userName?: string; userImageUrl?: string; clientMessageId?: string; + /** + * Set on prompts the harness queues on its own (recovery and continuation + * nudges), so a reader of the transcript can tell them from a request a + * person or the platform sent. + */ + source?: string; timestamp: number; } 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 f940035c7a..6b298b7c57 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 @@ -4114,6 +4114,21 @@ describe('OpenCodeServerHarness', () => { .filter((text): text is string => typeof text === 'string') .join('\n'); expect(secondPromptText).toContain('temporary provider rate limit'); + // The hidden continue prompt is persisted marked as the harness's own, + // so transcript readers never mistake it for a request from a person. + expect( + persistedEnvelopes.find( + (envelope) => + envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt && + String(envelope.payload.text ?? '').includes( + 'temporary provider rate limit', + ), + ), + ).toMatchObject({ + visibleInTranscript: false, + metadata: { source: 'opencode-rate-limit-retry' }, + payload: { source: 'opencode-rate-limit-retry' }, + }); expect( taskEvents.some( (event) => event.eventName === TaskEventName.TaskAborted, 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 35a3ae7330..4bc8c47216 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 @@ -84,6 +84,25 @@ describe('RuntimePromptQueue', () => { }); }); + describe('restore', () => { + it('keeps the harness source of a hidden continuation across a restore', () => { + const original = createQueue(); + original.queue.enqueue({ + text: 'Continue where you left off after the temporary provider rate limit.', + visibleInTranscript: false, + source: 'opencode-rate-limit-retry', + }); + + const restored = createQueue(); + restored.queue.restore(original.queue.snapshot()); + + expect(restored.queue.dequeue()).toMatchObject({ + visibleInTranscript: false, + source: 'opencode-rate-limit-retry', + }); + }); + }); + describe('clear', () => { it('removes all 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 ee5178bf3e..a7e472a388 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 @@ -3900,6 +3900,7 @@ export class OpenCodeServerHarness const queuedId = this.prompts.enqueue({ text: VISUAL_PROOF_TIMEOUT_RECOVERY_PROMPT, visibleInTranscript: false, + source: 'opencode-visual-proof-recovery', }); this.prompts.prioritize(queuedId); @@ -3936,6 +3937,7 @@ export class OpenCodeServerHarness this.prompts.enqueue({ text: PLAN_EXIT_CONTINUATION_PROMPT, visibleInTranscript: false, + source: 'opencode-plan-exit-continuation', }); } @@ -4521,6 +4523,7 @@ export class OpenCodeServerHarness const queuedId = this.prompts.enqueue({ text: recovery.promptText, visibleInTranscript: false, + source: 'opencode-provider-error-recovery', }); this.prompts.prioritize(queuedId); this.providerErrorRecoveryQueuedPromptId = queuedId; @@ -4582,6 +4585,7 @@ export class OpenCodeServerHarness const queuedId = this.prompts.enqueue({ text: OPENCODE_RATE_LIMIT_RETRY_PROMPT_TEXT, visibleInTranscript: false, + source: 'opencode-rate-limit-retry', }); this.prompts.prioritize(queuedId); @@ -6041,6 +6045,7 @@ export class OpenCodeServerHarness userName: next.userName, userImageUrl: next.userImageUrl, clientMessageId: next.clientMessageId, + source: next.source, }); await this.submitPrompt({ text: next.text, @@ -6051,6 +6056,7 @@ export class OpenCodeServerHarness userName: next.userName, userImageUrl: next.userImageUrl, clientMessageId: next.clientMessageId, + source: next.source, }); } 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 08d2c2cf2b..eae2d4ce2f 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 @@ -50,6 +50,7 @@ export class RuntimePromptQueue { userName?: string; userImageUrl?: string; clientMessageId?: string; + source?: string; }): string { // Hidden platform follow-ups reuse one clientMessageId per logical // notification (e.g. the PR re-review prompt for a run), so a newer @@ -75,6 +76,7 @@ export class RuntimePromptQueue { userName: prompt.userName, userImageUrl: prompt.userImageUrl, clientMessageId: prompt.clientMessageId, + source: prompt.source, timestamp: Date.now(), }; @@ -247,6 +249,7 @@ export class RuntimePromptQueue { ...(message.clientMessageId ? { clientMessageId: message.clientMessageId } : {}), + ...(message.source ? { source: message.source } : {}), timestamp: message.timestamp, })); this.queuedMessageIdCounter = this.queuedMessages.reduce( diff --git a/packages/cloud-agents/src/server/__tests__/task-completion-gate.test.ts b/packages/cloud-agents/src/server/__tests__/task-completion-gate.test.ts index edd27016fd..43d12847d0 100644 --- a/packages/cloud-agents/src/server/__tests__/task-completion-gate.test.ts +++ b/packages/cloud-agents/src/server/__tests__/task-completion-gate.test.ts @@ -25,10 +25,17 @@ vi.mock('../typesafe-judgment', () => ({ import { evaluateTaskCompletionGate } from '../task-completion-gate'; -const prompt = (text: string) => ({ +const prompt = ( + text: string, + options: { source?: string; hidden?: boolean } = {}, +) => ({ id: text, contentBlocks: [{ type: 'text', text }], - payload: null, + payload: options.source ? { source: options.source } : null, + metadata: { + ...(options.source ? { source: options.source } : {}), + ...(options.hidden || options.source ? { visibleInTranscript: false } : {}), + }, }); /** @@ -36,10 +43,12 @@ const prompt = (text: string) => ({ * then reads the latest plan. */ function mockTranscript( - prompts: string[], + prompts: Array>, options: { plan?: string; scanLimit?: number } = {}, ): void { - const rows = prompts.map(prompt); + const rows = prompts.map((entry) => + typeof entry === 'string' ? prompt(entry) : entry, + ); const scanLimit = options.scanLimit ?? 12; mockPromptRows .mockReset() @@ -179,6 +188,60 @@ describe('evaluateTaskCompletionGate', () => { }); }); + it('reads a request that arrived as a hidden prompt, as a delegated task gets it', async () => { + mockTranscript([ + prompt( + 'Standard task workflow.\nRemove the duplicate-call guard.', + { hidden: true }, + ), + ]); + + await evaluateTaskCompletionGate({ taskId: 'task-1', check }); + + expect(mockEvaluateDecisionModel).toHaveBeenCalledTimes(1); + expect(mockEvaluateDecisionModel.mock.calls[0]![0].state.request).toContain( + 'Remove the duplicate-call guard.', + ); + }); + + it('reads only visible follow-ups, never notices or reminders', async () => { + mockTranscript([ + 'Remove the duplicate-call guard.', + prompt( + 'Roomote automatically compared what was asked, your closing report, and everything this task changed, and flagged the following: ...', + { source: 'opencode-completion-gate' }, + ), + prompt('Before finalizing, post a terminal chat-visible reply.', { + source: 'opencode-stop-hook', + }), + // Queued by the harness itself after a provider rate limit. + prompt( + 'Continue where you left off after the temporary provider rate limit.', + { source: 'opencode-rate-limit-retry' }, + ), + // A platform notice, hidden, with its own source. + prompt('The environment setup finished; the snapshot is ready.', { + source: 'environment-setup', + }), + // A hidden follow-up with no source at all still never counts. + prompt( + 'The visual proof step exceeded its shared five-minute deadline.', + { + hidden: true, + }, + ), + // A person's follow-up is visible and does count. + 'Also drop the helper.', + ]); + + await evaluateTaskCompletionGate({ taskId: 'task-1', check }); + + expect(mockEvaluateDecisionModel.mock.calls[0]![0].state).toMatchObject({ + request: 'Remove the duplicate-call guard.', + follow_ups: 'Also drop the helper.', + }); + }); + it('does not repeat a lone opening prompt as its own follow-up', async () => { mockTranscript(['Remove the duplicate-call guard.']); diff --git a/packages/cloud-agents/src/server/task-completion-gate.ts b/packages/cloud-agents/src/server/task-completion-gate.ts index 8705fae730..56d10c4900 100644 --- a/packages/cloud-agents/src/server/task-completion-gate.ts +++ b/packages/cloud-agents/src/server/task-completion-gate.ts @@ -1,5 +1,5 @@ import { redactBrainText } from '@roomote/communication/redact-brain-text'; -import { and, asc, db, desc, eq, sql, taskMessages } from '@roomote/db/server'; +import { and, asc, db, desc, eq, taskMessages } from '@roomote/db/server'; import { ACP_ENVELOPE_EVENT_TYPES, extractAcpMessageText, @@ -144,7 +144,42 @@ function clip(text: string, maxChars: number): string { * What the person asked this task for: the opening prompt and the latest * follow-ups. Read from the transcript rather than taken from the sandbox so * the agent cannot restate its own request. + * + * The opening prompt counts whether or not it is visible: a task delegated + * from a Session gets its request as a hidden `` prompt. Later + * hidden prompts never count. Those are the platform's and the harness's own + * notices (an environment-setup notice, a recovery or continuation nudge, a + * completion-check reminder), and reading one back as a follow-up would have + * the check judging its own instructions. A person's follow-up, from the web + * or a chat thread, is always visible. */ +const HARNESS_PROMPT_SOURCE_PREFIX = 'opencode-'; + +type PromptRow = { + id: string; + contentBlocks: unknown; + payload: unknown; + metadata: unknown; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' + ? (value as Record) + : null; +} + +function isHiddenPrompt(row: PromptRow): boolean { + return asRecord(row.metadata)?.visibleInTranscript === false; +} + +function isHarnessPrompt(row: PromptRow): boolean { + return [asRecord(row.payload)?.source, asRecord(row.metadata)?.source].some( + (source) => + typeof source === 'string' && + source.startsWith(HARNESS_PROMPT_SOURCE_PREFIX), + ); +} + async function loadTaskRequests( taskId: string, ): Promise<{ request: string; followUps: string } | null> { @@ -156,24 +191,34 @@ async function loadTaskRequests( id: taskMessages.id, contentBlocks: taskMessages.contentBlocks, payload: taskMessages.payload, + metadata: taskMessages.metadata, }) .from(taskMessages) .where( and( eq(taskMessages.taskId, taskId), eq(taskMessages.eventType, ACP_ENVELOPE_EVENT_TYPES.UserPrompt), - sql`coalesce(${taskMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, ), ) .orderBy(direction(taskMessages.ts), direction(taskMessages.createdAt)) .limit(PROMPT_SCAN_LIMIT); - const visiblePrompts = (rows: Awaited>) => + const requestPrompts = ( + rows: PromptRow[], + options: { includeHidden: boolean }, + ) => rows.flatMap((row) => { - const payload = - row.payload && typeof row.payload === 'object' - ? (row.payload as Record) - : null; - const raw = extractAcpMessageText(row.contentBlocks, payload)?.trim(); + if ( + isHarnessPrompt(row) || + (!options.includeHidden && isHiddenPrompt(row)) + ) { + return []; + } + + const payload = asRecord(row.payload); + const raw = extractAcpMessageText( + row.contentBlocks as Parameters[0], + payload, + )?.trim(); const text = raw ? normalizeTranscriptUserText( isSystemInjectedAcpPromptText(raw) @@ -186,13 +231,13 @@ async function loadTaskRequests( return text ? [{ id: row.id, text }] : []; }); - const [opening] = visiblePrompts(await scan(asc)); + const [opening] = requestPrompts(await scan(asc), { includeHidden: true }); if (!opening) { return null; } - const followUps = visiblePrompts(await scan(desc)) + const followUps = requestPrompts(await scan(desc), { includeHidden: false }) .filter((prompt) => prompt.id !== opening.id) .slice(0, FOLLOW_UP_LIMIT) .reverse();