diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 5e661b3e3..b11017df4 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parseArgs, validateCommandAndFlags } from './args' +import { parseArgs, supportsBrowserPageFlag, validateCommandAndFlags } from './args' describe('parseArgs', () => { it('keeps an empty string as a flag value', () => { @@ -39,6 +39,12 @@ describe('parseArgs', () => { }) }) +describe('supportsBrowserPageFlag', () => { + it('does not expose browser page targeting on orchestration commands', () => { + expect(supportsBrowserPageFlag(['orchestration', 'send'])).toBe(false) + }) +}) + describe('validateCommandAndFlags', () => { const specs = [ { diff --git a/src/cli/args.ts b/src/cli/args.ts index 204c4eabd..3baeddbe8 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -81,6 +81,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { 'worktree', 'terminal', 'file', + 'orchestration', 'computer', 'note', 'diagnostics' diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index 0a67ae852..8f1cc501d 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -56,6 +56,68 @@ describe('orchestration reset CLI handler', () => { }) }) +describe('orchestration send structured payload flags', () => { + beforeEach(() => { + callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } }) + delete process.env.ORCA_TERMINAL_HANDLE + }) + + const invokeSend = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration send']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it('serializes common worker payload fields as JSON', async () => { + await invokeSend( + new Map([ + ['from', 'term_worker'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['files-modified', 'src/a.ts, src/b.ts'], + ['report-path', 'reports/done.md'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: JSON.stringify({ + taskId: 'task_1', + dispatchId: 'ctx_1', + filesModified: ['src/a.ts', 'src/b.ts'], + reportPath: 'reports/done.md' + }), + devMode: false + }) + }) + + it('rejects mixing raw payload with structured payload flags', async () => { + await expect( + invokeSend( + new Map([ + ['from', 'term_worker'], + ['to', 'term_coord'], + ['subject', 'done'], + ['payload', '{"taskId":"task_1"}'], + ['task-id', 'task_1'] + ]) + ) + ).rejects.toThrow(/structured payload/) + expect(callMock).not.toHaveBeenCalled() + }) +}) + describe('orchestration timeout flag validation', () => { const invalidTimeoutValues: [string, string | boolean][] = [ ['missing', true], diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index f57a86e8f..a697842aa 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -72,6 +72,54 @@ type MessageSummary = { payload?: string | null } +function getOptionalStructuredMessagePayload( + flags: Map +): string | undefined { + const rawPayload = getOptionalStringFlag(flags, 'payload') + const taskId = getOptionalStringFlag(flags, 'task-id') + const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') + const filesModified = getOptionalStringFlag(flags, 'files-modified') + const reportPath = getOptionalStringFlag(flags, 'report-path') + const phase = getOptionalStringFlag(flags, 'phase') + const hasStructuredPayload = + taskId !== undefined || + dispatchId !== undefined || + filesModified !== undefined || + reportPath !== undefined || + phase !== undefined + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + // Why: raw JSON arguments are fragile in Windows PowerShell; these flags let + // workers send parseable orchestration payloads without shell-specific quoting. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + if (filesModified) { + payload.filesModified = filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + if (reportPath) { + payload.reportPath = reportPath + } + if (phase) { + payload.phase = phase + } + return JSON.stringify(payload) +} + async function resolveOrchestrationTerminalHandle( flags: Map, cwd: string, @@ -127,7 +175,7 @@ export const ORCHESTRATION_HANDLERS: Record = { type: getOptionalStringFlag(flags, 'type'), priority: getOptionalStringFlag(flags, 'priority'), threadId: getOptionalStringFlag(flags, 'thread-id'), - payload: getOptionalStringFlag(flags, 'payload'), + payload: getOptionalStructuredMessagePayload(flags), devMode: isDevCliInvocation() }) printResult(result, json, (r) => { diff --git a/src/cli/help.ts b/src/cli/help.ts index 0375aaa9a..2378fbc21 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -390,6 +390,11 @@ export function formatFlagHelp(flag: string): string { terminal: '--terminal Runtime-issued terminal handle', text: '--text Text payload to send or type', 'text-stdin': '--text-stdin Read text payload from stdin', + 'task-id': '--task-id Task id to include in orchestration payload JSON', + 'dispatch-id': '--dispatch-id Dispatch id to include in orchestration payload JSON', + 'files-modified': '--files-modified Comma-separated files for orchestration payload JSON', + 'report-path': '--report-path Report path to include in orchestration payload JSON', + phase: '--phase Worker phase to include in orchestration payload JSON', 'timeout-ms': '--timeout-ms Maximum wait time before timing out', 'to-element-index': '--to-element-index Destination element index from get-app-state', 'to-x': '--to-x Destination window-local x coordinate', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 4f23c7690..b05bb60a3 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -6,7 +6,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'send'], summary: 'Send an inter-agent message', usage: - 'orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]', + 'orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--task-id ] [--dispatch-id ] [--files-modified ] [--report-path ] [--phase ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'to', @@ -16,7 +16,16 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'type', 'priority', 'thread-id', - 'payload' + 'payload', + 'task-id', + 'dispatch-id', + 'files-modified', + 'report-path', + 'phase' + ], + notes: [ + 'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".', + 'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.' ] }, { @@ -39,6 +48,9 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'inject', 'wait', 'timeout-ms' + ], + notes: [ + 'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".' ] }, { diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 40f559ca8..0431919a9 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -25,7 +25,9 @@ Slack, GitHub comments, or any other channel to reach a human during the run. orca orchestration send --to term_COORD \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ - --payload '{"taskId":"task_SNAP","dispatchId":"ctx_SNAP","filesModified":["path/a","path/b"],"reportPath":""}' + --task-id task_SNAP --dispatch-id ctx_SNAP \\ + --files-modified "path/a,path/b" \\ + --report-path "" # BEHAVIOR RULE: send a heartbeat every 5 minutes # while actively working on the task. The coordinator uses this to @@ -39,7 +41,8 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # cannot mask a hung retry. orca orchestration send --to term_COORD \\ --type heartbeat --subject "alive" \\ - --payload '{"taskId":"task_SNAP","dispatchId":"ctx_SNAP","phase":""}' + --task-id task_SNAP --dispatch-id ctx_SNAP \\ + --phase "" # Ask the coordinator a question and block until it answers. # @@ -63,7 +66,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. orca orchestration send --to term_COORD \\ --type escalation --subject "Blocked: " \\ --body "
" \\ - --payload '{"taskId":"task_SNAP"}' + --task-id task_SNAP # Check for messages from the coordinator: orca orchestration check diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index a6cc8ff01..a40ab3f2c 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -32,11 +32,10 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--body') expect(result).toMatch(/3-sentence summary/) expect(result).toContain('reportPath') - const workerDoneLine = result - .split('\n') - .find((line) => line.includes('"taskId"') && line.includes('filesModified')) - expect(workerDoneLine).toContain('dispatchId') - expect(workerDoneLine).toContain('ctx_def456') + expect(result).toContain('--task-id task_abc123') + expect(result).toContain('--dispatch-id ctx_def456') + expect(result).toContain('--files-modified "path/a,path/b"') + expect(result).toContain('--report-path ""') }) it('CLI examples parse as valid shell (bash -n on the extracted block)', () => { @@ -64,15 +63,12 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--type heartbeat') expect(result).toContain('--subject "alive"') expect(result).toMatch(/5 minutes/) - // Both taskId and dispatchId are rendered inside the payload template + // Both taskId and dispatchId are rendered as structured payload flags // (regression guard for §5.3.4 attribution — dispatchId attribution // prevents the zombie-heartbeat-masks-hung-retry race). - const heartbeatLine = result - .split('\n') - .find((line) => line.includes('"taskId"') && line.includes('dispatchId')) - expect(heartbeatLine).toBeTruthy() - expect(heartbeatLine).toContain('task_abc123') - expect(heartbeatLine).toContain('ctx_def456') + expect(result).toContain('--task-id task_abc123') + expect(result).toContain('--dispatch-id ctx_def456') + expect(result).toContain('--phase ""') }) it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index b10864e8f..d0bbf80b9 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -64,7 +64,9 @@ Slack, GitHub comments, or any other channel to reach a human during the run. ${cli} orchestration send --to ${params.coordinatorHandle} \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ - --payload '{"taskId":"${params.taskId}","dispatchId":"${params.dispatchId}","filesModified":["path/a","path/b"],"reportPath":""}' + --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ + --files-modified "path/a,path/b" \\ + --report-path "" # BEHAVIOR RULE: send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes # while actively working on the task. The coordinator uses this to @@ -78,7 +80,8 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # cannot mask a hung retry. ${cli} orchestration send --to ${params.coordinatorHandle} \\ --type heartbeat --subject "alive" \\ - --payload '{"taskId":"${params.taskId}","dispatchId":"${params.dispatchId}","phase":""}' + --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ + --phase "" # Ask the coordinator a question and block until it answers. # @@ -102,7 +105,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. ${cli} orchestration send --to ${params.coordinatorHandle} \\ --type escalation --subject "Blocked: " \\ --body "
" \\ - --payload '{"taskId":"${params.taskId}"}' + --task-id ${params.taskId} # Check for messages from the coordinator: ${cli} orchestration check diff --git a/src/renderer/src/components/tab-bar/drop-indicator.test.ts b/src/renderer/src/components/tab-bar/drop-indicator.test.ts index 01608ac97..aa6e6209c 100644 --- a/src/renderer/src/components/tab-bar/drop-indicator.test.ts +++ b/src/renderer/src/components/tab-bar/drop-indicator.test.ts @@ -46,7 +46,9 @@ describe('ACTIVE_TAB_INDICATOR_CLASSES', () => { expect(ACTIVE_TAB_INDICATOR_CLASSES).toContain('absolute') expect(ACTIVE_TAB_INDICATOR_CLASSES).toContain('bottom-0') expect(ACTIVE_TAB_INDICATOR_CLASSES).toContain('h-[2px]') - expect(ACTIVE_TAB_INDICATOR_CLASSES).toContain('bg-foreground') + expect(ACTIVE_TAB_INDICATOR_CLASSES).toContain( + 'bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))]' + ) expect(ACTIVE_TAB_INDICATOR_CLASSES).toContain('pointer-events-none') expect(ACTIVE_TAB_INDICATOR_CLASSES).not.toContain('-top-px') expect(ACTIVE_TAB_INDICATOR_CLASSES).not.toContain('bg-[#1e3d9c]') @@ -56,7 +58,7 @@ describe('ACTIVE_TAB_INDICATOR_CLASSES', () => { describe('getTabRootStateClasses', () => { it('returns the shared selected-tab surface treatment', () => { const classes = getTabRootStateClasses(true) - expect(classes).toContain('bg-[color-mix(in_srgb,var(--foreground)_10%,var(--card))]') + expect(classes).toContain('bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))]') expect(classes).toContain('text-foreground') expect(classes).not.toContain('hover:text-foreground') })