fix(orchestration): surface structured chat pauses

This commit is contained in:
Brennan Benson
2026-08-23 11:34:25 -07:00
parent f4058e3515
commit c103bc33ba
13 changed files with 585 additions and 8 deletions
@@ -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<string, string | boolean>([
['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: {
+36
View File
@@ -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<string, string | true>([
['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 } }
+7
View File
@@ -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}.`
}
@@ -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<void> {
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<string, unknown>, 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'])
})
})
@@ -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: [
@@ -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(<NativeChatOrchestrationPausedNotice />)
expect(screen.queryByRole('status')).toBeNull()
rerender(<NativeChatOrchestrationPausedNotice dispatchStatus="completed" />)
expect(screen.queryByRole('status')).toBeNull()
})
it.each(['pending', 'dispatched'] as const)(
'persists recovery guidance for an active %s Dispatch',
(dispatchStatus) => {
render(<NativeChatOrchestrationPausedNotice dispatchStatus={dispatchStatus} />)
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'
)
}
)
})
@@ -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 (
<div
data-orchestration-paused="true"
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
role="status"
>
<PauseCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<div className="min-w-0 flex-1 space-y-1">
<Badge variant="secondary">
{translate('components.native-chat.orchestrationPaused.label', 'Orchestration paused')}
</Badge>
<p>
{translate(
'components.native-chat.orchestrationPaused.message',
'Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with'
)}{' '}
<code className="font-mono text-foreground">orca orchestration check</code>.
</p>
</div>
</div>
)
}
@@ -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<string | null>(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"
>
<NativeChatOrchestrationPausedNotice dispatchStatus={props.orchestrationDispatchStatus} />
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
@@ -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<NativeChatViewProps, { mode: 'structured' }>): React.JSX.Element {
const { entry: agentStatusEntry, paneKey } = useNativeChatStatusEntry(
terminalTabId,
@@ -99,6 +101,7 @@ function NativeChatBridgeView({
onSwitchToTerminal={onSwitchToTerminal}
readTerminalScreen={readTerminalScreen}
contextMenuActions={contextMenuActions}
orchestrationDispatchStatus={orchestrationDispatchStatus}
/>
)}
</NativeChatSessionGate>
@@ -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"
>
<NativeChatOrchestrationPausedNotice dispatchStatus={orchestrationDispatchStatus} />
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
@@ -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<NativeChatContextMenuActions, 'onPaste'>
}
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
@@ -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}
/>
)}
</div>,
+5 -1
View File
@@ -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": {
@@ -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`.'
}