mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
fix(orchestration): surface structured chat pauses
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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`.'
|
||||
}
|
||||
Reference in New Issue
Block a user