From e8d14ec1e321972fe226449320dba5e0e08de5a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:37:07 -0700 Subject: [PATCH] fix(automations): identify dispatched agent turns --- .../headless-workspace-create.test.ts | 3 +- .../automations/headless-workspace-create.ts | 3 +- .../run-completion-watcher.test.ts | 18 ++- .../automations/run-completion-watcher.ts | 10 +- .../runtime-terminal-run-observer.ts | 24 +++- src/main/index.ts | 3 +- .../automation-run-operations.ts | 8 +- src/main/runtime/orca-runtime.test.ts | 114 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 54 ++++++--- .../hooks/useAutomationDispatchEvents.test.ts | 12 +- .../src/hooks/useAutomationDispatchEvents.ts | 6 +- src/shared/automation-turn-prompt.test.ts | 24 ++++ src/shared/automation-turn-prompt.ts | 20 +++ 13 files changed, 256 insertions(+), 43 deletions(-) create mode 100644 src/shared/automation-turn-prompt.test.ts create mode 100644 src/shared/automation-turn-prompt.ts diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts index 3e25ad87440..4713baa4c3a 100644 --- a/src/main/automations/headless-workspace-create.test.ts +++ b/src/main/automations/headless-workspace-create.test.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import type { Automation } from '../../shared/automations-types' +import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt' import type { Repo } from '../../shared/repo-types' import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create' @@ -72,7 +73,7 @@ describe('headless automation workspace create args', () => { activate: false, createdWithAgent: 'codex', startupAgent: 'codex', - startupPrompt: 'Review changes', + startupPrompt: buildAutomationTurnPrompt('Review changes', 'run-1'), telemetrySource: 'unknown', automationProvenance: { kind: 'created-by-automation', diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts index 2047a105c44..16c71637a3a 100644 --- a/src/main/automations/headless-workspace-create.ts +++ b/src/main/automations/headless-workspace-create.ts @@ -1,5 +1,6 @@ import type { Automation, AutomationRun } from '../../shared/automations-types' import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' +import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt' import type { Repo } from '../../shared/repo-types' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -40,7 +41,7 @@ export function buildHeadlessAutomationWorktreeCreateArgs({ activate: false, createdWithAgent: automation.agentId, startupAgent: automation.agentId, - startupPrompt: automation.prompt, + startupPrompt: buildAutomationTurnPrompt(automation.prompt, run.id), telemetrySource: 'unknown', automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt) } diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 5fd96504cd7..7e64246de29 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -6,6 +6,7 @@ import type { Repo } from '../../shared/repo-types' import type { Automation, AutomationRun } from '../../shared/automations-types' import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' import { normalizePromptField } from '../../shared/agent-status-field-normalization' +import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt' import { AutomationService } from './service' import type { AutomationRunCompletionObservation, @@ -85,15 +86,16 @@ function createObserver( observe: ( signal: AbortSignal, observedAfter: number, - expectedPrompt?: string + expectedPrompt?: string, + expectedTurnId?: string ) => Promise, resolveRunTerminal: (run: AutomationRun) => string | null = (run) => run.terminalPaneKey ? 'handle-1' : null ): AutomationRunTerminalObserver { return { resolveRunTerminal, - observeCompletion: (_handle, { signal, observedAfter, expectedPrompt }) => - observe(signal, observedAfter, expectedPrompt) + observeCompletion: (_handle, { signal, observedAfter, expectedPrompt, expectedTurnId }) => + observe(signal, observedAfter, expectedPrompt, expectedTurnId) } } @@ -280,18 +282,22 @@ describe('authority-owned automation run completion', () => { error: null }) let expectedPrompt: string | undefined + let expectedTurnId: string | undefined const service = new AutomationService(store, { - terminalObserver: createObserver((_signal, _boundary, promptPreview) => { + terminalObserver: createObserver((_signal, _boundary, promptPreview, turnId) => { expectedPrompt = promptPreview + expectedTurnId = turnId return new Promise(() => {}) }) }) service.start() - await vi.waitFor(() => expect(expectedPrompt).toBe(normalizePromptField(prompt))) + const turnPrompt = buildAutomationTurnPrompt(prompt, run.id) + await vi.waitFor(() => expect(expectedPrompt).toBe(normalizePromptField(turnPrompt))) + expect(expectedTurnId).toBe(run.id) expect(readRun(store, automation.id, run.id).dispatchPromptPreview).toBe( - normalizePromptField(prompt) + normalizePromptField(turnPrompt) ) service.stop() }) diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts index 44d298b81dd..c17dc8c0e1b 100644 --- a/src/main/automations/run-completion-watcher.ts +++ b/src/main/automations/run-completion-watcher.ts @@ -18,7 +18,12 @@ export type AutomationRunTerminalObserver = { resolveRunTerminal: (run: AutomationRun) => string | null observeCompletion: ( handle: string, - options: { signal: AbortSignal; observedAfter: number; expectedPrompt?: string } + options: { + signal: AbortSignal + observedAfter: number + expectedPrompt?: string + expectedTurnId?: string + } ) => Promise } @@ -118,7 +123,8 @@ export class AutomationRunCompletionWatcher { observation = await this.observer.observeCompletion(handle, { signal: controller.signal, observedAfter: run.dispatchedAt ?? run.startedAt ?? run.createdAt, - expectedPrompt: run.dispatchPromptPreview + expectedPrompt: run.dispatchPromptPreview, + expectedTurnId: run.id }) } catch (error) { if (controller.signal.aborted) { diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts index 761f37c2605..279a3374cb0 100644 --- a/src/main/automations/runtime-terminal-run-observer.ts +++ b/src/main/automations/runtime-terminal-run-observer.ts @@ -25,7 +25,8 @@ export type AutomationRunTerminalHost = { hasTerminalAgentWorkedSince( handle: string, observedAfter: number, - expectedPrompt?: string + expectedPrompt?: string, + expectedTurnId?: string ): boolean waitForTerminal( handle: string, @@ -35,6 +36,7 @@ export type AutomationRunTerminalHost = { signal?: AbortSignal agentTurnStartedAfter?: number agentTurnPrompt?: string + agentTurnId?: string } ): Promise<{ satisfied: boolean; blockedReason?: string }> readTerminal(handle: string, opts?: { limit?: number }): Promise<{ tail: string[] }> @@ -96,7 +98,10 @@ export function createRuntimeAutomationRunTerminalObserver( return { resolveRunTerminal: (run) => run.terminalPaneKey ? runtime.getTerminalHandleForPaneKey(run.terminalPaneKey) : null, - observeCompletion: async (handle, { signal, observedAfter, expectedPrompt }) => { + observeCompletion: async ( + handle, + { signal, observedAfter, expectedPrompt, expectedTurnId } + ) => { const startedAt = Date.now() // Why: shell startup and a ready prompt can both satisfy opposite sides of // tui-idle before the agent begins. Only a working lifecycle observed for @@ -107,14 +112,22 @@ export function createRuntimeAutomationRunTerminalObserver( timeoutMs: AGENT_START_DEADLINE_MS, signal, agentTurnStartedAfter: observedAfter, - agentTurnPrompt: expectedPrompt + agentTurnPrompt: expectedPrompt, + agentTurnId: expectedTurnId }) return await buildObservation(runtime, handle, initialWait) } catch (error) { if (signal.aborted || !isTerminalWaitTimeout(error)) { throw error } - if (!runtime.hasTerminalAgentWorkedSince(handle, observedAfter, expectedPrompt)) { + if ( + !runtime.hasTerminalAgentWorkedSince( + handle, + observedAfter, + expectedPrompt, + expectedTurnId + ) + ) { return await buildUnobservedObservation( runtime, handle, @@ -129,7 +142,8 @@ export function createRuntimeAutomationRunTerminalObserver( condition: 'tui-idle', signal, agentTurnStartedAfter: observedAfter, - agentTurnPrompt: expectedPrompt + agentTurnPrompt: expectedPrompt, + agentTurnId: expectedTurnId }) return await buildObservation(runtime, handle, wait) } catch (error) { diff --git a/src/main/index.ts b/src/main/index.ts index 4cc1025d916..a101aa0d5bd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -322,6 +322,7 @@ import { initializeBrowserClientHostId } from './browser/browser-client-host-id' import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' +import { buildAutomationTurnPrompt } from '../shared/automation-turn-prompt' import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer' import { AgentAwakeService } from './agent-awake-service' import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' @@ -2834,7 +2835,7 @@ void app.whenReady().then(async () => { `id:${automation.workspaceId}`, { agent: automation.agentId, - prompt: automation.prompt, + prompt: buildAutomationTurnPrompt(automation.prompt, run.id), title: run.title } ) diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index d9361b00e68..ab9ad141950 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -6,6 +6,7 @@ import type { AutomationRunTrigger } from '../../../shared/automations-types' import { normalizePromptField } from '../../../shared/agent-status-field-normalization' +import { buildAutomationTurnPrompt } from '../../../shared/automation-turn-prompt' import type { PersistedState } from '../../../shared/persisted-state-types' import { nextAutomationRunNumber, @@ -62,8 +63,9 @@ export function createAutomationRun( const runNumber = nextAutomationRunNumber( (operations.state.automationRuns ?? []).filter((run) => run.automationId === automation.id) ) + const runId = randomUUID() const run: AutomationRun = { - id: randomUUID(), + id: runId, automationId: automation.id, runNumber, runContext: automation.runContext ?? null, @@ -85,7 +87,9 @@ export function createAutomationRun( error: null, startedAt: null, dispatchedAt: null, - dispatchPromptPreview: normalizePromptField(automation.prompt), + dispatchPromptPreview: normalizePromptField( + buildAutomationTurnPrompt(automation.prompt, runId) + ), createdAt: now } operations.state.automationRuns = pruneAutomationRuns([ diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b49c78e0af5..af068ba9f2e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -27,6 +27,7 @@ import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/li import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt' import { reviewHeadRemoteRefComponent, REVIEW_HEAD_FETCH_TIMEOUT_MS @@ -37665,10 +37666,11 @@ describe('OrcaRuntimeService', () => { it('restores ordered completion from persisted working evidence after restart', async () => { const observedAfter = Date.now() - 1_000 const leafId = '11111111-2222-4333-8444-555555555555' + const turnPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1') const statuses = [ { state: 'working' as const, - prompt: 'automation prompt', + prompt: turnPrompt, agentType: 'codex' as const, paneKey: `tab-1:${leafId}`, connectionId: null, @@ -37708,7 +37710,8 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', timeoutMs: 1_000, agentTurnStartedAfter: observedAfter, - agentTurnPrompt: 'automation prompt' + agentTurnPrompt: turnPrompt, + agentTurnId: 'run-1' }) runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200) @@ -37719,6 +37722,7 @@ describe('OrcaRuntimeService', () => { it('does not let a different post-restart prompt consume retained dispatch evidence', async () => { const observedAfter = Date.now() - 1_000 const leafId = '11111111-2222-4333-8444-555555555555' + const turnPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1') const statuses = [ { state: 'working' as const, @@ -37765,7 +37769,111 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals expect( - runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, 'automation prompt') + runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, turnPrompt, 'run-1') + ).toBe(false) + }) + + it('accepts an exact marker-matched SSH completion without working or clock ordering', async () => { + const observedAfter = Date.now() + const leafId = '11111111-2222-4333-8444-555555555555' + const turnPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1') + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + state: 'done', + prompt: turnPrompt, + agentType: 'codex', + paneKey: `tab-1:${leafId}`, + connectionId: 'ssh-1', + receivedAt: observedAfter - 100, + stateStartedAt: observedAfter - 200, + observation: { + origin: 'hook', + authorityId: 'remote-authority', + incarnation: 0, + revision: 2, + observedAt: observedAfter + 60 * 60 * 1_000, + kind: 'snapshot' + } + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + + expect( + runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, turnPrompt, 'run-1') + ).toBe(true) + }) + + it('rejects raw same-text and different-run completion evidence', async () => { + const observedAfter = Date.now() - 1_000 + const leafId = '11111111-2222-4333-8444-555555555555' + const expectedPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1') + let prompt = 'automation prompt' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + state: 'done', + prompt, + agentType: 'codex', + paneKey: `tab-1:${leafId}`, + connectionId: null, + receivedAt: observedAfter + 100, + stateStartedAt: observedAfter + 100, + turnCompletedAt: observedAfter + 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + + expect( + runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, expectedPrompt, 'run-1') + ).toBe(false) + prompt = buildAutomationTurnPrompt('automation prompt', 'run-2') + expect( + runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, expectedPrompt, 'run-1') ).toBe(false) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 182f9fa5634..d3835b01758 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -56,6 +56,7 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import { isAutomationTurnPrompt } from '../../shared/automation-turn-prompt' import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' @@ -2455,6 +2456,7 @@ type TerminalWaiter = { condition: RuntimeTerminalWaitCondition agentTurnStartedAfter: number | null agentTurnPrompt: string | null + agentTurnId: string | null resolve: (result: RuntimeTerminalWait) => void reject: (error: Error) => void timeout: NodeJS.Timeout | null @@ -19528,9 +19530,11 @@ export class OrcaRuntimeService { hasTerminalAgentWorkedSince( handle: string, observedAfter: number, - expectedPrompt?: string + expectedPrompt?: string, + expectedTurnId?: string ): boolean { - return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt).started + return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt, expectedTurnId) + .started } recordAutomationAgentStatus(entry: AgentStatusIpcPayload): void { @@ -19584,15 +19588,18 @@ export class OrcaRuntimeService { private hasTerminalAgentFinishedSince( handle: string, observedAfter: number, - expectedPrompt?: string | null + expectedPrompt?: string | null, + expectedTurnId?: string | null ): boolean { - return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt).finished + return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt, expectedTurnId) + .finished } private getTerminalAgentTurnEvidence( handle: string, observedAfter: number, - expectedPrompt?: string | null + expectedPrompt?: string | null, + expectedTurnId?: string | null ): { started: boolean; finished: boolean } { const ptyId = this.getTerminalAgentStatusPtyId(handle) const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) @@ -19605,21 +19612,27 @@ export class OrcaRuntimeService { : null let completedFromHook = false const paneKey = this.getPaneKeyForTerminalHandle(handle) + const hasTurnIdentity = + expectedPrompt && expectedTurnId + ? isAutomationTurnPrompt(expectedPrompt, expectedTurnId) + : false const liveHookEvidence = paneKey ? this.automationHookTurnEvidenceByPaneKey.get(paneKey) : undefined + const liveEvidenceMatchesTurn = hasTurnIdentity && liveHookEvidence?.prompt === expectedPrompt if ( liveHookEvidence?.startedAt !== null && liveHookEvidence?.startedAt !== undefined && (!expectedPrompt || liveHookEvidence.prompt === expectedPrompt) ) { - if (liveHookEvidence.startedAt >= observedAfter) { + if (liveEvidenceMatchesTurn || liveHookEvidence.startedAt >= observedAfter) { startedAt = Math.max(startedAt ?? observedAfter, liveHookEvidence.startedAt) } if ( liveHookEvidence.finishedAt !== null && - liveHookEvidence.finishedAt > observedAfter && - (liveHookEvidence.startedAt >= observedAfter || liveHookEvidence.startedAt === null) + (liveEvidenceMatchesTurn || + (liveHookEvidence.finishedAt > observedAfter && + (liveHookEvidence.startedAt >= observedAfter || liveHookEvidence.startedAt === null))) ) { completedFromHook = true } @@ -19636,16 +19649,19 @@ export class OrcaRuntimeService { entry.observation.origin === 'hook' && entry.observation.replayedAt !== undefined && entry.observation.replayedAt > observedAfter + const matchesTurnIdentity = + hasTurnIdentity && expectedPrompt !== null && entry.prompt === expectedPrompt if ( entry.observation?.kind === 'snapshot' && entry.state !== 'working' && - !replayedCompletion + !replayedCompletion && + !matchesTurnIdentity ) { continue } if ( entry.state === 'working' && - entry.stateStartedAt >= observedAfter && + (matchesTurnIdentity || entry.stateStartedAt >= observedAfter) && (!expectedPrompt || entry.prompt === expectedPrompt) ) { startedAt = Math.max(startedAt ?? observedAfter, entry.stateStartedAt) @@ -19653,8 +19669,8 @@ export class OrcaRuntimeService { if ( entry.state === 'done' && entry.sessionBoundary !== true && - (entry.turnCompletedAt !== undefined || replayedCompletion) && - entry.receivedAt > observedAfter && + (entry.turnCompletedAt !== undefined || replayedCompletion || matchesTurnIdentity) && + (matchesTurnIdentity || entry.receivedAt > observedAfter) && (!expectedPrompt || entry.prompt === expectedPrompt) ) { completedFromHook = true @@ -19678,7 +19694,8 @@ export class OrcaRuntimeService { this.hasTerminalAgentFinishedSince( waiter.handle, waiter.agentTurnStartedAfter, - waiter.agentTurnPrompt + waiter.agentTurnPrompt, + waiter.agentTurnId ) ) } @@ -20648,14 +20665,21 @@ export class OrcaRuntimeService { signal?: AbortSignal agentTurnStartedAfter?: number agentTurnPrompt?: string + agentTurnId?: string } ): Promise { const condition = options?.condition ?? 'exit' const agentTurnStartedAfter = options?.agentTurnStartedAfter ?? null const agentTurnPrompt = options?.agentTurnPrompt ?? null + const agentTurnId = options?.agentTurnId ?? null const hasOrderedAgentCompletion = (): boolean => agentTurnStartedAfter !== null && - this.hasTerminalAgentFinishedSince(handle, agentTurnStartedAfter, agentTurnPrompt) + this.hasTerminalAgentFinishedSince( + handle, + agentTurnStartedAfter, + agentTurnPrompt, + agentTurnId + ) const pty = this.getLivePtyForHandle(handle) if (pty) { if (condition === 'exit' && !pty.pty.connected) { @@ -20700,6 +20724,7 @@ export class OrcaRuntimeService { condition, agentTurnStartedAfter, agentTurnPrompt, + agentTurnId, resolve, reject, timeout: null, @@ -20811,6 +20836,7 @@ export class OrcaRuntimeService { condition, agentTurnStartedAfter, agentTurnPrompt, + agentTurnId, resolve, reject, timeout: null, diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 19f220a479e..fdd9cadeb8d 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -278,7 +278,7 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-created', - prompt: 'run this' + prompt: '\nrun this' }) ) expect(order).toEqual(['agent']) @@ -320,7 +320,7 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-created', - prompt: 'run this' + prompt: '\nrun this' }) ) }) @@ -339,7 +339,7 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( expect.objectContaining({ agent: 'claude', - prompt: 'run this', + prompt: '\nrun this', worktreeId: 'wt-created' }) ) @@ -358,7 +358,7 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-created', - prompt: 'run this' + prompt: '\nrun this' }) ) expect(mockMarkDispatchResult).toHaveBeenCalledWith( @@ -393,7 +393,7 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-existing', - prompt: 'run this' + prompt: '\nrun this' }) ) }) @@ -439,7 +439,7 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: folderWorkspace.id, - prompt: 'run this' + prompt: '\nrun this' }) ) expect(mockMarkDispatchResult).toHaveBeenCalledWith( diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 820d0d4a4d9..a7c64733398 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -34,6 +34,7 @@ import { import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types' import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' +import { buildAutomationTurnPrompt } from '../../../shared/automation-turn-prompt' const activeReuseDispatchTabIds = new Set() @@ -90,6 +91,7 @@ export function useAutomationDispatchEvents(): void { useEffect(() => { const unsubscribe = window.api.automations.onDispatchRequested( async ({ automation, run, dispatchToken }) => { + const dispatchPrompt = buildAutomationTurnPrompt(automation.prompt, run.id) const markDispatchResult = async (result: AutomationDispatchResult): Promise => { // Deliberately no local emit: the write publishes its own host-scoped event // (automation-run-writer.ts) before this reply returns, and an unscoped one @@ -527,7 +529,7 @@ export function useAutomationDispatchEvents(): void { const submitted = await submitPromptToAgentPty({ tabId: reusableSession.tabId, ptyId: reusableSession.ptyId, - content: automation.prompt + content: dispatchPrompt }) if (!submitted) { cleanupRunObservers() @@ -600,7 +602,7 @@ export function useAutomationDispatchEvents(): void { const result = await launchAgentBackgroundSession({ agent: automation.agentId, worktreeId: worktree.id, - prompt: automation.prompt, + prompt: dispatchPrompt, launchSource: 'unknown', title: run.title, onData: (chunk) => { diff --git a/src/shared/automation-turn-prompt.test.ts b/src/shared/automation-turn-prompt.test.ts new file mode 100644 index 00000000000..cef389d055b --- /dev/null +++ b/src/shared/automation-turn-prompt.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { normalizePromptField } from './agent-status-field-normalization' +import { buildAutomationTurnPrompt, isAutomationTurnPrompt } from './automation-turn-prompt' + +describe('automation turn prompts', () => { + it('gives identical task text distinct authority-owned turn identities', () => { + const first = buildAutomationTurnPrompt('run this', 'run-1') + const second = buildAutomationTurnPrompt('run this', 'run-2') + + expect(first).not.toBe(second) + expect(isAutomationTurnPrompt(first)).toBe(true) + expect(isAutomationTurnPrompt(second)).toBe(true) + expect(isAutomationTurnPrompt(first, 'run-1')).toBe(true) + expect(isAutomationTurnPrompt(first, 'run-2')).toBe(false) + expect(isAutomationTurnPrompt(normalizePromptField(first))).toBe(true) + expect(isAutomationTurnPrompt('run this')).toBe(false) + }) + + it('does not trust a legacy user-authored marker for another run', () => { + const legacyPrompt = '\nlegacy task' + + expect(isAutomationTurnPrompt(normalizePromptField(legacyPrompt), 'real-run-id')).toBe(false) + }) +}) diff --git a/src/shared/automation-turn-prompt.ts b/src/shared/automation-turn-prompt.ts new file mode 100644 index 00000000000..768a10f4b64 --- /dev/null +++ b/src/shared/automation-turn-prompt.ts @@ -0,0 +1,20 @@ +const AUTOMATION_TURN_PREFIX = '' + +/** Adds authority-generated turn identity without changing the user's task body. */ +export function buildAutomationTurnPrompt(prompt: string, runId: string): string { + return `${AUTOMATION_TURN_PREFIX}${runId}${AUTOMATION_TURN_SUFFIX}\n${prompt}` +} + +export function isAutomationTurnPrompt(prompt: string, runId?: string): boolean { + if (!prompt.startsWith(AUTOMATION_TURN_PREFIX)) { + return false + } + const markerEnd = prompt.indexOf(AUTOMATION_TURN_SUFFIX, AUTOMATION_TURN_PREFIX.length) + const delimiter = prompt[markerEnd + AUTOMATION_TURN_SUFFIX.length] + if (markerEnd <= AUTOMATION_TURN_PREFIX.length || (delimiter !== '\n' && delimiter !== ' ')) { + return false + } + const markerRunId = prompt.slice(AUTOMATION_TURN_PREFIX.length, markerEnd) + return runId === undefined || markerRunId === runId +}