From c6585e267fa8e650f0efa826b862b9d4a6544004 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:16:45 -0400 Subject: [PATCH 1/5] [Fix] The completion check skips every task delegated from a Session --- .../server/__tests__/task-completion-gate.test.ts | 13 +++++++++++++ .../cloud-agents/src/server/task-completion-gate.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) 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..d592e4e4a2 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 @@ -179,6 +179,19 @@ describe('evaluateTaskCompletionGate', () => { }); }); + it('reads a request that arrived as a hidden prompt, as a delegated task gets it', async () => { + mockTranscript([ + 'Remove the duplicate-call guard.\ntrue', + ]); + + 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('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..c2ba160106 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,6 +144,11 @@ 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. + * + * Hidden prompts count. A task delegated from a Session gets its request as + * a hidden `` prompt, and the harness's own reminders are never + * persisted as prompts, so every stored prompt is a request from a person or + * the Session acting for one. */ async function loadTaskRequests( taskId: string, @@ -162,7 +167,6 @@ async function loadTaskRequests( 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)) From b201d6eee04154720d1addbce56fc513c7dbda72 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:41:36 -0400 Subject: [PATCH 2/5] Keep the harness's own reminder prompts out of the request the check reads --- .../__tests__/task-completion-gate.test.ts | 32 +++++++++++++++--- .../src/server/task-completion-gate.ts | 33 ++++++++++++++++--- 2 files changed, 57 insertions(+), 8 deletions(-) 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 d592e4e4a2..02c9718371 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,11 @@ vi.mock('../typesafe-judgment', () => ({ import { evaluateTaskCompletionGate } from '../task-completion-gate'; -const prompt = (text: string) => ({ +const prompt = (text: string, source?: string) => ({ id: text, contentBlocks: [{ type: 'text', text }], - payload: null, + payload: source ? { source } : null, + metadata: source ? { source, visibleInTranscript: false } : null, }); /** @@ -36,10 +37,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() @@ -192,6 +195,27 @@ describe('evaluateTaskCompletionGate', () => { ); }); + it("never reads the harness's own reminders back as requests", 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: ...', + 'opencode-completion-gate', + ), + prompt( + 'Before finalizing, post a terminal chat-visible reply.', + 'opencode-stop-hook', + ), + ]); + + await evaluateTaskCompletionGate({ taskId: 'task-1', check }); + + expect(mockEvaluateDecisionModel.mock.calls[0]![0].state).toMatchObject({ + request: 'Remove the duplicate-call guard.', + follow_ups: '', + }); + }); + 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 c2ba160106..f7c966ddaf 100644 --- a/packages/cloud-agents/src/server/task-completion-gate.ts +++ b/packages/cloud-agents/src/server/task-completion-gate.ts @@ -145,11 +145,30 @@ function clip(text: string, maxChars: number): string { * follow-ups. Read from the transcript rather than taken from the sandbox so * the agent cannot restate its own request. * - * Hidden prompts count. A task delegated from a Session gets its request as - * a hidden `` prompt, and the harness's own reminders are never - * persisted as prompts, so every stored prompt is a request from a person or - * the Session acting for one. + * Hidden prompts count: a task delegated from a Session gets its request as + * a hidden `` prompt. The harness's own reminders (a completion + * check follow-up, a chat closeout nudge) are not persisted as prompts today; + * should that change, their `source` keeps them out, so the check never + * reads its own instructions back as a request. */ +const HARNESS_PROMPT_SOURCE_PREFIX = 'opencode-'; + +function isHarnessPrompt( + payload: Record | null, + metadata: unknown, +): boolean { + const meta = + metadata && typeof metadata === 'object' + ? (metadata as Record) + : null; + + return [payload?.source, meta?.source].some( + (source) => + typeof source === 'string' && + source.startsWith(HARNESS_PROMPT_SOURCE_PREFIX), + ); +} + async function loadTaskRequests( taskId: string, ): Promise<{ request: string; followUps: string } | null> { @@ -161,6 +180,7 @@ async function loadTaskRequests( id: taskMessages.id, contentBlocks: taskMessages.contentBlocks, payload: taskMessages.payload, + metadata: taskMessages.metadata, }) .from(taskMessages) .where( @@ -177,6 +197,11 @@ async function loadTaskRequests( row.payload && typeof row.payload === 'object' ? (row.payload as Record) : null; + + if (isHarnessPrompt(payload, row.metadata)) { + return []; + } + const raw = extractAcpMessageText(row.contentBlocks, payload)?.trim(); const text = raw ? normalizeTranscriptUserText( From eb3de9b9d6d7e696b11acd8e1cad967e361eded2 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:54:47 -0400 Subject: [PATCH 3/5] Mark the continuations the harness queues on its own so transcript readers can tell them from requests --- apps/worker/src/sandbox-server/lib/harness.ts | 6 ++++++ .../harnesses/__tests__/opencode-server.test.ts | 15 +++++++++++++++ .../lib/harnesses/opencode-server/harness.ts | 6 ++++++ .../lib/harnesses/runtime-prompt-queue.ts | 2 ++ .../server/__tests__/task-completion-gate.test.ts | 5 +++++ 5 files changed, 34 insertions(+) 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/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..c66b42cc0f 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(), }; 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 02c9718371..849bc74de6 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 @@ -206,6 +206,11 @@ describe('evaluateTaskCompletionGate', () => { 'Before finalizing, post a terminal chat-visible reply.', '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.', + 'opencode-rate-limit-retry', + ), ]); await evaluateTaskCompletionGate({ taskId: 'task-1', check }); From 81530030071fd86ccbec69642f0ed54a6fd9d0c1 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:00:22 -0400 Subject: [PATCH 4/5] Keep a queued prompt's source across a queue restore --- .../__tests__/runtime-prompt-queue.test.ts | 19 +++++++++++++++++++ .../lib/harnesses/runtime-prompt-queue.ts | 1 + 2 files changed, 20 insertions(+) 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/runtime-prompt-queue.ts b/apps/worker/src/sandbox-server/lib/harnesses/runtime-prompt-queue.ts index c66b42cc0f..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 @@ -249,6 +249,7 @@ export class RuntimePromptQueue { ...(message.clientMessageId ? { clientMessageId: message.clientMessageId } : {}), + ...(message.source ? { source: message.source } : {}), timestamp: message.timestamp, })); this.queuedMessageIdCounter = this.queuedMessages.reduce( From 293dae156294e9d42cfdd25476505fce7bf699f4 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:06:45 -0400 Subject: [PATCH 5/5] Count a hidden prompt only as the opening request, never as a follow-up --- .../__tests__/task-completion-gate.test.ts | 45 +++++++++---- .../src/server/task-completion-gate.ts | 64 ++++++++++++------- 2 files changed, 73 insertions(+), 36 deletions(-) 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 849bc74de6..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,11 +25,17 @@ vi.mock('../typesafe-judgment', () => ({ import { evaluateTaskCompletionGate } from '../task-completion-gate'; -const prompt = (text: string, source?: string) => ({ +const prompt = ( + text: string, + options: { source?: string; hidden?: boolean } = {}, +) => ({ id: text, contentBlocks: [{ type: 'text', text }], - payload: source ? { source } : null, - metadata: source ? { source, visibleInTranscript: false } : null, + payload: options.source ? { source: options.source } : null, + metadata: { + ...(options.source ? { source: options.source } : {}), + ...(options.hidden || options.source ? { visibleInTranscript: false } : {}), + }, }); /** @@ -184,7 +190,10 @@ describe('evaluateTaskCompletionGate', () => { it('reads a request that arrived as a hidden prompt, as a delegated task gets it', async () => { mockTranscript([ - 'Remove the duplicate-call guard.\ntrue', + prompt( + 'Standard task workflow.\nRemove the duplicate-call guard.', + { hidden: true }, + ), ]); await evaluateTaskCompletionGate({ taskId: 'task-1', check }); @@ -195,29 +204,41 @@ describe('evaluateTaskCompletionGate', () => { ); }); - it("never reads the harness's own reminders back as requests", async () => { + 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: ...', - 'opencode-completion-gate', - ), - prompt( - 'Before finalizing, post a terminal chat-visible reply.', - 'opencode-stop-hook', + { 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.', - 'opencode-rate-limit-retry', + { 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: '', + follow_ups: 'Also drop the helper.', }); }); diff --git a/packages/cloud-agents/src/server/task-completion-gate.ts b/packages/cloud-agents/src/server/task-completion-gate.ts index f7c966ddaf..56d10c4900 100644 --- a/packages/cloud-agents/src/server/task-completion-gate.ts +++ b/packages/cloud-agents/src/server/task-completion-gate.ts @@ -145,24 +145,35 @@ function clip(text: string, maxChars: number): string { * follow-ups. Read from the transcript rather than taken from the sandbox so * the agent cannot restate its own request. * - * Hidden prompts count: a task delegated from a Session gets its request as - * a hidden `` prompt. The harness's own reminders (a completion - * check follow-up, a chat closeout nudge) are not persisted as prompts today; - * should that change, their `source` keeps them out, so the check never - * reads its own instructions back as a 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-'; -function isHarnessPrompt( - payload: Record | null, - metadata: unknown, -): boolean { - const meta = - metadata && typeof metadata === 'object' - ? (metadata as Record) - : null; +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; +} - return [payload?.source, meta?.source].some( +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), @@ -191,18 +202,23 @@ async function loadTaskRequests( ) .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; - - if (isHarnessPrompt(payload, row.metadata)) { + if ( + isHarnessPrompt(row) || + (!options.includeHidden && isHiddenPrompt(row)) + ) { return []; } - const raw = extractAcpMessageText(row.contentBlocks, payload)?.trim(); + const payload = asRecord(row.payload); + const raw = extractAcpMessageText( + row.contentBlocks as Parameters[0], + payload, + )?.trim(); const text = raw ? normalizeTranscriptUserText( isSystemInjectedAcpPromptText(raw) @@ -215,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();