diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index d19666556c6..cd48e0f4c32 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -164,6 +164,52 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBe(1) }) + it('prints the Structured Chat recovery action for a refused worker start', async () => { + callMock.mockResolvedValue({ + result: { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'failed', + failedStage: 'dispatch_input', + lastError: + 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.', + effects: [], + residualResources: [] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags: new Map([ + ['task', 'task_1'], + ['terminal', 'term_worker'], + ['from', 'term_coord'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as + | ((result: { + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + }) => string) + | undefined + expect( + formatter?.({ + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'failed', + failedStage: 'dispatch_input', + lastError: + 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + }) + ).toMatch(/Structured Chat.*Switch it to Terminal.*orca orchestration worker-start/s) + }) + it('prints a reveal warning for a live background worker', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index 7d08658fc66..b13775dce00 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -94,6 +94,42 @@ describe('terminal send CLI', () => { }) }) + it('explains that Structured Chat blocked a refused send and how to recover', async () => { + const call = vi.fn().mockResolvedValue({ + result: { + send: { + handle: 'term-1', + accepted: false, + bytesWritten: 0, + agentSessionRefusal: { + code: 'agent_session_conflict', + sessionId: 'session-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7 + } + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text', 'review'], + ['enter', true] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: false + }) + + expect(console.log).toHaveBeenCalledWith( + expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s) + ) + }) + it('keeps text-only and bare Enter sends as direct terminal input', async () => { const call = vi.fn().mockResolvedValue({ result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } } diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 806a39cb588..017d2f3b923 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,4 +1,5 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' +import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' import type { RuntimeTerminalClose, RuntimeTerminalCreate, @@ -180,6 +181,12 @@ function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string } export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { + if (result.send.agentSessionRefusal) { + const copy = structuredChatPtyWriteRefusalCopy(result.send.agentSessionRefusal, 'terminal-send') + if (copy) { + return copy + } + } return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.` } diff --git a/src/main/runtime/orchestration-structured-chat-lease.test.ts b/src/main/runtime/orchestration-structured-chat-lease.test.ts new file mode 100644 index 00000000000..67469a480c2 --- /dev/null +++ b/src/main/runtime/orchestration-structured-chat-lease.test.ts @@ -0,0 +1,370 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { TERMINAL_METHODS } from './rpc/methods/terminal' + +const WORKTREE_ID = 'repo-structured-chat::/tmp/structured-chat' +const SESSION_ID = 'session-structured-chat' +const COORDINATOR = { + handle: 'term_structured_coord', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ptyId: 'pty-structured-coord' +} +const WORKER = { + handle: 'term_structured_worker', + tabId: '22222222-2222-4222-8222-222222222222', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'pty-structured-worker' +} +const PLAIN = { + handle: 'term-plain', + tabId: '33333333-3333-4333-8333-333333333333', + leafId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + ptyId: 'pty-plain' +} +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' +} + +type TestTerminal = typeof COORDINATOR + +function paneKey(terminal: TestTerminal): string { + return `${terminal.tabId}:${terminal.leafId}` +} + +function makeStore() { + const session = getDefaultWorkspaceSession() + const repo = { + id: 'repo-structured-chat', + path: '/tmp/structured-chat', + displayName: 'structured-chat', + badgeColor: '#000000', + addedAt: 0 + } + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [repo]), + getRepo: vi.fn(() => repo), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +describe('orchestration while Structured Chat owns an agent session', () => { + let directory: string + let recordStore: AgentSessionRecordStore + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let dispatcher: RpcDispatcher + let writes: Mock<(ptyId: string, data: string) => void> + let operationSequence: number + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-orchestration-structured-chat-')) + recordStore = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: WORKTREE_ID, + repoId: 'repo-structured-chat' + } as never) + writes = vi.fn<(ptyId: string, data: string) => void>() + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'unused' })), + write: (ptyId: string, data: string) => { + agentSessionPtyWriteGate.assertAdmitted(ptyId) + writes(ptyId, data) + return true + }, + kill: vi.fn(() => true), + getForegroundProcess: vi.fn(async () => 'codex'), + listProcesses: vi.fn(async () => []), + hasPty: vi.fn(() => true) + } as never) + for (const terminal of [COORDINATOR, WORKER, PLAIN]) { + runtime.registerPty(terminal.ptyId, WORKTREE_ID, null, { + tabId: terminal.tabId, + leafId: terminal.leafId, + incarnationId: `${terminal.ptyId}-incarnation`, + agentLaunchAuthority: { launchToken: `${terminal.ptyId}-launch`, launchAgent: 'codex' } + }) + runtime.registerPreAllocatedHandleForPty(terminal.ptyId, terminal.handle) + } + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [COORDINATOR, WORKER, PLAIN].map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: WORKTREE_ID, + title: 'Codex', + activeLeafId: terminal.leafId, + layout: null + })), + leaves: [COORDINATOR, WORKER, PLAIN].map((terminal, index) => ({ + tabId: terminal.tabId, + worktreeId: WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: index + 1, + ptyId: terminal.ptyId, + paneTitle: null, + title: 'Codex' + })) + }) + await runtime.listTerminals() + for (const terminal of [COORDINATOR, WORKER, PLAIN]) { + runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex working\x07', 1) + runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex done\x07', 2) + } + operationSequence = 0 + await establishOwner('native', 'spawn-native', null) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => recordStore.getRecord(sessionId)) + agentSessionPtyWriteGate.bindPty(WORKER.ptyId, SESSION_ID) + dispatcher = new RpcDispatcher({ + runtime, + methods: [...ORCHESTRATION_METHODS, ...TERMINAL_METHODS] + }) + }) + + afterEach(async () => { + vi.useRealTimers() + agentSessionPtyWriteGate.detachRecordLookup() + db.close() + await rm(directory, { recursive: true, force: true }) + }) + + function operation() { + operationSequence += 1 + return { + callerKey: 'structured-chat-test', + operationId: `1800000000000-${operationSequence.toString(16).padStart(32, '0')}`, + fingerprint: `structured-chat-${operationSequence}` + } + } + + async function establishOwner( + runtimeKind: 'native' | 'tui', + spawnToken: string, + expectedFence: number | null + ): Promise { + const now = 1_800_000_000_000 + operationSequence + const reserved = await recordStore.reserveOwner({ + sessionId: SESSION_ID, + location: LOCATION, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + runtimeKind, + expectedFence, + spawnToken, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: + expectedFence === null + ? { outcome: 'indeterminate', reason: 'new session' } + : { outcome: 'pid-absent' }, + operation: operation(), + now + }) + const fence = reserved.record.lease.runtimeFence + await recordStore.commitProcessIdentity({ + sessionId: SESSION_ID, + fence, + process: { hostId: 'local', pid: 4242 + fence, processStartTimeMs: now, spawnToken }, + now + }) + await recordStore.proveOwner({ + sessionId: SESSION_ID, + fence, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: fence === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: now + }, + now + }) + } + + function createRun(coordinator = COORDINATOR) { + return db.createRun({ + objective: 'Structured Chat lease coverage', + coordinatorHandle: coordinator.handle, + coordinatorPaneKey: paneKey(coordinator) + }) + } + + function queueRunMessage(runId: string) { + return db.insertMessage({ + from: 'term_sender', + to: `run:${runId}`, + subject: 'Queued guidance', + type: 'status', + runId + }) + } + + async function rpc(method: string, params: Record, capability?: string) { + return dispatcher.dispatch({ + id: `request-${method}-${Math.random()}`, + authToken: 'test-token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `mutation-${method}-${Math.random()}`, + orchestrationCapability: capability + }) + } + + it('retains a Run mailbox pointer while Structured Chat refuses the PTY write', () => { + const run = createRun(WORKER) + const message = queueRunMessage(run.id) + + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + + expect(db.getMessageById(message.id)?.delivered_at).toBeNull() + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('redrives the same retained pointer after the lease returns to TUI', async () => { + vi.useFakeTimers() + const run = createRun(WORKER) + const message = queueRunMessage(run.id) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + expect(db.getMessageById(message.id)?.delivered_at).toBeNull() + + await establishOwner('tui', 'spawn-tui', 1) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + await vi.advanceTimersByTimeAsync(500) + + expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[0]?.[1]).toContain('orca orchestration check') + expect(writes.mock.calls[1]).toEqual([WORKER.ptyId, '\r']) + }) + + it('fails worker-start truthfully when its reused terminal is in Structured Chat', async () => { + const run = createRun() + const task = db.createTask({ spec: 'Run the queued work', runId: run.id }) + + const response = await rpc('orchestration.workerStart', { + task: task.id, + worktree: 'current', + terminal: WORKER.handle, + from: COORDINATOR.handle + }) + + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.ok).toBe(true) + expect(response.result).toMatchObject({ + state: 'failed', + failedStage: 'dispatch_input', + lastError: expect.stringMatching(/Structured Chat.*Switch it to Terminal/), + agentSessionRefusal: { code: 'agent_session_conflict', ownerRuntimeKind: 'native' } + }) + expect(db.getTask(task.id)?.status).toBe('failed') + expect(db.getDispatchContext(task.id)?.status).toBe('failed') + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('returns terminal.send typed refusal metadata without writing bytes', async () => { + const response = await rpc('terminal.send', { + terminal: WORKER.handle, + text: 'new prompt', + client: { id: 'test-client', type: 'desktop' } + }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: WORKER.handle, + accepted: false, + bytesWritten: 0, + agentSessionRefusal: expect.objectContaining({ + code: 'agent_session_conflict', + ownerRuntimeKind: 'native' + }) + } + }) + expect(writes).not.toHaveBeenCalled() + }) + + it('settles worker_done while its pane remains in Structured Chat', async () => { + const run = createRun() + const task = db.createTask({ spec: 'Finish from Structured Chat', runId: run.id }) + const dispatch = db.createDispatchContext( + task.id, + WORKER.handle, + paneKey(WORKER), + undefined, + runtime.getTerminalProcessIncarnation(WORKER.handle) ?? undefined + ) + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: paneKey(WORKER), + processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle)! + }) + + const response = await rpc( + 'orchestration.send', + { + from: WORKER.handle, + subject: 'Done', + body: 'Implemented the task. Verified the result. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + }, + capability + ) + + expect(response.ok).toBe(true) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('delivers normally to a never-adopted terminal', async () => { + vi.useFakeTimers() + const run = createRun(PLAIN) + const message = queueRunMessage(run.id) + + expect(agentSessionPtyWriteGate.admit(PLAIN.ptyId)).toEqual({ + admitted: true, + sessionId: null, + runtimeFence: null + }) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + await vi.advanceTimersByTimeAsync(500) + + expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[1]).toEqual([PLAIN.ptyId, '\r']) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index e58f9fff01e..494cf1faccd 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -4,6 +4,8 @@ import { type WorkerSetupReceipt } from './orchestration-worker-topology' import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' +import { isAgentSessionPtyWriteRefusedError } from '../../../../shared/agent-session-pty-write-admission' +import { structuredChatPtyWriteRefusalCopy } from '../../../../shared/agent-session-pty-write-refusal-copy' export function failWorkerStartWithReceipt(args: { db: OrchestrationDb @@ -15,7 +17,13 @@ export function failWorkerStartWithReceipt(args: { setup: WorkerSetupReceipt launch: OrchestrationWorkerLaunchReceipt }): unknown { - const reason = args.error instanceof Error ? args.error.message : String(args.error) + const agentSessionRefusal = isAgentSessionPtyWriteRefusedError(args.error) + ? args.error.refusal + : undefined + const reason = + (agentSessionRefusal && + structuredChatPtyWriteRefusalCopy(agentSessionRefusal, 'worker-start')) ?? + (args.error instanceof Error ? args.error.message : String(args.error)) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) const worker = unknown ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) @@ -32,6 +40,7 @@ export function failWorkerStartWithReceipt(args: { launch: args.launch, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], + ...(agentSessionRefusal ? { agentSessionRefusal } : {}), ...(unknown ? { nextCommands: [ diff --git a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx new file mode 100644 index 00000000000..a13f672feb4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' + +describe('NativeChatOrchestrationPausedNotice', () => { + afterEach(cleanup) + + it('stays hidden while dispatch state is loading or settled', () => { + const { rerender } = render() + + expect(screen.queryByRole('status')).toBeNull() + + rerender() + expect(screen.queryByRole('status')).toBeNull() + }) + + it.each(['pending', 'dispatched'] as const)( + 'persists recovery guidance for an active %s Dispatch', + (dispatchStatus) => { + render() + + const notice = screen.getByRole('status') + expect(notice.textContent).toContain('Orchestration paused') + expect(notice.textContent).toContain('Structured Chat blocks terminal prompts and sends') + expect(notice.textContent).toContain('Orchestration messages remain queued') + expect(notice.textContent).toContain( + 'switch to Terminal, then check the Orca inbox with orca orchestration check' + ) + } + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx new file mode 100644 index 00000000000..0e43f0cb1bb --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx @@ -0,0 +1,36 @@ +import { PauseCircle } from 'lucide-react' +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' +import { Badge } from '@/components/ui/badge' +import { translate } from '@/i18n/i18n' + +export function NativeChatOrchestrationPausedNotice({ + dispatchStatus +}: { + dispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] +}): React.JSX.Element | null { + if (dispatchStatus !== 'pending' && dispatchStatus !== 'dispatched') { + return null + } + + return ( +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 047875f3662..7b0e2bc8541 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -17,6 +17,8 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useStructuredAgentSession } from './use-structured-agent-session' import { translate } from '@/i18n/i18n' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` @@ -29,6 +31,7 @@ export function NativeChatStructuredSession(props: { agent: AgentType allowFileUriLinks: boolean onSwitchToTerminal?: () => void + orchestrationDispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] }): React.JSX.Element { const controller = useStructuredAgentSession(props) const [composerError, setComposerError] = useState(null) @@ -117,6 +120,7 @@ export function NativeChatStructuredSession(props: { tabIndex={-1} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > +
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index a85f1e4b512..3574ab90eb8 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -53,6 +53,7 @@ import type { NativeChatResolvedViewProps, NativeChatViewProps } from './native- import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { useNativeChatStatusEntry } from './use-native-chat-status-entry' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' export type { NativeChatViewProps } from './native-chat-view-types' @@ -73,7 +74,8 @@ function NativeChatBridgeView({ resolvedAgent, onSwitchToTerminal, readTerminalScreen, - contextMenuActions + contextMenuActions, + orchestrationDispatchStatus }: Exclude): React.JSX.Element { const { entry: agentStatusEntry, paneKey } = useNativeChatStatusEntry( terminalTabId, @@ -99,6 +101,7 @@ function NativeChatBridgeView({ onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} contextMenuActions={contextMenuActions} + orchestrationDispatchStatus={orchestrationDispatchStatus} /> )} @@ -115,7 +118,8 @@ function NativeChatResolvedView({ terminalTabId, onSwitchToTerminal, readTerminalScreen, - contextMenuActions + contextMenuActions, + orchestrationDispatchStatus }: NativeChatResolvedViewProps): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to // the remote runtime host for a runtime-owned pane; null keeps the local path. @@ -394,6 +398,7 @@ function NativeChatResolvedView({ onContextMenuCapture={contextMenu.onContextMenuCapture} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > +
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index d1a206be82b..0b6f9f15470 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -1,10 +1,17 @@ -import type { AgentType } from '../../../../shared/agent-status-types' +import type { + AgentStatusOrchestrationContext, + AgentType +} from '../../../../shared/agent-status-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { NativeChatSession } from '../../../../shared/native-chat-types' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -export type NativeChatBridgeViewProps = { +type NativeChatOrchestrationProps = { + orchestrationDispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] +} + +export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & { mode?: 'bridge' /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ terminalTabId: string @@ -25,7 +32,7 @@ export type NativeChatBridgeViewProps = { contextMenuActions?: Omit } -export type NativeChatStructuredViewProps = { +export type NativeChatStructuredViewProps = NativeChatOrchestrationProps & { mode: 'structured' tabId: string sessionId: string @@ -35,7 +42,7 @@ export type NativeChatStructuredViewProps = { onSwitchToTerminal?: () => void } -export type NativeChatResolvedViewProps = { +export type NativeChatResolvedViewProps = NativeChatOrchestrationProps & { paneKey: string agent: NativeChatSession['agent'] sessionId: string | null diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index dfbe7558dfe..f871810fdc7 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -534,6 +534,11 @@ function TerminalPane( ) const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) const effectiveChatViewMode = nativeChatEnabled && isChatViewMode + const chatPaneDispatchStatus = useAppStore((store) => + chatLeafId + ? store.agentStatusByPaneKey[makePaneKey(tabId, chatLeafId)]?.orchestration?.dispatchStatus + : undefined + ) const unifiedTabLabel = useAppStore( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.label @@ -3172,6 +3177,7 @@ function TerminalPane( agent="codex" target={{ kind: 'local' }} allowFileUriLinks + orchestrationDispatchStatus={chatPaneDispatchStatus} onSwitchToTerminal={switchNativeChatToTerminal} /> ) : ( @@ -3213,6 +3219,7 @@ function TerminalPane( canClosePane: managedPanes.length > 1, onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) }} + orchestrationDispatchStatus={chatPaneDispatchStatus} /> )}
, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ac9f0c159f..c77ebb15ceb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16109,7 +16109,11 @@ "allow": "Allow", "deny": "Deny" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "Not delivered — check the terminal", + "orchestrationPaused": { + "label": "Orchestration paused", + "message": "Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with" + } }, "tab": { "bar": { diff --git a/src/shared/agent-session-pty-write-refusal-copy.ts b/src/shared/agent-session-pty-write-refusal-copy.ts new file mode 100644 index 00000000000..6820b6ce44b --- /dev/null +++ b/src/shared/agent-session-pty-write-refusal-copy.ts @@ -0,0 +1,13 @@ +import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' + +export function structuredChatPtyWriteRefusalCopy( + refusal: AgentSessionPtyWriteRefusal, + action: 'terminal-send' | 'worker-start' +): string | null { + if (refusal.ownerRuntimeKind !== 'native') { + return null + } + return action === 'worker-start' + ? 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + : 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca terminal send`.' +}