mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
refactor(terminals): terminal input and inspection route by the PTY's owner (#27279)
* refactor(terminals): terminal input and inspection route by the PTY's owner A remote PTY id already names the server that owns it. inspect, confirm, send, verified send and the data subscription took settings only to fall back to the focused server for an id without an owner segment, so an old ownerless id could reach a server that does not own it. They now route by the id alone: an ownerless remote id is never dialed and never confirmed locally. Dropping the parameter removes the settings plumbing the native chat, agent draft paste and follow-up paths carried only for this, including the raw focused-settings default in sendBracketedPasteToAgent, and deletes getSettingsForAgentTabRuntimeOwner. * test(e2e): native chat SSH delivery and remote completion follow the narrower terminal signatures --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -60,21 +60,12 @@
|
||||
1 src/renderer/src/components/linear-api-key-dialog.tsx
|
||||
2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx
|
||||
1 src/renderer/src/components/native-chat-resume-tree.test-support.ts
|
||||
2 src/renderer/src/components/native-chat/NativeChatComposer.tsx
|
||||
1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
|
||||
1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
|
||||
1 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts
|
||||
1 src/renderer/src/components/native-chat/native-chat-file-link.ts
|
||||
1 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts
|
||||
1 src/renderer/src/components/native-chat/native-chat-observed-send.ts
|
||||
2 src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts
|
||||
2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts
|
||||
6 src/renderer/src/components/native-chat/native-chat-runtime-send.ts
|
||||
2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts
|
||||
6 src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
|
||||
2 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts
|
||||
4 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts
|
||||
2 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts
|
||||
1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts
|
||||
2 src/renderer/src/components/new-workspace/use-linear-source-connection.ts
|
||||
1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts
|
||||
@@ -234,9 +225,6 @@
|
||||
3 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts
|
||||
5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts
|
||||
1 src/renderer/src/components/task-page/TaskPage.tsx
|
||||
2 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts
|
||||
1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts
|
||||
2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts
|
||||
1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts
|
||||
@@ -247,15 +235,11 @@
|
||||
1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts
|
||||
1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts
|
||||
1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts
|
||||
1 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
|
||||
1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts
|
||||
1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts
|
||||
1 src/renderer/src/components/terminal-pane/use-terminal-pane-paste-listeners.ts
|
||||
1 src/renderer/src/components/terminal-workspace-model.ts
|
||||
1 src/renderer/src/components/terminal/pty-running-work-probe.ts
|
||||
1 src/renderer/src/components/terminal/running-terminal-close-guard.ts
|
||||
1 src/renderer/src/components/terminal/terminal-tab-create.ts
|
||||
1 src/renderer/src/components/terminal/window-close-running-work.ts
|
||||
2 src/renderer/src/components/use-task-page-source-availability.ts
|
||||
2 src/renderer/src/components/use-task-page-store-bindings.ts
|
||||
2 src/renderer/src/components/use-terminal-activation-actions.ts
|
||||
@@ -301,18 +285,12 @@
|
||||
1 src/renderer/src/lib/active-agent-note-send.ts
|
||||
1 src/renderer/src/lib/active-agent-note-target.ts
|
||||
1 src/renderer/src/lib/agent-background-session-launch-host.ts
|
||||
1 src/renderer/src/lib/agent-draft-paste-content.ts
|
||||
1 src/renderer/src/lib/agent-draft-readiness.ts
|
||||
2 src/renderer/src/lib/agent-followup-delivery.ts
|
||||
1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts
|
||||
3 src/renderer/src/lib/agent-hibernation-coordinator.ts
|
||||
5 src/renderer/src/lib/agent-paste-draft.ts
|
||||
1 src/renderer/src/lib/agent-ready-wait.ts
|
||||
1 src/renderer/src/lib/automation-session-observer.ts
|
||||
1 src/renderer/src/lib/client-creation-action-error.ts
|
||||
3 src/renderer/src/lib/client-creation-action-policy.ts
|
||||
3 src/renderer/src/lib/codex-pane-selection-lane.ts
|
||||
2 src/renderer/src/lib/codex-session-restart.ts
|
||||
1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host
|
||||
1 src/renderer/src/lib/doc-preview-grants.ts
|
||||
3 src/renderer/src/lib/ensure-hooks-confirmed.ts
|
||||
@@ -324,7 +302,7 @@
|
||||
1 src/renderer/src/lib/host-mirrored-pane-liveness.ts
|
||||
6 src/renderer/src/lib/http-link-destinations.ts
|
||||
3 src/renderer/src/lib/http-link-routing.ts
|
||||
2 src/renderer/src/lib/launch-agent-background-session.ts
|
||||
1 src/renderer/src/lib/launch-agent-background-session.ts
|
||||
1 src/renderer/src/lib/launch-agent-execution-context.ts
|
||||
2 src/renderer/src/lib/launch-agent-in-new-tab.ts
|
||||
1 src/renderer/src/lib/launch-agent-web-host-tab.ts
|
||||
@@ -333,7 +311,6 @@
|
||||
2 src/renderer/src/lib/launch-work-item-direct.ts
|
||||
1 src/renderer/src/lib/launch-worktree-background-terminals.ts
|
||||
2 src/renderer/src/lib/local-preflight-context.ts
|
||||
1 src/renderer/src/lib/new-workspace.ts
|
||||
1 src/renderer/src/lib/open-agent-message-sender.ts
|
||||
2 src/renderer/src/lib/provider-runtime-context.ts
|
||||
1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts
|
||||
@@ -380,9 +357,6 @@
|
||||
5 src/renderer/src/runtime/runtime-linear-issue-mutations.ts
|
||||
12 src/renderer/src/runtime/runtime-linear-project-client.ts
|
||||
3 src/renderer/src/runtime/runtime-repo-client.ts
|
||||
3 src/renderer/src/runtime/runtime-terminal-inspection.ts
|
||||
1 src/renderer/src/runtime/runtime-terminal-stream.ts
|
||||
1 src/renderer/src/runtime/runtime-terminal-verified-input.ts
|
||||
5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts
|
||||
2 src/renderer/src/runtime/web-runtime-browser-creation.ts
|
||||
1 src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.ts
|
||||
|
||||
@@ -40,9 +40,6 @@
|
||||
1 src/renderer/src/runtime/runtime-jira-target.ts
|
||||
1 src/renderer/src/runtime/runtime-linear-client.ts
|
||||
3 src/renderer/src/runtime/runtime-repo-client.ts
|
||||
3 src/renderer/src/runtime/runtime-terminal-inspection.ts
|
||||
1 src/renderer/src/runtime/runtime-terminal-stream.ts
|
||||
1 src/renderer/src/runtime/runtime-terminal-verified-input.ts
|
||||
1 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts
|
||||
2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts
|
||||
3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts
|
||||
|
||||
@@ -54,7 +54,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
sendRuntimePtyInput: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) }))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({
|
||||
emitNativeChatMessageSent: vi.fn(),
|
||||
emitNativeChatPickerItemAccepted: vi.fn(),
|
||||
|
||||
@@ -72,9 +72,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
sendRuntimePtyInput: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({})
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args),
|
||||
sendNativeChatTypedCommand: (...args: unknown[]) => mocks.sendNativeChatTypedCommand(...args),
|
||||
@@ -450,7 +447,6 @@ describe('NativeChatComposer', () => {
|
||||
|
||||
expect(mocks.sendNativeChatMessageWithImageAttachments).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
{},
|
||||
'pty-1',
|
||||
'hello',
|
||||
['/tmp/pasted.png'],
|
||||
@@ -471,7 +467,7 @@ describe('NativeChatComposer', () => {
|
||||
|
||||
act(() => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status')
|
||||
expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith('pty-1', '/status')
|
||||
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -488,7 +484,7 @@ describe('NativeChatComposer', () => {
|
||||
|
||||
act(() => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '$ref-oss', undefined)
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith('pty-1', '$ref-oss', undefined)
|
||||
expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -505,7 +501,7 @@ describe('NativeChatComposer', () => {
|
||||
|
||||
act(() => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/clear', undefined)
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith('pty-1', '/clear', undefined)
|
||||
expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -793,7 +789,6 @@ describe('NativeChatComposer', () => {
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model opus',
|
||||
expect.any(AbortSignal)
|
||||
@@ -802,7 +797,6 @@ describe('NativeChatComposer', () => {
|
||||
expect(onOptimisticSend).not.toHaveBeenCalled()
|
||||
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Opus'
|
||||
})
|
||||
expect(onSwitchToTerminal).not.toHaveBeenCalled()
|
||||
@@ -826,14 +820,12 @@ describe('NativeChatComposer', () => {
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model fable',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable'
|
||||
})
|
||||
expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce()
|
||||
@@ -866,7 +858,6 @@ describe('NativeChatComposer', () => {
|
||||
})
|
||||
|
||||
expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model',
|
||||
expect.any(AbortSignal)
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { forwardRef, useCallback, useState } from 'react'
|
||||
import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt'
|
||||
import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { useNativeChatMentionFiles } from './use-native-chat-mention-files'
|
||||
import { useNativeChatDraft } from './use-native-chat-draft'
|
||||
import { useNativeChatComposerRecall } from './use-native-chat-composer-recall'
|
||||
@@ -143,14 +142,13 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
structuredWorktreeId: structuredTransport?.worktreeId
|
||||
})
|
||||
|
||||
// Resolve the live ptyId for this chat leaf; runtime owner settings route
|
||||
// local vs remote (SSH) sends.
|
||||
// Resolve the live ptyId for this chat leaf; the id names its owning host.
|
||||
const resolveTarget = useCallback((): NativeChatResolvedTarget | null => {
|
||||
if (!targetPtyId) {
|
||||
return null
|
||||
}
|
||||
return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) }
|
||||
}, [targetPtyId, terminalTabId])
|
||||
return { ptyId: targetPtyId }
|
||||
}, [targetPtyId])
|
||||
|
||||
// Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame.
|
||||
const [hasPty, disabled] = structuredTransport
|
||||
|
||||
+6
-6
@@ -19,8 +19,8 @@ const MESSAGE = 'please also run lint'
|
||||
const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null }))
|
||||
const composer = vi.hoisted(() => ({ mounts: 0, typed: vi.fn<(text: string) => boolean>() }))
|
||||
const pty = vi.hoisted(() => ({
|
||||
verified: vi.fn<(settings: unknown, id: string, data: string) => Promise<boolean>>(),
|
||||
raw: vi.fn<(settings: unknown, id: string, data: string) => boolean>()
|
||||
verified: vi.fn<(id: string, data: string) => Promise<boolean>>(),
|
||||
raw: vi.fn<(id: string, data: string) => boolean>()
|
||||
}))
|
||||
vi.mock('./use-native-chat-retained-session', () => ({
|
||||
useNativeChatRetainedSession: () => retained.session
|
||||
@@ -68,7 +68,7 @@ vi.mock('./NativeChatComposer', async () => {
|
||||
)
|
||||
const send = (): void => {
|
||||
let pendingId: string | undefined
|
||||
const handle = sendNativeChatMessage(null, props.targetPtyId ?? '', MESSAGE, {
|
||||
const handle = sendNativeChatMessage(props.targetPtyId ?? '', MESSAGE, {
|
||||
onWriteRejected: () => {
|
||||
if (pendingId) {
|
||||
props.optimisticSendOutcome?.reject(pendingId)
|
||||
@@ -185,7 +185,7 @@ describe('a prompt card hides the composer without unmounting it', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('composer-send'))
|
||||
})
|
||||
expect(pty.verified.mock.calls.map((call) => call[2]).join('')).toContain(MESSAGE)
|
||||
expect(pty.verified.mock.calls.map((call) => call[1]).join('')).toContain(MESSAGE)
|
||||
|
||||
// The approval arrives inside the body-to-Enter gap.
|
||||
await act(async () => {
|
||||
@@ -197,9 +197,9 @@ describe('a prompt card hides the composer without unmounting it', () => {
|
||||
})
|
||||
|
||||
expect(document.querySelector('[data-native-chat-approval-card="true"]')).not.toBeNull()
|
||||
expect(pty.verified.mock.calls.map((call) => call[2])).not.toContain('\r')
|
||||
expect(pty.verified.mock.calls.map((call) => call[1])).not.toContain('\r')
|
||||
// Only the pre-body clear: retiring the send types nothing under the card.
|
||||
expect(pty.raw.mock.calls.map((call) => call[2])).toEqual(['\x15'])
|
||||
expect(pty.raw.mock.calls.map((call) => call[1])).toEqual(['\x15'])
|
||||
expect(screen.getByTestId('composer-send').closest('[hidden]')).not.toBeNull()
|
||||
expect(screen.getAllByText(MESSAGE).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Message not sent')).toBeInTheDocument()
|
||||
|
||||
@@ -122,7 +122,7 @@ export function NativeChatResolvedView({
|
||||
const canSend = useNativeChatCanSend(targetPtyId)
|
||||
// Reuse the verified composer send path for interactive cards and composer
|
||||
// stop (Stop sends ESC, the agent-TUI interrupt key).
|
||||
const send = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent)
|
||||
const send = useNativeChatInteractiveSend(paneKey, targetPtyId, agent)
|
||||
// Every send this pane makes brings the latest into view, wherever the reader had scrolled.
|
||||
const { messageListRef, revealLatest } = useNativeChatRevealLatest()
|
||||
const interactiveSend = useNativeChatInteractiveSendReveal(send, targetPtyId, revealLatest)
|
||||
|
||||
@@ -21,7 +21,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
const unsubscribe = vi.fn(() => {})
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
@@ -47,7 +46,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Opus (1M context)',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
@@ -69,7 +67,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Opus (1M context)',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
@@ -95,7 +92,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
const submitConfirmation = vi.fn()
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
@@ -121,7 +117,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Haiku',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
@@ -141,7 +136,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
it('reports unknown when the PTY observer cannot be established', async () => {
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => Promise.reject(new Error('unavailable')),
|
||||
timeoutMs: 100
|
||||
@@ -158,7 +152,6 @@ describe('Claude model switch confirmation detection', () => {
|
||||
let settled = false
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => vi.fn(() => {}),
|
||||
timeoutMs: 100
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions'
|
||||
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream'
|
||||
@@ -64,7 +63,6 @@ function hasClaudeModelSwitchRejection(buffer: string): boolean {
|
||||
|
||||
function subscribeToClaudeModelSwitchData(args: {
|
||||
ptyId: string
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
subscribeToData?: SubscribeToData
|
||||
watcher: (data: string) => void
|
||||
}): Promise<() => void> | (() => void) {
|
||||
@@ -73,7 +71,6 @@ function subscribeToClaudeModelSwitchData(args: {
|
||||
}
|
||||
if (isRemoteRuntimePtyId(args.ptyId)) {
|
||||
return subscribeToRuntimeTerminalData(
|
||||
args.settings,
|
||||
args.ptyId,
|
||||
`desktop:native-chat-model-switch:${args.ptyId}`,
|
||||
args.watcher,
|
||||
@@ -85,7 +82,6 @@ function subscribeToClaudeModelSwitchData(args: {
|
||||
|
||||
export function createClaudeModelSwitchConfirmationObserver(args: {
|
||||
ptyId: string
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
expectedModelLabel: string | null
|
||||
subscribeToData?: SubscribeToData
|
||||
submitConfirmation?: () => boolean | void
|
||||
@@ -147,7 +143,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: {
|
||||
// exact Claude warning defaults to “Yes” and needs only one Enter.
|
||||
const accepted = args.submitConfirmation
|
||||
? args.submitConfirmation() !== false
|
||||
: sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
: sendRuntimePtyInput(args.ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
if (!accepted) {
|
||||
finish('unknown')
|
||||
return
|
||||
@@ -162,7 +158,6 @@ export function createClaudeModelSwitchConfirmationObserver(args: {
|
||||
try {
|
||||
const subscription = subscribeToClaudeModelSwitchData({
|
||||
ptyId: args.ptyId,
|
||||
settings: args.settings,
|
||||
subscribeToData: args.subscribeToData,
|
||||
watcher: observeData
|
||||
})
|
||||
|
||||
@@ -33,9 +33,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
sendRuntimePtyInput: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({})
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
sendNativeChatTypedCommand: vi.fn(),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
|
||||
export type NativeChatResolvedTarget = {
|
||||
ptyId: string
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
}
|
||||
|
||||
/** Upper bound for clipboard text pulled into the composer via Cmd/Ctrl+V, so a
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Clearing the agent's unsubmitted input line before a chat send writes its body.
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends
|
||||
@@ -36,22 +35,11 @@ export type NativeChatSendOptions = {
|
||||
confirmCleared?: () => boolean
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
export function clearUnsubmittedAgentInput(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options?: NativeChatSendOptions
|
||||
): void {
|
||||
export function clearUnsubmittedAgentInput(ptyId: string, options?: NativeChatSendOptions): void {
|
||||
if (options?.clearInput === '') {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(
|
||||
settings,
|
||||
ptyId,
|
||||
options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
'driving'
|
||||
)
|
||||
sendRuntimePtyInput(ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, 'driving')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,13 +51,12 @@ export function clearUnsubmittedAgentInput(
|
||||
* the buffer holds, since the user can type into the TUI directly.
|
||||
*/
|
||||
export function clearThenWrite(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options: NativeChatSendOptions | undefined,
|
||||
delay: (ms: number, fn: () => void) => void,
|
||||
writeBody: () => void
|
||||
): void {
|
||||
clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
clearUnsubmittedAgentInput(ptyId, options)
|
||||
const confirmCleared = options?.confirmCleared
|
||||
if (!confirmCleared) {
|
||||
writeBody()
|
||||
@@ -83,7 +70,7 @@ export function clearThenWrite(
|
||||
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
|
||||
}
|
||||
if (!cleared) {
|
||||
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving')
|
||||
sendRuntimePtyInput(ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving')
|
||||
}
|
||||
writeBody()
|
||||
})
|
||||
|
||||
@@ -22,15 +22,15 @@ afterEach(() => {
|
||||
it('observes a refused write, skips Enter, and releases the queue for the next user action', async () => {
|
||||
const rejected = vi.fn()
|
||||
io.verified.mockResolvedValueOnce(false)
|
||||
sendNativeChatMessage(null, 'pane', 'refused', { onWriteRejected: rejected })
|
||||
sendNativeChatMessage('pane', 'refused', { onWriteRejected: rejected })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(rejected).toHaveBeenCalledOnce()
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
expect(io.verified.mock.calls.map((call) => call[1])).toEqual([
|
||||
buildNativeChatPasteBytes('refused')
|
||||
])
|
||||
sendNativeChatMessage(null, 'pane', 'next', { onWriteRejected: rejected })
|
||||
sendNativeChatMessage('pane', 'next', { onWriteRejected: rejected })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
expect(io.verified.mock.calls.map((call) => call[1])).toEqual([
|
||||
buildNativeChatPasteBytes('refused'),
|
||||
buildNativeChatPasteBytes('next'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
@@ -41,26 +41,26 @@ it('reports a lost acknowledgment once as unconfirmed, never as rejection, and s
|
||||
const unconfirmed = vi.fn()
|
||||
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
|
||||
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
|
||||
sendNativeChatMessage(null, 'pane', 'uncertain', {
|
||||
sendNativeChatMessage('pane', 'uncertain', {
|
||||
onWriteRejected: rejected,
|
||||
onWriteUnconfirmed: unconfirmed
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(120000)
|
||||
expect(rejected).not.toHaveBeenCalled()
|
||||
expect(unconfirmed).toHaveBeenCalledOnce()
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
expect(io.verified.mock.calls.map((call) => call[1])).toEqual([
|
||||
buildNativeChatPasteBytes('uncertain'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
])
|
||||
})
|
||||
it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => {
|
||||
const rejected = vi.fn()
|
||||
sendNativeChatMessage(null, 'pane', 'one', { onWriteRejected: rejected })
|
||||
sendNativeChatMessage(null, 'pane', 'two', { onWriteRejected: rejected })
|
||||
sendNativeChatMessage('pane', 'one', { onWriteRejected: rejected })
|
||||
sendNativeChatMessage('pane', 'two', { onWriteRejected: rejected })
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(io.verified).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
expect(io.verified.mock.calls.map((call) => call[1])).toEqual([
|
||||
buildNativeChatPasteBytes('one'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
buildNativeChatPasteBytes('two'),
|
||||
@@ -77,11 +77,11 @@ it.each(['accepted', 'refused', 'unknown'] as const)(
|
||||
} else if (outcome === 'unknown') {
|
||||
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
|
||||
}
|
||||
sendNativeChatMessage(null, 'pane', 'answer', { onDeliverySettled: settled })
|
||||
sendNativeChatMessage('pane', 'answer', { onDeliverySettled: settled })
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(settled).toHaveBeenCalledExactlyOnceWith(outcome === 'accepted')
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual(
|
||||
expect(io.verified.mock.calls.map((call) => call[1])).toEqual(
|
||||
outcome === 'refused'
|
||||
? [buildNativeChatPasteBytes('answer')]
|
||||
: [buildNativeChatPasteBytes('answer'), NATIVE_CHAT_SUBMIT]
|
||||
@@ -97,7 +97,7 @@ it('ignores a pasted answer acknowledgment after cancellation', async () => {
|
||||
})
|
||||
)
|
||||
const settled = vi.fn()
|
||||
const handle = sendNativeChatMessage(null, 'pane', 'answer', { onDeliverySettled: settled })
|
||||
const handle = sendNativeChatMessage('pane', 'answer', { onDeliverySettled: settled })
|
||||
handle.cancel()
|
||||
acknowledge(true)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue'
|
||||
import {
|
||||
clearConfirmDurationMs,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
|
||||
/** Observe write refusals without mistaking transport success for provider acceptance. */
|
||||
export function sendNativeChatObservedWrites(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
writes: readonly { data: string; delayBeforeMs: number }[],
|
||||
options: NativeChatSendOptions & { stopOnUnconfirmed?: boolean; settleDelayMs?: number }
|
||||
@@ -48,7 +46,7 @@ export function sendNativeChatObservedWrites(
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving', writeOptions)
|
||||
void sendRuntimePtyInputVerified(ptyId, write.data, 'driving', writeOptions)
|
||||
.then((accepted) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
@@ -86,8 +84,8 @@ export function sendNativeChatObservedWrites(
|
||||
send()
|
||||
}
|
||||
}
|
||||
clearThenWrite(settings, ptyId, options, delay, () => writeAt(0))
|
||||
clearThenWrite(ptyId, options, delay, () => writeAt(0))
|
||||
},
|
||||
{ onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) }
|
||||
{ onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
sendRuntimePtyInput: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({})
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
sendNativeChatTypedCommand: vi.fn(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { sendNativeChatObservedWrites } from './native-chat-observed-send'
|
||||
import { agentImagePasteWrites, formatAgentImagePath } from '../../../../shared/agent-image-paste'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat-answer-stepping'
|
||||
import {
|
||||
buildNativeChatImagePasteBytes,
|
||||
@@ -22,8 +21,6 @@ import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat-
|
||||
|
||||
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
/** The path text an agent detects as an image. One with spaces or shell characters is escaped as a
|
||||
* terminal image drop is, which agent TUIs undo before checking the file exists. */
|
||||
export function agentImagePastePath(agent: AgentType, path: string): string {
|
||||
@@ -37,14 +34,13 @@ export function agentImagePastePath(agent: AgentType, path: string): string {
|
||||
|
||||
export function sendNativeChatMessageWithImageAttachments(
|
||||
agent: AgentType,
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
imagePaths: readonly string[],
|
||||
options?: NativeChatSendOptions
|
||||
): NativeChatSendHandle {
|
||||
if (imagePaths.length === 0) {
|
||||
return sendNativeChatMessage(settings, ptyId, text, options)
|
||||
return sendNativeChatMessage(ptyId, text, options)
|
||||
}
|
||||
const trimmedText = text.trim()
|
||||
if (options?.onWriteRejected) {
|
||||
@@ -60,7 +56,7 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
})
|
||||
}
|
||||
writes.push({ data: NATIVE_CHAT_SUBMIT, delayBeforeMs: NATIVE_CHAT_SUBMIT_DELAY_MS })
|
||||
return sendNativeChatObservedWrites(settings, ptyId, writes, options)
|
||||
return sendNativeChatObservedWrites(ptyId, writes, options)
|
||||
}
|
||||
const durationMs =
|
||||
(trimmedText.length > 0
|
||||
@@ -73,7 +69,7 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
clearThenWrite(ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
@@ -84,26 +80,26 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
),
|
||||
trimmedText.length > 0
|
||||
)) {
|
||||
sendRuntimePtyInput(settings, ptyId, payload, 'driving')
|
||||
sendRuntimePtyInput(ptyId, payload, 'driving')
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving')
|
||||
sendRuntimePtyInput(ptyId, buildNativeChatPasteBytes(text), 'driving')
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+16
-17
@@ -29,11 +29,10 @@ import {
|
||||
buildAgentTuiClearInputForText
|
||||
} from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
|
||||
const PTY = 'pty-launch-draft'
|
||||
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string)
|
||||
const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => String(call[1]))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -49,19 +48,19 @@ afterEach(() => {
|
||||
describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput })
|
||||
sendNativeChatMessage(PTY, 'edited text', { clearInput })
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')])
|
||||
expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('still defaults to a single Ctrl+U when no draft is parked', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'plain')
|
||||
sendNativeChatMessage(PTY, 'plain')
|
||||
expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('holds the body until the clear is confirmed, then submits after the gap', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
sendNativeChatMessage(PTY, 'edited', {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
@@ -76,15 +75,15 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => {
|
||||
vi.useRealTimers()
|
||||
const writeTimes = new Map<string, number>()
|
||||
sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => {
|
||||
sendRuntimePtyInput.mockImplementation((_pty, bytes: string) => {
|
||||
writeTimes.set(bytes, performance.now())
|
||||
return true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
sendNativeChatMessage(PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'queued')
|
||||
sendNativeChatMessage(PTY, 'queued')
|
||||
|
||||
const blockedUntil =
|
||||
performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50
|
||||
@@ -106,7 +105,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
|
||||
it('widens to a maximal burst when the draft is still observed on the line', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
sendNativeChatMessage(PTY, 'edited', {
|
||||
clearInput,
|
||||
confirmCleared: () => false
|
||||
})
|
||||
@@ -119,7 +118,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
})
|
||||
|
||||
it('re-clears before the body, never after it', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
sendNativeChatMessage(PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => false
|
||||
})
|
||||
@@ -131,7 +130,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
})
|
||||
|
||||
it('charges the confirm gap to the handle so the send card outlives the Enter', () => {
|
||||
const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', {
|
||||
const withConfirm = sendNativeChatMessage(PTY, 'a', {
|
||||
clearInput: '\x15',
|
||||
confirmCleared: () => true
|
||||
})
|
||||
@@ -142,11 +141,11 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
|
||||
it('submits before a queued send starts after clear confirmation', async () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first', {
|
||||
sendNativeChatMessage(PTY, 'first', {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'second')
|
||||
sendNativeChatMessage(PTY, 'second')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
@@ -163,7 +162,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
describe('image sends with a parked multi-line draft', () => {
|
||||
it('clears every draft line before pasting, so no line rides along with the image', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
sendNativeChatMessageWithImageAttachments('claude', PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput
|
||||
})
|
||||
expect(writes()[0]).toBe(clearInput)
|
||||
@@ -171,7 +170,7 @@ describe('image sends with a parked multi-line draft', () => {
|
||||
|
||||
it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
sendNativeChatMessageWithImageAttachments('claude', PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput
|
||||
})
|
||||
vi.advanceTimersByTime(10_000)
|
||||
@@ -180,11 +179,11 @@ describe('image sends with a parked multi-line draft', () => {
|
||||
|
||||
it('submits the image send before a queued message starts', async () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
sendNativeChatMessageWithImageAttachments('claude', PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'second')
|
||||
sendNativeChatMessage(PTY, 'second')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
NATIVE_CHAT_CLEAR_CONFIRM_MS +
|
||||
|
||||
@@ -28,11 +28,10 @@ import {
|
||||
} from './native-chat-runtime-image-send'
|
||||
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
|
||||
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
|
||||
const PTY = 'pty-1'
|
||||
|
||||
function expectWriteOrder(calls: unknown[][], expected: string[]): void {
|
||||
expect(calls.map((call) => call[2])).toEqual(expected)
|
||||
expect(calls.map((call) => call[1])).toEqual(expected)
|
||||
}
|
||||
|
||||
describe('sendNativeChatMessage', () => {
|
||||
@@ -48,7 +47,7 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('clears the TUI line, then writes the framed body, before the Enter', () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world')
|
||||
const handle = sendNativeChatMessage(PTY, 'hello world')
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('hello world')
|
||||
@@ -57,7 +56,7 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
sendNativeChatMessage(PTY, 'hi')
|
||||
// A short gap would fire Enter while a busy Codex has not yet landed the
|
||||
// paste, submitting an empty box — so nothing must happen before 500ms.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
|
||||
@@ -65,7 +64,7 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('writes the bare carriage-return Enter as a separate delayed write', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
sendNativeChatMessage(PTY, 'hi')
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
@@ -75,7 +74,7 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('cancels the delayed Enter and re-clears an unsubmitted body', () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
const handle = sendNativeChatMessage(PTY, 'hi')
|
||||
handle.cancel()
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
@@ -88,15 +87,15 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('clears leftover unsubmitted body on cancel so the next send cannot glue', async () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke')
|
||||
const handle = sendNativeChatMessage(PTY, 'tell me a joke')
|
||||
handle.cancel()
|
||||
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'continue')
|
||||
sendNativeChatMessage(PTY, 'continue')
|
||||
// Queue release after cancel is promise-chained; flush so the next body runs.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRuntimePtyInput.mock.calls.map((call) => call[2])).toEqual([
|
||||
expect(sendRuntimePtyInput.mock.calls.map((call) => call[1])).toEqual([
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('tell me a joke'),
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // cancel cleanup
|
||||
@@ -106,7 +105,7 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('does not clear the TUI input when cancel runs after Enter already fired', () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'already submitted')
|
||||
const handle = sendNativeChatMessage(PTY, 'already submitted')
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
sendRuntimePtyInput.mockClear()
|
||||
handle.cancel()
|
||||
@@ -115,8 +114,8 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('serializes rapid sends on the same PTY so bodies cannot glue before Enter', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke')
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'continue')
|
||||
sendNativeChatMessage(PTY, 'tell me a joke')
|
||||
sendNativeChatMessage(PTY, 'continue')
|
||||
|
||||
// First clear+body are immediate; second sequence waits for the first Enter.
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
@@ -134,19 +133,14 @@ describe('sendNativeChatMessage', () => {
|
||||
])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not let a canceled queued send stall the sends behind it', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first')
|
||||
const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled')
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'third')
|
||||
sendNativeChatMessage(PTY, 'first')
|
||||
const canceled = sendNativeChatMessage(PTY, 'canceled')
|
||||
sendNativeChatMessage(PTY, 'third')
|
||||
canceled.cancel()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
@@ -161,16 +155,16 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
|
||||
it('does not serialize sends across different PTYs', () => {
|
||||
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
|
||||
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
|
||||
sendNativeChatMessage('pty-a', 'one')
|
||||
sendNativeChatMessage('pty-b', 'two')
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('one'),
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('two')
|
||||
])
|
||||
expect(sendRuntimePtyInput.mock.calls[1]?.[1]).toBe('pty-a')
|
||||
expect(sendRuntimePtyInput.mock.calls[3]?.[1]).toBe('pty-b')
|
||||
expect(sendRuntimePtyInput.mock.calls[1]?.[0]).toBe('pty-a')
|
||||
expect(sendRuntimePtyInput.mock.calls[3]?.[0]).toBe('pty-b')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,12 +182,11 @@ describe('sendNativeChatMessageVerified', () => {
|
||||
it('awaits body acceptance before the delayed Enter write (no pre-clear)', async () => {
|
||||
// Why: model-switch confirmation watches the PTY while this send runs;
|
||||
// verified option commands must not inject Ctrl+U noise.
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')
|
||||
const result = sendNativeChatMessageVerified(PTY, '/model sonnet')
|
||||
await vi.waitFor(() => {
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('/model sonnet'),
|
||||
'driving'
|
||||
@@ -202,15 +195,10 @@ describe('sendNativeChatMessageVerified', () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(await result).toBe(true)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
expect(
|
||||
sendRuntimePtyInputVerified.mock.calls.some(
|
||||
(call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
(call) => call[1] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -218,40 +206,35 @@ describe('sendNativeChatMessageVerified', () => {
|
||||
it('does not send Enter when the body is rejected', async () => {
|
||||
sendRuntimePtyInputVerified.mockResolvedValueOnce(false)
|
||||
|
||||
await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false)
|
||||
await expect(sendNativeChatMessageVerified(PTY, '/model sonnet')).resolves.toBe(false)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)
|
||||
sendRuntimePtyInputVerified.mock.calls.some((call) => call[1] === NATIVE_CHAT_SUBMIT)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels an in-flight chat Enter before delivering a verified option command', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hello')
|
||||
sendNativeChatMessage(PTY, 'hello')
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalled()
|
||||
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model haiku')
|
||||
const result = sendNativeChatMessageVerified(PTY, '/model haiku')
|
||||
// Chat cancel may Ctrl+U the unsubmitted body; Enter from chat must not fire.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(await result).toBe(true)
|
||||
const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT)
|
||||
const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[1] === NATIVE_CHAT_SUBMIT)
|
||||
// Only the verified path's Enter — chat's delayed Enter was cancelled.
|
||||
expect(submits).toHaveLength(0)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
})
|
||||
|
||||
it('returns false when the delayed Enter wait is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal)
|
||||
const result = sendNativeChatMessageVerified(PTY, '/model sonnet', controller.signal)
|
||||
await vi.waitFor(() => {
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -259,7 +242,7 @@ describe('sendNativeChatMessageVerified', () => {
|
||||
|
||||
expect(await result).toBe(false)
|
||||
expect(
|
||||
sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)
|
||||
sendRuntimePtyInputVerified.mock.calls.some((call) => call[1] === NATIVE_CHAT_SUBMIT)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -277,7 +260,7 @@ describe('typeNativeChatCommand', () => {
|
||||
})
|
||||
|
||||
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
|
||||
const result = typeNativeChatCommand(SETTINGS, PTY, '/model')
|
||||
const result = typeNativeChatCommand(PTY, '/model')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
@@ -294,7 +277,7 @@ describe('typeNativeChatCommand', () => {
|
||||
})
|
||||
|
||||
it('queues composer commands as the same paced key sequence', async () => {
|
||||
const handle = sendNativeChatTypedCommand(SETTINGS, PTY, '/status')
|
||||
const handle = sendNativeChatTypedCommand(PTY, '/status')
|
||||
await vi.runAllTimersAsync()
|
||||
await handle.settled
|
||||
|
||||
@@ -312,9 +295,9 @@ describe('typeNativeChatCommand', () => {
|
||||
})
|
||||
|
||||
it('does not clear into the next queued send after cancellation', async () => {
|
||||
const command = sendNativeChatTypedCommand(SETTINGS, PTY, '/status')
|
||||
const command = sendNativeChatTypedCommand(PTY, '/status')
|
||||
command.cancel()
|
||||
const next = sendNativeChatMessage(SETTINGS, PTY, 'next')
|
||||
const next = sendNativeChatMessage(PTY, 'next')
|
||||
await vi.runAllTimersAsync()
|
||||
await Promise.all([command.settled, next.settled])
|
||||
|
||||
@@ -339,7 +322,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('escapes a pasted image path with spaces, so an attachment agent sees one path', () => {
|
||||
sendNativeChatMessageWithImageAttachments('codex', SETTINGS, PTY, '', [
|
||||
sendNativeChatMessageWithImageAttachments('codex', PTY, '', [
|
||||
'/Users/me/Library/Application Support/orca/native-chat-pastes/orca-paste-1-ab.png'
|
||||
])
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
@@ -349,10 +332,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
})
|
||||
|
||||
it.each(['', 'describe'])('separates every OMP image reference before %j', (text) => {
|
||||
sendNativeChatMessageWithImageAttachments('omp', SETTINGS, PTY, text, [
|
||||
'/tmp/a.png',
|
||||
'/tmp/b.png'
|
||||
])
|
||||
sendNativeChatMessageWithImageAttachments('omp', PTY, text, ['/tmp/a.png', '/tmp/b.png'])
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
'\x1b[200~@/tmp/a.png\x1b[201~ ',
|
||||
@@ -361,7 +341,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('sends OMP image paths as framed references, preserving spaces and delayed submit', () => {
|
||||
sendNativeChatMessageWithImageAttachments('omp', SETTINGS, PTY, 'describe', [
|
||||
sendNativeChatMessageWithImageAttachments('omp', PTY, 'describe', [
|
||||
'C:\\Images\\screen shot.png'
|
||||
])
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
@@ -369,24 +349,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
'\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ '
|
||||
])
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving')
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, 'describe', 'driving')
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
})
|
||||
|
||||
it('clears the line, then bracket-pastes image paths before prompt text', () => {
|
||||
const handle = sendNativeChatMessageWithImageAttachments(
|
||||
'claude',
|
||||
SETTINGS,
|
||||
PTY,
|
||||
'what do you see?',
|
||||
['/tmp/orca-paste-image.png']
|
||||
)
|
||||
const handle = sendNativeChatMessageWithImageAttachments('claude', PTY, 'what do you see?', [
|
||||
'/tmp/orca-paste-image.png'
|
||||
])
|
||||
|
||||
expect(handle.settleAfterMs).toBe(
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
@@ -399,25 +370,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
])
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
'what do you see?',
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, 'what do you see?', 'driving')
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('does not append a trailing separator on an attachment-only send', () => {
|
||||
const handle = sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, '', [
|
||||
const handle = sendNativeChatMessageWithImageAttachments('claude', PTY, '', [
|
||||
'/tmp/orca-paste-image.png'
|
||||
])
|
||||
|
||||
@@ -433,18 +394,11 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
'driving'
|
||||
)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
})
|
||||
|
||||
it('treats whitespace-only prompt input as attachment-only', () => {
|
||||
sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, ' ', [
|
||||
'/tmp/orca-paste-image.png'
|
||||
])
|
||||
sendNativeChatMessageWithImageAttachments('claude', PTY, ' ', ['/tmp/orca-paste-image.png'])
|
||||
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
@@ -453,10 +407,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('keeps multiple image frames bare and separates only the final frame from prompt text', () => {
|
||||
sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'hello', [
|
||||
'/tmp/a.png',
|
||||
'/tmp/b.png'
|
||||
])
|
||||
sendNativeChatMessageWithImageAttachments('claude', PTY, 'hello', ['/tmp/a.png', '/tmp/b.png'])
|
||||
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
@@ -466,7 +417,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('cancels deferred prompt and Enter writes after the attachment path', () => {
|
||||
const handle = sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'describe', [
|
||||
const handle = sendNativeChatMessageWithImageAttachments('claude', PTY, 'describe', [
|
||||
'/tmp/orca-paste-image.png'
|
||||
])
|
||||
handle.cancel()
|
||||
@@ -478,7 +429,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
'\x1b[200~/tmp/orca-paste-image.png\x1b[201~ ',
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
])
|
||||
expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe(
|
||||
expect(sendRuntimePtyInput.mock.calls.some((call) => call[1] === NATIVE_CHAT_SUBMIT)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
@@ -490,9 +441,9 @@ describe('empty prompt submit', () => {
|
||||
})
|
||||
|
||||
it('submits an empty prompt with a bare Enter', () => {
|
||||
submitNativeChatPrompt(SETTINGS, PTY)
|
||||
submitNativeChatPrompt(PTY)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledOnce()
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -510,14 +461,14 @@ describe('sendNativeChatAskAnswer', () => {
|
||||
})
|
||||
|
||||
it('returns a no-op handle for an empty key group list', () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [])
|
||||
const handle = sendNativeChatAskAnswer(PTY, [])
|
||||
expect(handle.settleAfterMs).toBe(0)
|
||||
handle.cancel()
|
||||
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('paces key groups so selector steps render before the next write', async () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [
|
||||
const handle = sendNativeChatAskAnswer(PTY, [
|
||||
{ raw: '1' },
|
||||
{ raw: '2' },
|
||||
{ text: 'custom answer' }
|
||||
@@ -527,26 +478,13 @@ describe('sendNativeChatAskAnswer', () => {
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
'1',
|
||||
'driving',
|
||||
undefined
|
||||
)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, '1', 'driving', undefined)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
'2',
|
||||
'driving',
|
||||
undefined
|
||||
)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, '2', 'driving', undefined)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('custom answer'),
|
||||
'driving',
|
||||
@@ -555,7 +493,7 @@ describe('sendNativeChatAskAnswer', () => {
|
||||
})
|
||||
|
||||
it('cancels remaining key group timers', async () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '2' }])
|
||||
const handle = sendNativeChatAskAnswer(PTY, [{ raw: '1' }, { raw: '2' }])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
handle.cancel()
|
||||
@@ -566,14 +504,14 @@ describe('sendNativeChatAskAnswer', () => {
|
||||
it('reports verified delivery only after settling and suppresses it after cancellation', async () => {
|
||||
const onSettled = vi.fn()
|
||||
sendRuntimePtyInputVerified.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '\r' }], onSettled)
|
||||
const handle = sendNativeChatAskAnswer(PTY, [{ raw: '1' }, { raw: '\r' }], onSettled)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(handle.settleAfterMs)
|
||||
expect(onSettled).toHaveBeenCalledExactlyOnceWith(false)
|
||||
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
|
||||
const canceledSettled = vi.fn()
|
||||
const canceled = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }], canceledSettled)
|
||||
const canceled = sendNativeChatAskAnswer(PTY, [{ raw: '1' }], canceledSettled)
|
||||
canceled.cancel()
|
||||
await vi.runAllTimersAsync()
|
||||
expect(canceledSettled).not.toHaveBeenCalled()
|
||||
@@ -588,10 +526,10 @@ describe('sendNativeChatAskAnswer', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled)
|
||||
const handle = sendNativeChatAskAnswer(PTY, [{ raw: '2' }], onSettled)
|
||||
await vi.advanceTimersByTimeAsync(handle.settleAfterMs)
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving', {
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, '2', 'driving', {
|
||||
requireWriteSettlement: true
|
||||
})
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
sendRuntimePtyInput,
|
||||
sendRuntimePtyInputVerified
|
||||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt'
|
||||
import {
|
||||
clearConfirmDurationMs,
|
||||
@@ -45,8 +44,6 @@ export type NativeChatSendHandle = {
|
||||
settled?: Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
/**
|
||||
* Chat message path:
|
||||
* 1. clear any unsubmitted TUI line
|
||||
@@ -56,14 +53,12 @@ type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
* Serialized per PTY so rapid sends cannot glue before Enter.
|
||||
*/
|
||||
export function sendNativeChatMessage(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
options?: NativeChatSendOptions
|
||||
): NativeChatSendHandle {
|
||||
if (options?.onWriteRejected || options?.onDeliverySettled) {
|
||||
return sendNativeChatObservedWrites(
|
||||
settings,
|
||||
ptyId,
|
||||
[
|
||||
{ data: buildNativeChatPasteBytes(text), delayBeforeMs: 0 },
|
||||
@@ -79,21 +74,21 @@ export function sendNativeChatMessage(
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
clearThenWrite(ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving')
|
||||
sendRuntimePtyInput(ptyId, buildNativeChatPasteBytes(text), 'driving')
|
||||
// Schedule from the actual body write: an overdue clear-confirm callback
|
||||
// must not collapse the required body-to-Enter gap after a renderer stall.
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -127,7 +122,6 @@ function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
|
||||
* chat Enter cannot dismiss Claude's "Switch model?" dialog.
|
||||
*/
|
||||
export async function sendNativeChatMessageVerified(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
signal?: AbortSignal
|
||||
@@ -144,7 +138,6 @@ export async function sendNativeChatMessageVerified(
|
||||
// Why: option commands await remote/SSH acceptance so the Enter cannot race
|
||||
// ahead of the body while a model-change observer is already armed.
|
||||
const bodyAccepted = await sendRuntimePtyInputVerified(
|
||||
settings,
|
||||
ptyId,
|
||||
buildNativeChatPasteBytes(text),
|
||||
'driving'
|
||||
@@ -152,12 +145,11 @@ export async function sendNativeChatMessageVerified(
|
||||
if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) {
|
||||
return false
|
||||
}
|
||||
return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
return sendRuntimePtyInputVerified(ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
}
|
||||
|
||||
/** Types a slash command as individual keys so Codex opens its command palette. */
|
||||
export async function typeNativeChatCommand(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
command: string,
|
||||
signal?: AbortSignal
|
||||
@@ -168,17 +160,13 @@ export async function typeNativeChatCommand(
|
||||
command,
|
||||
signal,
|
||||
write: async (key) =>
|
||||
(await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected'
|
||||
(await sendRuntimePtyInputVerified(ptyId, key, 'driving')) ? 'accepted' : 'rejected'
|
||||
})
|
||||
return outcome === 'accepted'
|
||||
}
|
||||
|
||||
/** Queues a typed slash command with composer sends on the same PTY. */
|
||||
export function sendNativeChatTypedCommand(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
command: string
|
||||
): NativeChatSendHandle {
|
||||
export function sendNativeChatTypedCommand(ptyId: string, command: string): NativeChatSendHandle {
|
||||
const controller = new AbortController()
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
@@ -186,7 +174,7 @@ export function sendNativeChatTypedCommand(
|
||||
({ isCancelled, markSubmitted }) => {
|
||||
const finish = (outcome: 'accepted' | 'rejected' | 'unknown'): void => {
|
||||
if (!isCancelled() && outcome !== 'accepted') {
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
clearUnsubmittedAgentInput(ptyId)
|
||||
}
|
||||
markSubmitted()
|
||||
}
|
||||
@@ -197,7 +185,7 @@ export function sendNativeChatTypedCommand(
|
||||
if (isCancelled()) {
|
||||
return 'rejected'
|
||||
}
|
||||
return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving'))
|
||||
return (await sendRuntimePtyInputVerified(ptyId, key, 'driving'))
|
||||
? 'accepted'
|
||||
: 'rejected'
|
||||
}
|
||||
@@ -206,7 +194,7 @@ export function sendNativeChatTypedCommand(
|
||||
{
|
||||
onCancelUnsubmitted: () => {
|
||||
controller.abort()
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
clearUnsubmittedAgentInput(ptyId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -214,8 +202,8 @@ export function sendNativeChatTypedCommand(
|
||||
|
||||
/** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the
|
||||
* composer is empty. */
|
||||
export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
export function submitNativeChatPrompt(ptyId: string): void {
|
||||
sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,7 +212,6 @@ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string)
|
||||
* step so the arrow-navigate selector applies each before the next.
|
||||
*/
|
||||
export function sendNativeChatAskAnswer(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
groups: AskAnswerKeyGroup[],
|
||||
onSettled?: (delivered: boolean) => void
|
||||
@@ -233,7 +220,6 @@ export function sendNativeChatAskAnswer(
|
||||
return { cancel: () => {}, settleAfterMs: 0 }
|
||||
}
|
||||
return sendNativeChatObservedWrites(
|
||||
settings,
|
||||
ptyId,
|
||||
groups.map((group, index) => ({
|
||||
data: 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text),
|
||||
|
||||
-3
@@ -38,9 +38,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
sendRuntimePtyInput: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({})
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
sendNativeChatTypedCommand: vi.fn(),
|
||||
|
||||
+1
-2
@@ -26,8 +26,7 @@ type AttachmentApi = ReturnType<typeof useNativeChatComposerAttachments>
|
||||
type ProbeApi = AttachmentApi & { adoptDraft: (draft: string) => void }
|
||||
|
||||
const target: NativeChatResolvedTarget = {
|
||||
ptyId: 'pty-1',
|
||||
settings: { activeRuntimeEnvironmentId: null }
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
|
||||
function Probe({
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useNativeChatComposerInterrupt(args: {
|
||||
}
|
||||
const target = resolveTarget()
|
||||
if (target) {
|
||||
sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving')
|
||||
sendRuntimePtyInput(target.ptyId, ESC, 'driving')
|
||||
}
|
||||
}, [inert, cancelPendingSends, isWorking, onStop, resolveTarget])
|
||||
}
|
||||
|
||||
@@ -40,10 +40,6 @@ vi.mock('@/runtime/runtime-terminal-verified-input', () => ({
|
||||
sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: (terminalTabId: string) => ({ terminalTabId })
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatAskAnswer: (...args: unknown[]) => mocks.sendNativeChatAskAnswer(...args),
|
||||
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args)
|
||||
@@ -70,33 +66,25 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
})
|
||||
|
||||
it('routes a non-selector answer through the pasted-text send path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'grok'))
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
// Grok commits a pasted answer: label text 'B', not option-number keystrokes.
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
'B',
|
||||
{ onDeliverySettled: expect.any(Function) }
|
||||
)
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith('pty-1', 'B', {
|
||||
onDeliverySettled: expect.any(Function)
|
||||
})
|
||||
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a Codex answer through the option-number keystroke path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'codex'))
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
// Codex's request_user_input card ignores typed labels (STA-1860 shape):
|
||||
// the 2nd option is delivered as its digit '2', which selects AND commits.
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
[{ raw: '2' }],
|
||||
expect.any(Function)
|
||||
@@ -105,15 +93,12 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
})
|
||||
|
||||
it('routes a Claude answer through the option-number keystroke path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
// The 2nd option is delivered as its number '2', not the label 'B' (STA-1860).
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
[{ raw: '2' }],
|
||||
expect.any(Function)
|
||||
@@ -123,21 +108,19 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
|
||||
it('infers OpenClaude answers through its Claude-compatible selector path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'openclaude')
|
||||
useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'openclaude')
|
||||
)
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2]
|
||||
expect(onSettled).toBeTypeOf('function')
|
||||
onSettled?.(true)
|
||||
expect(mocks.inferQuestionAnswered).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does nothing when no option is answered', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
let resultValue: ReturnType<typeof result.current.sendAnswer> | undefined
|
||||
act(() => {
|
||||
@@ -151,7 +134,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
|
||||
it('cancels delayed answer writes when the PTY target changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, targetPtyId, 'codex'),
|
||||
({ targetPtyId }) => useNativeChatInteractiveSend(PANE_KEY, targetPtyId, 'codex'),
|
||||
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
|
||||
)
|
||||
|
||||
@@ -163,7 +146,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
|
||||
it('cancels delayed answer writes when the pane identity changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ paneKey }) => useNativeChatInteractiveSend('tab-1', paneKey, 'pty-1', 'claude'),
|
||||
({ paneKey }) => useNativeChatInteractiveSend(paneKey, 'pty-1', 'claude'),
|
||||
{ initialProps: { paneKey: PANE_KEY } }
|
||||
)
|
||||
|
||||
@@ -174,35 +157,27 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
})
|
||||
|
||||
it('cancels delayed answer writes before interrupting the active PTY', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
act(() => result.current.cancel())
|
||||
|
||||
expect(mocks.cancel).toHaveBeenCalledOnce()
|
||||
expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
'\x1b',
|
||||
'driving'
|
||||
)
|
||||
expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith('pty-1', '\x1b', 'driving')
|
||||
})
|
||||
|
||||
it.each(['opencode', 'opencode2'] as const)(
|
||||
'paces two Escape writes for %s Stop and cancels them on rebind',
|
||||
(agent) => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ ptyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, ptyId, agent),
|
||||
({ ptyId }) => useNativeChatInteractiveSend(PANE_KEY, ptyId, agent),
|
||||
{ initialProps: { ptyId: 'pty-1' } }
|
||||
)
|
||||
act(() => result.current.cancel())
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
[{ raw: '\x1b' }, { raw: '\x1b' }]
|
||||
)
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith('pty-1', [
|
||||
{ raw: '\x1b' },
|
||||
{ raw: '\x1b' }
|
||||
])
|
||||
rerender({ ptyId: 'pty-2' })
|
||||
expect(mocks.cancel).toHaveBeenCalledOnce()
|
||||
}
|
||||
@@ -211,12 +186,9 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
it.each(['opencode', 'opencode2'] as const)(
|
||||
'rejects a %s question with one acknowledged Escape and no delayed Stop',
|
||||
async (agent) => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent)
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', agent))
|
||||
await expect(result.current.cancelAsk()).resolves.toBe(true)
|
||||
expect(mocks.sendRuntimePtyInputVerified).toHaveBeenCalledExactlyOnceWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
'\x1b',
|
||||
'driving',
|
||||
@@ -229,21 +201,16 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
|
||||
it('reports an Escape whose delivery is unknown as not delivered', async () => {
|
||||
mocks.sendRuntimePtyInputVerified.mockRejectedValueOnce(new Error('timeout'))
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
await expect(result.current.cancelAsk()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it.each(['opencode', 'opencode2'] as const)(
|
||||
'delivers a non-default %s answer through selector keys',
|
||||
(agent) => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent)
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', agent))
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
[{ raw: '2' }],
|
||||
expect.any(Function)
|
||||
@@ -253,9 +220,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
)
|
||||
|
||||
it('can cancel delayed writes without interrupting the replacement prompt', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
act(() => result.current.cancelPending())
|
||||
@@ -265,14 +230,12 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
})
|
||||
|
||||
it('infers a Claude question answer only after every runtime write was delivered', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled()
|
||||
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2]
|
||||
expect(onSettled).toBeTypeOf('function')
|
||||
onSettled?.(false)
|
||||
expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled()
|
||||
@@ -288,9 +251,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
})
|
||||
|
||||
it('infers the answered question baseline, not a replacement that became current mid-send', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
// Answer question A while it is the current waiting question.
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
@@ -307,7 +268,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2]
|
||||
onSettled?.(true)
|
||||
|
||||
// The baseline is question A's (captured before delivery), so the server can
|
||||
@@ -323,9 +284,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
|
||||
it('reports verified delivery settlement to the question card', () => {
|
||||
const onDeliverySettled = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude'))
|
||||
|
||||
let sendResult: ReturnType<typeof result.current.sendAnswer> | undefined
|
||||
act(() => {
|
||||
@@ -333,7 +292,7 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
})
|
||||
expect(sendResult).toEqual({ settleAfterMs: 500 })
|
||||
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
|
||||
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2]
|
||||
onSettled?.(false)
|
||||
expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(false)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import type { AgentType } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
resolveNativeChatTranscriptAgent,
|
||||
@@ -57,7 +56,6 @@ export type NativeChatInteractiveSend = {
|
||||
* `sendNativeChatMessage`. Control strings (option digits, ESC) are written raw.
|
||||
*/
|
||||
export function useNativeChatInteractiveSend(
|
||||
terminalTabId: string,
|
||||
paneKey: string,
|
||||
targetPtyId: string | null,
|
||||
agent: AgentType
|
||||
@@ -72,38 +70,26 @@ export function useNativeChatInteractiveSend(
|
||||
}, [])
|
||||
// Why: a split can be rebound without unmounting this view. Cancel during
|
||||
// commit so no delayed answer write can race the replacement PTY.
|
||||
useLayoutEffect(
|
||||
() => cancelInFlight,
|
||||
[agent, cancelInFlight, paneKey, targetPtyId, terminalTabId]
|
||||
)
|
||||
useLayoutEffect(() => cancelInFlight, [agent, cancelInFlight, paneKey, targetPtyId])
|
||||
|
||||
const sendRaw = useCallback(
|
||||
(raw: string) => {
|
||||
if (!targetPtyId) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(
|
||||
getSettingsForAgentTabRuntimeOwner(terminalTabId),
|
||||
targetPtyId,
|
||||
raw,
|
||||
'driving'
|
||||
)
|
||||
sendRuntimePtyInput(targetPtyId, raw, 'driving')
|
||||
},
|
||||
[terminalTabId, targetPtyId]
|
||||
[targetPtyId]
|
||||
)
|
||||
|
||||
const sendRawVerified = useCallback(
|
||||
(raw: string): Promise<boolean> =>
|
||||
targetPtyId
|
||||
? sendRuntimePtyInputVerified(
|
||||
getSettingsForAgentTabRuntimeOwner(terminalTabId),
|
||||
targetPtyId,
|
||||
raw,
|
||||
'driving',
|
||||
{ requireWriteSettlement: true }
|
||||
).catch(() => false)
|
||||
? sendRuntimePtyInputVerified(targetPtyId, raw, 'driving', {
|
||||
requireWriteSettlement: true
|
||||
}).catch(() => false)
|
||||
: Promise.resolve(false),
|
||||
[terminalTabId, targetPtyId]
|
||||
[targetPtyId]
|
||||
)
|
||||
|
||||
const sendAnswer = useCallback(
|
||||
@@ -117,7 +103,6 @@ export function useNativeChatInteractiveSend(
|
||||
}
|
||||
// Cancel any prior in-flight answer before starting a new one.
|
||||
cancelInFlight()
|
||||
const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId)
|
||||
// Selector TUIs ignore pasted labels; Codex uses a different key sequence.
|
||||
const stepsAnswer = shouldStepNativeChatAskAnswer(agent)
|
||||
const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex'
|
||||
@@ -152,14 +137,13 @@ export function useNativeChatInteractiveSend(
|
||||
}
|
||||
const handle: NativeChatSendHandle = stepsAnswer
|
||||
? sendNativeChatAskAnswer(
|
||||
settings,
|
||||
targetPtyId,
|
||||
buildsCodexAnswer
|
||||
? buildCodexAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections),
|
||||
onSettled
|
||||
)
|
||||
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections), {
|
||||
: sendNativeChatMessage(targetPtyId, formatAskAnswer(prompt, selections), {
|
||||
onDeliverySettled: onSettled
|
||||
})
|
||||
// Why: native-chat answer writes bypass xterm.onData. Infer only after
|
||||
@@ -171,7 +155,7 @@ export function useNativeChatInteractiveSend(
|
||||
settleAfterMs: handle.settleAfterMs
|
||||
}
|
||||
},
|
||||
[terminalTabId, paneKey, targetPtyId, agent, cancelInFlight]
|
||||
[paneKey, targetPtyId, agent, cancelInFlight]
|
||||
)
|
||||
|
||||
const cancelAsk = useCallback(() => {
|
||||
@@ -183,15 +167,11 @@ export function useNativeChatInteractiveSend(
|
||||
cancelInFlight()
|
||||
if (resolveNativeChatTranscriptAgent(agent) === 'opencode' && targetPtyId) {
|
||||
// OpenCode confirms interruption with a second Escape; pace writes like mobile Stop.
|
||||
inFlightRef.current = sendNativeChatAskAnswer(
|
||||
getSettingsForAgentTabRuntimeOwner(terminalTabId),
|
||||
targetPtyId,
|
||||
[{ raw: ESC }, { raw: ESC }]
|
||||
)
|
||||
inFlightRef.current = sendNativeChatAskAnswer(targetPtyId, [{ raw: ESC }, { raw: ESC }])
|
||||
return
|
||||
}
|
||||
sendRaw(ESC)
|
||||
}, [agent, cancelInFlight, sendRaw, targetPtyId, terminalTabId])
|
||||
}, [agent, cancelInFlight, sendRaw, targetPtyId])
|
||||
|
||||
return { sendAnswer, sendRaw, sendRawVerified, cancelPending: cancelInFlight, cancelAsk, cancel }
|
||||
}
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ describe('useNativeChatPickerCommandDispatch', () => {
|
||||
const hook = renderDispatch('codex', onSubmitted)
|
||||
act(() => hook.result.current(COMMAND))
|
||||
|
||||
expect(sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status')
|
||||
expect(sendNativeChatTypedCommand).toHaveBeenCalledWith('pty-1', '/status')
|
||||
expect(sendNativeChatMessage).not.toHaveBeenCalled()
|
||||
expect(onSubmitted).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -69,7 +69,7 @@ describe('useNativeChatPickerCommandDispatch', () => {
|
||||
const hook = renderDispatch(agent)
|
||||
act(() => hook.result.current(COMMAND))
|
||||
|
||||
expect(sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/status')
|
||||
expect(sendNativeChatMessage).toHaveBeenCalledWith('pty-1', '/status')
|
||||
expect(sendNativeChatTypedCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,8 +79,8 @@ export function useNativeChatPickerCommandDispatch(args: {
|
||||
}
|
||||
trackPendingSend(
|
||||
agent === 'codex'
|
||||
? sendNativeChatTypedCommand(target.settings, target.ptyId, text)
|
||||
: sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
? sendNativeChatTypedCommand(target.ptyId, text)
|
||||
: sendNativeChatMessage(target.ptyId, text)
|
||||
)
|
||||
emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' })
|
||||
// Why: picker dispatch is a catalog-verified command send; it must leave
|
||||
|
||||
@@ -62,7 +62,7 @@ beforeEach(() => {
|
||||
|
||||
it('routes a Claude chat send outcome to its own pending echo', () => {
|
||||
const callbacks = send('claude', 'chat', 'hello')
|
||||
const options = vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3]
|
||||
const options = vi.mocked(sendNativeChatMessage).mock.calls[0]?.[2]
|
||||
options?.onWriteRejected?.()
|
||||
options?.onWriteUnconfirmed?.()
|
||||
expect(callbacks.rejected).toHaveBeenCalledWith('pending-1')
|
||||
@@ -71,7 +71,7 @@ it('routes a Claude chat send outcome to its own pending echo', () => {
|
||||
|
||||
it('routes a Claude image send outcome to its own pending echo', () => {
|
||||
const callbacks = send('claude', 'chat', 'look', ['/tmp/shot.png'])
|
||||
vi.mocked(sendNativeChatMessageWithImageAttachments).mock.calls[0]?.[5]?.onWriteRejected?.()
|
||||
vi.mocked(sendNativeChatMessageWithImageAttachments).mock.calls[0]?.[4]?.onWriteRejected?.()
|
||||
expect(callbacks.rejected).toHaveBeenCalledWith('pending-1')
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ it.each([
|
||||
['claude', 'command', '/compact']
|
||||
] as const)('leaves a %s %s send on the unobserved write path', (agent, classification, draft) => {
|
||||
send(agent, classification, draft)
|
||||
expect(vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3]?.onWriteRejected).toBeUndefined()
|
||||
expect(vi.mocked(sendNativeChatMessage).mock.calls[0]?.[2]?.onWriteRejected).toBeUndefined()
|
||||
})
|
||||
|
||||
// The pane brings the latest into view on this, whatever the send was classified as.
|
||||
|
||||
@@ -94,21 +94,20 @@ export function useNativeChatPtyComposerSend(args: {
|
||||
if (classification !== 'chat' && imagePaths.length === 0) {
|
||||
pendingHandle =
|
||||
args.agent === 'codex' && isSlashCommandDraft(text)
|
||||
? sendNativeChatTypedCommand(target.settings, target.ptyId, text)
|
||||
: sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
? sendNativeChatTypedCommand(target.ptyId, text)
|
||||
: sendNativeChatMessage(target.ptyId, text, sendOptions)
|
||||
} else if (imagePaths.length > 0) {
|
||||
pendingHandle = sendNativeChatMessageWithImageAttachments(
|
||||
args.agent,
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
text,
|
||||
imagePaths,
|
||||
sendOptions
|
||||
)
|
||||
} else if (text.trim().length > 0) {
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
pendingHandle = sendNativeChatMessage(target.ptyId, text, sendOptions)
|
||||
} else {
|
||||
submitNativeChatPrompt(target.settings, target.ptyId)
|
||||
submitNativeChatPrompt(target.ptyId)
|
||||
}
|
||||
if (classification !== 'chat') {
|
||||
if (pendingHandle) {
|
||||
|
||||
+1
-7
@@ -42,12 +42,7 @@ describe('useNativeChatSessionOptionCommand', () => {
|
||||
await act(() => hook.result.current.dispatch('/model'))
|
||||
expect(onSubmitted).toHaveBeenCalledOnce()
|
||||
|
||||
expect(typeNativeChatCommand).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(typeNativeChatCommand).toHaveBeenCalledWith('pty-1', '/model', expect.any(AbortSignal))
|
||||
expect(sendNativeChatMessageVerified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -56,7 +51,6 @@ describe('useNativeChatSessionOptionCommand', () => {
|
||||
await act(() => hook.result.current.dispatch('/model sonnet', { delivery: 'type' }))
|
||||
|
||||
expect(sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model sonnet',
|
||||
expect.any(AbortSignal)
|
||||
|
||||
@@ -74,7 +74,6 @@ export function useNativeChatSessionOptionCommand(args: {
|
||||
if (detectClaudeConfirmation) {
|
||||
observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: target.ptyId,
|
||||
settings: target.settings,
|
||||
expectedModelLabel: options?.expectedChoiceLabel ?? null
|
||||
})
|
||||
activeObserversRef.current.add(observer)
|
||||
@@ -88,18 +87,8 @@ export function useNativeChatSessionOptionCommand(args: {
|
||||
}
|
||||
const accepted =
|
||||
agent === 'codex'
|
||||
? await typeNativeChatCommand(
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
command,
|
||||
sendController.signal
|
||||
)
|
||||
: await sendNativeChatMessageVerified(
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
command,
|
||||
sendController.signal
|
||||
)
|
||||
? await typeNativeChatCommand(target.ptyId, command, sendController.signal)
|
||||
: await sendNativeChatMessageVerified(target.ptyId, command, sendController.signal)
|
||||
if (!accepted) {
|
||||
throw new Error('The terminal did not accept the command.')
|
||||
}
|
||||
|
||||
-8
@@ -16,7 +16,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchAttention,
|
||||
@@ -71,7 +70,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchAttention,
|
||||
@@ -113,7 +111,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchAttention,
|
||||
@@ -160,7 +157,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchAttention,
|
||||
@@ -201,7 +197,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion: vi.fn(),
|
||||
dispatchAttention,
|
||||
@@ -243,7 +238,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchAttention,
|
||||
@@ -272,7 +266,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foreground)),
|
||||
dispatchCompletion,
|
||||
dispatchAttention,
|
||||
@@ -321,7 +314,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion: vi.fn(),
|
||||
dispatchAttention,
|
||||
|
||||
-8
@@ -13,7 +13,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -49,7 +48,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -73,7 +71,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -116,7 +113,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -163,7 +159,6 @@ describe('agent completion coordinator', () => {
|
||||
const firstCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -177,7 +172,6 @@ describe('agent completion coordinator', () => {
|
||||
const remountedCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -196,7 +190,6 @@ describe('agent completion coordinator', () => {
|
||||
const firstCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -221,7 +214,6 @@ describe('agent completion coordinator', () => {
|
||||
const remountedCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
-1
@@ -28,7 +28,6 @@ function makeOptions(paneKey: string, live: { value: boolean }): AgentCompletion
|
||||
return {
|
||||
paneKey,
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => live.value
|
||||
|
||||
-7
@@ -15,7 +15,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -71,7 +70,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -103,7 +101,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -134,7 +131,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -165,7 +161,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -190,7 +185,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -227,7 +221,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
-10
@@ -17,7 +17,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -55,7 +54,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -78,7 +76,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult('codex')),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -104,7 +101,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -132,7 +128,6 @@ describe('agent completion coordinator', () => {
|
||||
const hookCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey,
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(null)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -157,7 +152,6 @@ describe('agent completion coordinator', () => {
|
||||
const processCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey,
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => result),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -189,7 +183,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(() => inspection.promise),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -219,7 +212,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -260,7 +252,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(() => inspection.promise),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -288,7 +279,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
-1
@@ -40,7 +40,6 @@ function createCoordinator() {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: PANE,
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchHookLifecycle,
|
||||
|
||||
-8
@@ -27,7 +27,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult('zsh')),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -51,7 +50,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -86,7 +84,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -121,7 +118,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -151,7 +147,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(() => inspection.promise),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -171,7 +166,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(() => inspection.promise),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -192,7 +186,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult('codex')),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -211,7 +204,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'remote:terminal-1',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'env-1' }),
|
||||
inspectProcess: vi.fn(() => inspection.promise),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
-13
@@ -16,7 +16,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
@@ -41,7 +40,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
@@ -63,7 +61,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
@@ -84,7 +81,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
@@ -117,7 +113,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true,
|
||||
@@ -150,7 +145,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -183,7 +177,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => result),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -226,7 +219,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => ({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false as const,
|
||||
@@ -252,7 +244,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => ({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false as const,
|
||||
@@ -295,7 +286,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => result),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -324,7 +314,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -349,7 +338,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
shouldSuppressProcessReplacementCompletion: () => true,
|
||||
@@ -376,7 +364,6 @@ describe('agent completion coordinator', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
|
||||
dispatchCompletion,
|
||||
shouldSuppressConfirmedProcessExitCompletion,
|
||||
|
||||
-2
@@ -23,7 +23,6 @@ describe('agent completion coordinator process-exit turn boundary', () => {
|
||||
paneKey,
|
||||
statusLane: 'pty',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => result),
|
||||
dispatchCompletion: ptyDispatch,
|
||||
isLive: () => true
|
||||
@@ -48,7 +47,6 @@ describe('agent completion coordinator process-exit turn boundary', () => {
|
||||
paneKey,
|
||||
statusLane: 'hook',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(null)),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
-3
@@ -20,7 +20,6 @@ describe('agent completion coordinator queued inspections', () => {
|
||||
createAgentCompletionCoordinator({
|
||||
paneKey: `tab-1:blocked-${index}`,
|
||||
getPtyId: () => `pty-blocked-${index}`,
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true
|
||||
@@ -40,7 +39,6 @@ describe('agent completion coordinator queued inspections', () => {
|
||||
createAgentCompletionCoordinator({
|
||||
paneKey: `tab-1:stale-${index}`,
|
||||
getPtyId: () => `pty-stale-${index}`,
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true
|
||||
@@ -50,7 +48,6 @@ describe('agent completion coordinator queued inspections', () => {
|
||||
const liveCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:live',
|
||||
getPtyId: () => 'pty-live',
|
||||
getSettings: () => null,
|
||||
inspectProcess: liveInspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true
|
||||
|
||||
-13
@@ -13,7 +13,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -41,7 +40,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -63,7 +61,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const firstCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -81,7 +78,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const remounted = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -104,7 +100,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchHookLifecycle,
|
||||
@@ -167,7 +162,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const localCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -175,7 +169,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const hostCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -230,7 +223,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const localCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -238,7 +230,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const hostCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -287,7 +278,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const localCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -295,7 +285,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const hostCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -349,7 +338,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const localCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -357,7 +345,6 @@ describe('agent completion coordinator stamped turn boundary', () => {
|
||||
const hostCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
-12
@@ -16,7 +16,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
statusLane: 'pty',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -25,7 +24,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
statusLane: 'hook',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -81,7 +79,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
statusLane: 'pty',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -91,7 +88,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
statusLane: 'hook',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -142,7 +138,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
dispatchHookLifecycle,
|
||||
@@ -196,7 +191,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const firstCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -221,7 +215,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const remounted = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -243,7 +236,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const firstCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -271,7 +263,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const remounted = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -293,7 +284,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const hookCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -301,7 +291,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const paneCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -350,7 +339,6 @@ describe('agent completion coordinator stamped turn replay', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition'
|
||||
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
|
||||
|
||||
@@ -28,9 +27,7 @@ export type AgentCompletionCoordinatorOptions = {
|
||||
/** Remote authorities are event-triggered only; no periodic process polls. */
|
||||
isRemotePtyId?: (ptyId: string) => boolean
|
||||
getExpectedIncarnationId?: () => string | null
|
||||
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
inspectProcess: (
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
options?: { expectedIncarnationId?: string; steadyState?: boolean }
|
||||
) => Promise<RuntimeTerminalProcessInspection>
|
||||
|
||||
@@ -37,7 +37,6 @@ function createCoordinator(
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true,
|
||||
|
||||
@@ -118,8 +118,8 @@ export function createAgentCompletionProcessMonitor({
|
||||
: {})
|
||||
}
|
||||
const result = await (Object.keys(inspectOptions).length > 0
|
||||
? options.inspectProcess(options.getSettings(), ptyId, inspectOptions)
|
||||
: options.inspectProcess(options.getSettings(), ptyId))
|
||||
? options.inspectProcess(ptyId, inspectOptions)
|
||||
: options.inspectProcess(ptyId))
|
||||
if (
|
||||
!state.disposed &&
|
||||
generationAtRequest === state.inspectionGeneration &&
|
||||
|
||||
-1
@@ -91,7 +91,6 @@ function inspect(result: RuntimeTerminalProcessInspection, roundTripMs = 20): Pr
|
||||
options: {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => SSH_PTY_ID,
|
||||
getSettings: () => null,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => INCARNATION
|
||||
} as unknown as AgentCompletionCoordinatorOptions,
|
||||
|
||||
+1
-4
@@ -12,14 +12,13 @@ import {
|
||||
describe('agent completion steadyState opt-in', () => {
|
||||
useAgentCompletionCoordinatorLifecycle()
|
||||
|
||||
const optionsOf = (call: unknown[]): unknown => call[2]
|
||||
const optionsOf = (call: unknown[]): unknown => call[1]
|
||||
|
||||
it('marks cadence polls on a local pane as steadyState', async () => {
|
||||
const inspectProcess = vi.fn(async () => processResult('codex'))
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
@@ -41,7 +40,6 @@ describe('agent completion steadyState opt-in', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
@@ -64,7 +62,6 @@ describe('agent completion steadyState opt-in', () => {
|
||||
getPtyId: () => 'remote:pty-1',
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
|
||||
@@ -36,7 +36,6 @@ function createUnmarkedPane(args: { visible: boolean }) {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
statusLane: 'pty',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foreground, hasChildren)),
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
|
||||
@@ -44,7 +44,6 @@ it.each([false, true])('requires confirmation for pending live work, paired=%s',
|
||||
const p = await prepare(remote)
|
||||
p.actions.handleRequestClosePane(1)
|
||||
expect(p.probePtyRunningWork).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
[remote ? 'remote:env-1@@term_original' : 'pty-restored'],
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -103,7 +102,7 @@ it('uses the direct SSH identity in the running-work probe', async () => {
|
||||
p.actions.handleRequestClosePane(1)
|
||||
p.verdict('live')
|
||||
await flushPtySideEffects()
|
||||
expect(p.probePtyRunningWork).toHaveBeenCalledWith(expect.any(Object), [id], expect.any(Object))
|
||||
expect(p.probePtyRunningWork).toHaveBeenCalledWith([id], expect.any(Object))
|
||||
expect(p.closed()).toBe(false)
|
||||
p.actions.handleConfirmClose(false)
|
||||
await p.settle()
|
||||
|
||||
@@ -182,11 +182,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
isTrackablePtyId: session.isForegroundTrackingAllowed,
|
||||
readForegroundProcess: (id, options) =>
|
||||
isRemotePtyId(id)
|
||||
? inspectRuntimeTerminalProcess(useAppStore.getState().settings, id, options)
|
||||
? inspectRuntimeTerminalProcess(id, options)
|
||||
: window.api.pty.getForegroundProcess(id),
|
||||
confirmForegroundProcess: (id, options) =>
|
||||
isRemotePtyId(id)
|
||||
? inspectRuntimeTerminalProcess(useAppStore.getState().settings, id, options)
|
||||
? inspectRuntimeTerminalProcess(id, options)
|
||||
: window.api.pty.confirmForegroundProcess(id),
|
||||
isRemotePtyId,
|
||||
getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null,
|
||||
|
||||
+10
-26
@@ -1,10 +1,8 @@
|
||||
import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { SessionRestoredBannerReason } from '../session-restored-banner-pane-state'
|
||||
import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { isExpectedAgentProcess } from '../../../../../shared/agent-process-recognition'
|
||||
import { resolveDraftPasteReadyTimeoutMs } from '../../../../../shared/draft-paste-ready-timeout'
|
||||
import { createDraftPasteReadyScanner } from '../../../../../shared/draft-paste-ready-scanner'
|
||||
@@ -117,28 +115,18 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
startupDraftPasteSettled = true
|
||||
session.startupDraftPasteAttempted = true
|
||||
session.cleanupStartupDraftPasteTimers()
|
||||
const settings = getSettingsForWorktreeRuntimeOwner(
|
||||
useAppStore.getState(),
|
||||
session.deps.worktreeId
|
||||
)
|
||||
// Why: xterm focus reports share this transport queue. Bypassing it can
|
||||
// race CSI I against the draft on ConPTY and expose a literal `[I` prefix.
|
||||
void sendAgentDraftPasteContent(
|
||||
settings,
|
||||
ptyId,
|
||||
session.startupDraftPrompt,
|
||||
'launch',
|
||||
async (data) => {
|
||||
const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch')
|
||||
if (accepted && !startupDraftInputRecorded) {
|
||||
// Why: this transport write bypasses xterm's user-input signal; keep
|
||||
// the composed draft from being discarded by later hibernation.
|
||||
startupDraftInputRecorded = true
|
||||
session.recordTerminalInputForHibernation()
|
||||
}
|
||||
return accepted
|
||||
void sendAgentDraftPasteContent(ptyId, session.startupDraftPrompt, 'launch', async (data) => {
|
||||
const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch')
|
||||
if (accepted && !startupDraftInputRecorded) {
|
||||
// Why: this transport write bypasses xterm's user-input signal; keep
|
||||
// the composed draft from being discarded by later hibernation.
|
||||
startupDraftInputRecorded = true
|
||||
session.recordTerminalInputForHibernation()
|
||||
}
|
||||
)
|
||||
return accepted
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
startupDraftPasteInFlight = false
|
||||
@@ -152,12 +140,8 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
const settings = getSettingsForWorktreeRuntimeOwner(
|
||||
useAppStore.getState(),
|
||||
session.deps.worktreeId
|
||||
)
|
||||
try {
|
||||
const process = await inspectRuntimeTerminalProcess(settings, ptyId)
|
||||
const process = await inspectRuntimeTerminalProcess(ptyId)
|
||||
const foreground = process.foregroundProcess?.toLowerCase() ?? ''
|
||||
if (
|
||||
getStartupDraftPtyId() === ptyId &&
|
||||
|
||||
@@ -184,7 +184,6 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
|
||||
isRemotePtyId: (ptyId) =>
|
||||
Boolean(isRemoteExecutionHostPtyId(ptyId) || isRemoteRuntimePtyId(ptyId)),
|
||||
getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null,
|
||||
getSettings: () => useAppStore.getState().settings,
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchHookLifecycle: (payload) =>
|
||||
dispatchAgentHookTerminalLifecycle(session.cacheKey, payload),
|
||||
|
||||
@@ -55,7 +55,6 @@ describe('terminal paste operation ordering', () => {
|
||||
writePty,
|
||||
yieldToEventLoop: async () => {
|
||||
startupDraft ??= sendAgentDraftPasteContent(
|
||||
null,
|
||||
'pty-1',
|
||||
'GENERATED_CONTEXT',
|
||||
'launch',
|
||||
@@ -83,7 +82,6 @@ describe('terminal paste operation ordering', () => {
|
||||
startupOpened = resolve
|
||||
})
|
||||
const startup = sendAgentDraftPasteContent(
|
||||
null,
|
||||
'pty-1',
|
||||
'G'.repeat(64 * 1024 + 1),
|
||||
'launch',
|
||||
|
||||
@@ -147,7 +147,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
|
||||
)
|
||||
// Why the shared probe rather than a direct inspect: this is the same question the tab-close
|
||||
// guard asks, and the two must not drift on what an unanswered host means.
|
||||
void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS })
|
||||
void probePtyRunningWork([ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS })
|
||||
.then((probes) => {
|
||||
clearTimeout(probeTimeout)
|
||||
decide(() => {
|
||||
|
||||
@@ -14,8 +14,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
|
||||
import { probePtyRunningWork } from './pty-running-work-probe'
|
||||
|
||||
const SETTINGS = { activeRuntimeEnvironmentId: null }
|
||||
|
||||
describe('probePtyRunningWork child-process evidence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -28,9 +26,9 @@ describe('probePtyRunningWork child-process evidence', () => {
|
||||
childProcessEvidence: 'no-children'
|
||||
})
|
||||
|
||||
await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(SETTINGS, 'pty-a', {
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith('pty-a', {
|
||||
scanChildProcesses: true
|
||||
})
|
||||
})
|
||||
@@ -42,7 +40,7 @@ describe('probePtyRunningWork child-process evidence', () => {
|
||||
childProcessEvidence: 'unverifiable'
|
||||
})
|
||||
|
||||
const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
const [probe] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(probe).toMatchObject({
|
||||
verdict: 'unverifiable',
|
||||
@@ -58,7 +56,7 @@ describe('probePtyRunningWork child-process evidence', () => {
|
||||
childProcessEvidence: 'no-children'
|
||||
})
|
||||
|
||||
const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
const [probe] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(probe?.verdict).toBe('exited')
|
||||
})
|
||||
@@ -70,7 +68,7 @@ describe('probePtyRunningWork child-process evidence', () => {
|
||||
childProcessEvidence: 'children'
|
||||
})
|
||||
|
||||
const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
const [probe] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(probe?.verdict).toBe('live')
|
||||
})
|
||||
@@ -85,8 +83,8 @@ describe('probePtyRunningWork child-process evidence', () => {
|
||||
hasChildProcesses: false
|
||||
})
|
||||
|
||||
const [live] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
const [idle] = await probePtyRunningWork(SETTINGS, ['pty-b'], { timeoutMs: 1000 })
|
||||
const [live] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 })
|
||||
const [idle] = await probePtyRunningWork(['pty-b'], { timeoutMs: 1000 })
|
||||
|
||||
expect(live?.verdict).toBe('live')
|
||||
expect(idle?.verdict).toBe('exited')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
import {
|
||||
probePtyRunningWorkWithInspection,
|
||||
@@ -10,15 +9,12 @@ export type {
|
||||
PtyRunningWorkVerdict
|
||||
} from '../../../../shared/pty-running-work-probe'
|
||||
|
||||
type ProbeSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
|
||||
export function probePtyRunningWork(
|
||||
settings: ProbeSettings,
|
||||
ptyIds: readonly string[],
|
||||
options: { timeoutMs: number }
|
||||
): Promise<PtyRunningWorkProbe[]> {
|
||||
return probePtyRunningWorkWithInspection(ptyIds, options, (ptyId) =>
|
||||
// Close guards require host child-process evidence before acting.
|
||||
inspectRuntimeTerminalProcess(settings, ptyId, { scanChildProcesses: true })
|
||||
inspectRuntimeTerminalProcess(ptyId, { scanChildProcesses: true })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('guardRunningTerminalClose', () => {
|
||||
guard(onClose)
|
||||
await settleProbe()
|
||||
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a', {
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith('pty-a', {
|
||||
scanChildProcesses: true
|
||||
})
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
@@ -220,7 +220,7 @@ describe('guardRunningTerminalClose', () => {
|
||||
},
|
||||
agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } }
|
||||
})
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation(async (ptyId: string) => ({
|
||||
foregroundProcess: ptyId === 'pty-b' ? 'claude' : 'zsh',
|
||||
hasChildProcesses: ptyId === 'pty-b'
|
||||
}))
|
||||
@@ -242,7 +242,7 @@ describe('guardRunningTerminalClose', () => {
|
||||
},
|
||||
agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } }
|
||||
})
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation(async (ptyId: string) => ({
|
||||
foregroundProcess: ptyId === 'pty-a' ? 'npm' : 'zsh',
|
||||
hasChildProcesses: ptyId === 'pty-a'
|
||||
}))
|
||||
|
||||
@@ -111,7 +111,7 @@ export function guardRunningTerminalClose(params: {
|
||||
decided = true
|
||||
}
|
||||
|
||||
void probePtyRunningWork(settings, ptyIds, { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS })
|
||||
void probePtyRunningWork(ptyIds, { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS })
|
||||
.then((probes) => {
|
||||
if (decided) {
|
||||
return
|
||||
|
||||
@@ -49,7 +49,7 @@ function setState(ptyIds: string[]): void {
|
||||
|
||||
/** Answers each pty id from `byPtyId`; anything unlisted never settles. */
|
||||
function answerWith(byPtyId: Record<string, unknown>): void {
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation((_settings: unknown, ptyId: string) =>
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation((ptyId: string) =>
|
||||
ptyId in byPtyId ? Promise.resolve(byPtyId[ptyId]) : new Promise(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function assessWindowCloseRunningWork(params: {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
|
||||
const probes = await probePtyRunningWork(state.settings, candidatePtyIds, {
|
||||
const probes = await probePtyRunningWork(candidatePtyIds, {
|
||||
timeoutMs: WINDOW_CLOSE_PROBE_TIMEOUT_MS
|
||||
})
|
||||
if (probes.some((probe) => probe.verdict === 'live')) {
|
||||
|
||||
@@ -236,7 +236,6 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion
|
||||
paneKey,
|
||||
statusLane: 'hook',
|
||||
getPtyId: () => getPtyIdForPaneKey(paneKey),
|
||||
getSettings: () => useAppStore.getState().settings,
|
||||
inspectProcess: async (): Promise<RuntimeTerminalProcessInspection> => ({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { yieldToEventLoop } from '../../../shared/event-loop-yield'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import {
|
||||
getUtf8ByteLengthForCodePoint,
|
||||
readUtf8CodePointAt
|
||||
@@ -27,21 +26,19 @@ const AGENT_DRAFT_PASTE_INERT_ESCAPE = '\u241b'
|
||||
export type AgentDraftPtyInputWriter = (data: string) => boolean | Promise<boolean>
|
||||
|
||||
export async function sendAgentDraftPasteContent(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
content: string,
|
||||
inputKind: TerminalInputKind,
|
||||
writePty?: AgentDraftPtyInputWriter
|
||||
): Promise<boolean> {
|
||||
return await runTerminalPtyInputTransaction(ptyId, () =>
|
||||
sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind, writePty)
|
||||
sendAgentDraftPasteContentNow(ptyId, content, inputKind, writePty)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: callers that must keep extra PTY writes (e.g. submit Enter) inside the same
|
||||
// transaction take the lock themselves; taking it again here would deadlock.
|
||||
export async function sendAgentDraftPasteContentNow(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
content: string,
|
||||
inputKind: TerminalInputKind,
|
||||
@@ -57,7 +54,6 @@ export async function sendAgentDraftPasteContentNow(
|
||||
})
|
||||
if (!directMeasurement.exceededLimit) {
|
||||
return await writeAgentDraftPtyInput(
|
||||
settings,
|
||||
ptyId,
|
||||
wrapTerminalBracketedPasteText(terminalContent),
|
||||
inputKind,
|
||||
@@ -75,16 +71,16 @@ export async function sendAgentDraftPasteContentNow(
|
||||
for (const chunk of iterateAgentDraftPasteContentChunks(terminalContent)) {
|
||||
let accepted = false
|
||||
try {
|
||||
accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, inputKind, writePty)
|
||||
accepted = await writeAgentDraftPtyInput(ptyId, chunk, inputKind, writePty)
|
||||
} catch {
|
||||
if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) {
|
||||
await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty)
|
||||
await closeAgentDraftBracketedPaste(ptyId, inputKind, writePty)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!accepted) {
|
||||
if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) {
|
||||
await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty)
|
||||
await closeAgentDraftBracketedPaste(ptyId, inputKind, writePty)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -198,19 +194,15 @@ function getSanitizedUtf8ByteLengthForCodePoint(codePoint: number): number {
|
||||
}
|
||||
|
||||
async function writeAgentDraftPtyInput(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
data: string,
|
||||
inputKind: TerminalInputKind,
|
||||
writePty?: AgentDraftPtyInputWriter
|
||||
): Promise<boolean> {
|
||||
return writePty
|
||||
? await writePty(data)
|
||||
: await sendRuntimePtyInputVerified(settings, ptyId, data, inputKind)
|
||||
return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(ptyId, data, inputKind)
|
||||
}
|
||||
|
||||
async function closeAgentDraftBracketedPaste(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
inputKind: TerminalInputKind,
|
||||
writePty?: AgentDraftPtyInputWriter
|
||||
@@ -218,7 +210,7 @@ async function closeAgentDraftBracketedPaste(
|
||||
try {
|
||||
// Why: once the opener reached the PTY, a failed content chunk should not
|
||||
// leave the target TUI in bracketed-paste mode.
|
||||
await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, inputKind, writePty)
|
||||
await writeAgentDraftPtyInput(ptyId, BRACKETED_PASTE_END, inputKind, writePty)
|
||||
} catch {
|
||||
// The original write already failed; callers only need the paste to fail closed.
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('waitForAgentDraftInputReady', () => {
|
||||
const primary = vi.fn()
|
||||
|
||||
await expect(
|
||||
waitForAgentDraftInputReady(PTY_ID, 20_000, 'codex-composer-prompt', {})
|
||||
waitForAgentDraftInputReady(PTY_ID, 20_000, 'codex-composer-prompt')
|
||||
).resolves.toBe(true)
|
||||
drainPreHandlerPtyData(PTY_ID, primary)
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('waitForAgentDraftInputReady', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', globalThis)
|
||||
let settled: boolean | null = null
|
||||
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then(
|
||||
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row').then(
|
||||
(ready) => (settled = ready)
|
||||
)
|
||||
testState.observer!(OPENCODE_BOX)
|
||||
@@ -78,7 +78,7 @@ describe('waitForAgentDraftInputReady', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', globalThis)
|
||||
let settled: boolean | null = null
|
||||
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then(
|
||||
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row').then(
|
||||
(ready) => (settled = ready)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(19_000)
|
||||
@@ -94,7 +94,7 @@ describe('waitForAgentDraftInputReady', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', globalThis)
|
||||
let settled: boolean | null = null
|
||||
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then(
|
||||
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row').then(
|
||||
(ready) => (settled = ready)
|
||||
)
|
||||
testState.observer!(OPENCODE_BOX)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { DraftPasteReadySignal } from '../../../shared/tui-agent-config'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions'
|
||||
import { replayPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
@@ -21,8 +20,7 @@ const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
export function waitForAgentDraftInputReady(
|
||||
ptyId: string,
|
||||
timeoutMs: number,
|
||||
readySignal: DraftPasteReadySignal,
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
readySignal: DraftPasteReadySignal
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false
|
||||
@@ -75,12 +73,7 @@ export function waitForAgentDraftInputReady(
|
||||
}
|
||||
|
||||
if (isRemoteRuntimePtyId(ptyId)) {
|
||||
void subscribeToRuntimeTerminalData(
|
||||
settings,
|
||||
ptyId,
|
||||
`desktop:paste-ready:${ptyId}`,
|
||||
observeData
|
||||
)
|
||||
void subscribeToRuntimeTerminalData(ptyId, `desktop:paste-ready:${ptyId}`, observeData)
|
||||
.then((remoteUnsubscribe) => {
|
||||
if (settled) {
|
||||
remoteUnsubscribe()
|
||||
|
||||
@@ -44,15 +44,14 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
|
||||
const delivery = sendFollowupPromptWhenAgentReady({
|
||||
ptyId: 'pty-1',
|
||||
expectedProcess,
|
||||
prompt: 'ship it',
|
||||
settings: null
|
||||
prompt: 'ship it'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4 * 150)
|
||||
const delivered = await delivery
|
||||
|
||||
expect(delivered).toBe(true)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch')
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', 'ship it\r', 'launch')
|
||||
})
|
||||
|
||||
it(`still refuses to type into a bare ${agent} shell foreground`, async () => {
|
||||
@@ -66,8 +65,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
|
||||
const delivery = sendFollowupPromptWhenAgentReady({
|
||||
ptyId: 'pty-1',
|
||||
expectedProcess,
|
||||
prompt: 'ship it',
|
||||
settings: null
|
||||
prompt: 'ship it'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(29 * 150)
|
||||
@@ -86,8 +84,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
|
||||
const delivery = sendFollowupPromptWhenAgentReady({
|
||||
ptyId: 'pty-1',
|
||||
expectedProcess,
|
||||
prompt: 'ship it',
|
||||
settings: null
|
||||
prompt: 'ship it'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(29 * 150)
|
||||
@@ -109,11 +106,10 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () =
|
||||
const delivered = await sendFollowupPromptWhenAgentReady({
|
||||
ptyId: 'pty-1',
|
||||
expectedProcess: 'aider',
|
||||
prompt: 'ship it',
|
||||
settings: null
|
||||
prompt: 'ship it'
|
||||
})
|
||||
|
||||
expect(delivered).toBe(true)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch')
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', 'ship it\r', 'launch')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,22 +7,18 @@ import {
|
||||
isExpectedAgentProcess
|
||||
} from '../../../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../../../shared/shell-process-detection'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
|
||||
type RuntimeOwnerSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
|
||||
export async function sendFollowupPromptWhenAgentReady(args: {
|
||||
ptyId: string
|
||||
expectedProcess: string
|
||||
prompt: string
|
||||
settings: RuntimeOwnerSettings
|
||||
}): Promise<boolean> {
|
||||
const { ptyId, expectedProcess, prompt, settings } = args
|
||||
if (!(await waitForAgentForeground(ptyId, expectedProcess, settings))) {
|
||||
const { ptyId, expectedProcess, prompt } = args
|
||||
if (!(await waitForAgentForeground(ptyId, expectedProcess))) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`, 'launch')
|
||||
return await sendRuntimePtyInputVerified(ptyId, `${prompt}\r`, 'launch')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -30,17 +26,13 @@ export async function sendFollowupPromptWhenAgentReady(args: {
|
||||
|
||||
// Why: delayed follow-ups must not type into an arbitrary shell. Require a
|
||||
// positive readiness signal before writing user/task text to the PTY.
|
||||
async function waitForAgentForeground(
|
||||
ptyId: string,
|
||||
expectedProcess: string,
|
||||
settings: RuntimeOwnerSettings
|
||||
): Promise<boolean> {
|
||||
async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
await new Promise((resolve) => globalThis.setTimeout(resolve, 150))
|
||||
}
|
||||
try {
|
||||
const process = await inspectRuntimeTerminalProcess(settings, ptyId)
|
||||
const process = await inspectRuntimeTerminalProcess(ptyId)
|
||||
const foreground = process.foregroundProcess?.toLowerCase() ?? ''
|
||||
if (isExpectedAgentProcess(foreground, expectedProcess)) {
|
||||
return true
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
|
||||
it('delivers when a cold Codex composer becomes ready after 8s', async () => {
|
||||
const promise = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: 'draft',
|
||||
agent: 'codex',
|
||||
@@ -63,19 +62,13 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
20_000,
|
||||
'codex-composer-prompt',
|
||||
{}
|
||||
)
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'codex-composer-prompt')
|
||||
expect(testState.sendInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('gives a cold opencode composer the same headroom as Codex', async () => {
|
||||
const onUnconfirmedDelivery = vi.fn()
|
||||
const promise = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: 'draft',
|
||||
agent: 'opencode',
|
||||
@@ -89,8 +82,7 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
20_000,
|
||||
'render-cursor-after-bracketed-paste',
|
||||
{}
|
||||
'render-cursor-after-bracketed-paste'
|
||||
)
|
||||
expect(testState.sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(onUnconfirmedDelivery).not.toHaveBeenCalled()
|
||||
@@ -100,7 +92,6 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
'waits for the %s agent row only when Enter follows the paste',
|
||||
async (agent) => {
|
||||
const promise = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: 'task',
|
||||
agent,
|
||||
@@ -111,7 +102,7 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
await vi.advanceTimersByTimeAsync(12_000)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {})
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -132,7 +123,7 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
await vi.advanceTimersByTimeAsync(12_000)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal, {})
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal)
|
||||
})
|
||||
|
||||
it('flags a blind paste when only the opencode process, not its composer, was seen', async () => {
|
||||
@@ -145,7 +136,6 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
})
|
||||
const onUnconfirmedDelivery = vi.fn()
|
||||
const promise = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: 'draft',
|
||||
agent: 'opencode',
|
||||
@@ -163,7 +153,6 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
it('keeps the 8s readiness deadline for agents without an agent-specific budget', async () => {
|
||||
const onTimeout = vi.fn()
|
||||
const promise = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: 'draft',
|
||||
agent: 'gemini',
|
||||
@@ -177,8 +166,7 @@ describe('pty-bound agent draft readiness budget', () => {
|
||||
expect(testState.waitForReady).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
8000,
|
||||
'render-quiet-after-bracketed-paste',
|
||||
{}
|
||||
'render-quiet-after-bracketed-paste'
|
||||
)
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1)
|
||||
expect(testState.sendInput).not.toHaveBeenCalled()
|
||||
|
||||
@@ -105,12 +105,7 @@ describe('post-paste submit retry Enter', () => {
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(enterWrites()).toHaveLength(2)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'\r',
|
||||
'launch'
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith('pty-1', '\r', 'launch')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -135,7 +130,7 @@ describe('post-paste submit retry Enter', () => {
|
||||
it('holds the PTY input transaction across the retry Enter', async () => {
|
||||
const writes: string[] = []
|
||||
testState.sendRuntimePtyInputVerified.mockImplementation(
|
||||
async (_settings: unknown, _ptyId: string, data: string) => {
|
||||
async (_ptyId: string, data: string) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
}
|
||||
@@ -146,7 +141,6 @@ describe('post-paste submit retry Enter', () => {
|
||||
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS)
|
||||
// Competing paste on the same PTY: it must not open a frame the retry can land in.
|
||||
const competing = sendAgentDraftPasteContent(
|
||||
{},
|
||||
'pty-1',
|
||||
'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1),
|
||||
'driving'
|
||||
@@ -178,7 +172,7 @@ describe('post-paste submit retry Enter', () => {
|
||||
})
|
||||
|
||||
function enterWrites(): unknown[][] {
|
||||
return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[2] === '\r')
|
||||
return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[1] === '\r')
|
||||
}
|
||||
|
||||
function startCodexSubmit(): Promise<boolean> {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES,
|
||||
AGENT_DRAFT_PASTE_MAX_BYTES,
|
||||
chunkAgentDraftPasteContent,
|
||||
getSettingsForAgentTabRuntimeOwner,
|
||||
iterateAgentDraftPasteContentChunks,
|
||||
pasteDraftToAgentPtyWhenReady,
|
||||
pasteDraftWhenAgentReady,
|
||||
@@ -58,8 +57,8 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId,
|
||||
// Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds.
|
||||
sendRuntimePtyInputVerified: (settings: unknown, ptyId: string, data: string) =>
|
||||
testState.sendRuntimePtyInputVerified(settings, ptyId, data),
|
||||
sendRuntimePtyInputVerified: (ptyId: string, data: string) =>
|
||||
testState.sendRuntimePtyInputVerified(ptyId, data),
|
||||
inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess
|
||||
}))
|
||||
|
||||
@@ -135,11 +134,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -168,14 +163,10 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
expect(testState.replayPreHandlerPtyData).toHaveBeenCalledWith('pty-1', testState.ptyObserver)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS)
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-1', '\r')
|
||||
expect(testState.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -198,11 +189,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it('pastes into opencode as soon as show-cursor renders after bracketed paste is enabled', async () => {
|
||||
@@ -220,11 +207,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.ptyObserver?.(SHOW_CURSOR)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -248,11 +231,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.ptyObserver?.(SHOW_CURSOR)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -308,11 +287,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
// The composer was never observed; the caller must be able to hedge its success notice.
|
||||
expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -336,11 +311,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await flushMicrotasks(5)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it('does not paste for agents that already use native draft prefill', async () => {
|
||||
@@ -371,16 +342,12 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
await vi.advanceTimersByTimeAsync(49)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-1', '\r')
|
||||
})
|
||||
|
||||
it('does not submit when the verified paste write fails', async () => {
|
||||
@@ -446,11 +413,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it('waits past the default budget for a cold-boot Codex composer glyph (STA-3367)', async () => {
|
||||
@@ -474,11 +437,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -532,11 +491,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it.each(['tab', 'pty'] as const)(
|
||||
@@ -568,7 +523,6 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.inspectRuntimeTerminalProcess.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const promise = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'codex',
|
||||
@@ -587,7 +541,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes tab-owned paste writes through the worktree runtime owner', async () => {
|
||||
it('pastes into a tab whose workspace a server owns', async () => {
|
||||
testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
|
||||
testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
|
||||
testState.appState.repos = [
|
||||
@@ -605,14 +559,10 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: 'owner-runtime' },
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it('routes legacy remote PTY readiness subscription through the tab owner', async () => {
|
||||
it('subscribes to a remote PTY by its id, never by the focused server', async () => {
|
||||
testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
|
||||
testState.appState.ptyIdsByTabId = { 'tab-1': ['remote:terminal-handle'] }
|
||||
testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
|
||||
@@ -622,12 +572,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
testState.isRemoteRuntimePtyId.mockReturnValue(true)
|
||||
testState.subscribeToRuntimeTerminalData.mockImplementation(
|
||||
async (
|
||||
_settings: unknown,
|
||||
_ptyId: string,
|
||||
_clientId: string,
|
||||
observer: (data: string) => void
|
||||
) => {
|
||||
async (_ptyId: string, _clientId: string, observer: (data: string) => void) => {
|
||||
testState.ptyObserver = observer
|
||||
return testState.unsubscribe
|
||||
}
|
||||
@@ -644,7 +589,6 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.subscribeToRuntimeTerminalData).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: 'owner-runtime' },
|
||||
'remote:terminal-handle',
|
||||
'desktop:paste-ready:remote:terminal-handle',
|
||||
expect.any(Function)
|
||||
@@ -660,11 +604,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
expect(testState.subscribeToPtyData).not.toHaveBeenCalled()
|
||||
expect(testState.subscribeToRuntimeTerminalData).not.toHaveBeenCalled()
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL)
|
||||
|
||||
await flushMicrotasks()
|
||||
await vi.advanceTimersByTimeAsync(49)
|
||||
@@ -672,13 +612,13 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-1', '\r')
|
||||
})
|
||||
|
||||
it('holds the PTY transaction across the paste and its submit Enter', async () => {
|
||||
const writes: string[] = []
|
||||
testState.sendRuntimePtyInputVerified.mockImplementation(
|
||||
async (_settings: unknown, _ptyId: string, data: string) => {
|
||||
async (_ptyId: string, data: string) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
}
|
||||
@@ -688,7 +628,6 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await flushMicrotasks()
|
||||
// Competing chunked paste on the same PTY: it must not open a frame the Enter can land in.
|
||||
const competing = sendAgentDraftPasteContent(
|
||||
{},
|
||||
'pty-1',
|
||||
'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1),
|
||||
'driving'
|
||||
@@ -728,16 +667,10 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ activeRuntimeEnvironmentId: 'owner-runtime' },
|
||||
'pty-right',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ activeRuntimeEnvironmentId: 'owner-runtime' },
|
||||
'pty-right',
|
||||
'\r'
|
||||
)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-right', '\r')
|
||||
})
|
||||
|
||||
it('streams large running-agent drafts as bounded bracketed chunks before submit', async () => {
|
||||
@@ -752,22 +685,22 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await flushMicrotasks(20)
|
||||
|
||||
const calls = testState.sendRuntimePtyInputVerified.mock.calls
|
||||
expect(calls.at(0)).toEqual([{}, 'pty-1', '\x1b[200~'])
|
||||
expect(calls.at(-1)?.[2]).toBe('\x1b[201~')
|
||||
expect(calls.at(0)).toEqual(['pty-1', '\x1b[200~'])
|
||||
expect(calls.at(-1)?.[1]).toBe('\x1b[201~')
|
||||
expect(
|
||||
calls
|
||||
.slice(1, -1)
|
||||
.map((call) => call[2])
|
||||
.map((call) => call[1])
|
||||
.join('')
|
||||
).toBe(content)
|
||||
for (const call of calls.slice(1, -1)) {
|
||||
expect((call[2] as string).length).toBeLessThanOrEqual(AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES)
|
||||
expect(String(call[1]).length).toBeLessThanOrEqual(AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES)
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith('pty-1', '\r')
|
||||
})
|
||||
|
||||
it('normalizes multiline running-agent drafts like terminal paste', async () => {
|
||||
@@ -777,7 +710,6 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
})
|
||||
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'\x1b[200~line one\rline two\rline three\x1b[201~'
|
||||
)
|
||||
@@ -802,9 +734,9 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(3)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(1, {}, 'pty-1', '[200~')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(3, {}, 'pty-1', '[201~')
|
||||
expect(testState.sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === '\r')).toBe(
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(1, 'pty-1', '[200~')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(3, 'pty-1', '[201~')
|
||||
expect(testState.sendRuntimePtyInputVerified.mock.calls.some((call) => call[1] === '\r')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
@@ -859,7 +791,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
|
||||
it('yields during large accepted-size preflight before writing agent draft chunks', async () => {
|
||||
const content = 'x'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 300 * 1024)
|
||||
const promise = sendAgentDraftPasteContent({}, 'pty-1', content, 'driving')
|
||||
const promise = sendAgentDraftPasteContent('pty-1', content, 'driving')
|
||||
|
||||
await flushMicrotasks(5)
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
@@ -867,46 +799,19 @@ describe('pasteDraftWhenAgentReady', () => {
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await flushMicrotasks(10)
|
||||
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith({}, 'pty-1', '\x1b[200~')
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', '\x1b[200~')
|
||||
await expect(promise).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects oversized agent drafts before any PTY write', async () => {
|
||||
const oversized = 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1)
|
||||
const result = sendAgentDraftPasteContent({}, 'pty-1', oversized, 'driving')
|
||||
const result = sendAgentDraftPasteContent('pty-1', oversized, 'driving')
|
||||
await expect(result).resolves.toBe(false)
|
||||
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSettingsForAgentTabRuntimeOwner', () => {
|
||||
beforeEach(() => {
|
||||
testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
|
||||
testState.appState.tabsByWorktree = {}
|
||||
testState.appState.repos = []
|
||||
testState.appState.worktreesByRepo = {}
|
||||
})
|
||||
|
||||
it('falls back to focused settings when the tab is not mapped to a worktree', () => {
|
||||
expect(getSettingsForAgentTabRuntimeOwner('missing-tab')).toEqual({
|
||||
activeRuntimeEnvironmentId: 'focused-runtime'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the tab worktree owner when mapped', () => {
|
||||
testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
|
||||
testState.appState.repos = [
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }
|
||||
]
|
||||
testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
|
||||
expect(getSettingsForAgentTabRuntimeOwner('tab-1')).toEqual({
|
||||
activeRuntimeEnvironmentId: 'owner-runtime'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function flushMicrotasks(iterations = 2): Promise<void> {
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { TerminalInputKind } from '../../../shared/terminal-input-kind'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from '@/components/terminal-pane/terminal-bracketed-paste'
|
||||
import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction'
|
||||
import { waitForAgentReady } from './agent-ready-wait'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner'
|
||||
import { sendAgentDraftPasteContentNow } from './agent-draft-paste-content'
|
||||
import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill'
|
||||
import { waitForAgentDraftInputReady } from './agent-draft-readiness'
|
||||
@@ -45,20 +43,6 @@ export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_M
|
||||
// also stops one slow step from spending the other's budget (STA-3367).
|
||||
const PTY_SPAWN_TIMEOUT_MS = 8000
|
||||
|
||||
export function getSettingsForAgentTabRuntimeOwner(
|
||||
tabId: string
|
||||
): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined {
|
||||
const store = useAppStore.getState()
|
||||
for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree ?? {})) {
|
||||
if (tabs?.some((tab) => tab.id === tabId)) {
|
||||
// Why: legacy remote PTY ids may not embed their runtime owner. The tab's
|
||||
// worktree still identifies which host should receive readiness/send RPCs.
|
||||
return getSettingsForWorktreeRuntimeOwner(store, worktreeId)
|
||||
}
|
||||
}
|
||||
return store.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the agent on `tabId` has rendered its input-accepting TUI,
|
||||
* then bracketed-paste `content` into its input buffer. By default the
|
||||
@@ -102,14 +86,12 @@ export async function pasteDraftWhenAgentReady(args: {
|
||||
}
|
||||
|
||||
const readySignal = resolvePasteReadySignal(agentConfig, submit === true)
|
||||
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
|
||||
const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs)
|
||||
const readinessResult = waitForAgentDraftInputReadyOnTab({
|
||||
tabId,
|
||||
spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS,
|
||||
readinessTimeoutMs,
|
||||
readySignal,
|
||||
settings
|
||||
readySignal
|
||||
})
|
||||
// Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses.
|
||||
if (args.sendGate && !(await args.sendGate)) {
|
||||
@@ -143,7 +125,6 @@ export async function pasteDraftWhenAgentReady(args: {
|
||||
}
|
||||
|
||||
return await sendBracketedPasteToAgent({
|
||||
settings,
|
||||
ptyId,
|
||||
content,
|
||||
submit: submit === true,
|
||||
@@ -154,7 +135,6 @@ export async function pasteDraftWhenAgentReady(args: {
|
||||
}
|
||||
|
||||
export async function pasteDraftToAgentPtyWhenReady(args: {
|
||||
tabId: string
|
||||
ptyId: string
|
||||
content: string
|
||||
agent?: TuiAgent
|
||||
@@ -164,31 +144,21 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
|
||||
onTimeout?: () => void
|
||||
onUnconfirmedDelivery?: () => void
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
tabId,
|
||||
ptyId,
|
||||
content,
|
||||
agent,
|
||||
submit,
|
||||
forcePaste,
|
||||
timeoutMs,
|
||||
onTimeout,
|
||||
onUnconfirmedDelivery
|
||||
} = args
|
||||
const { ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } =
|
||||
args
|
||||
const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null
|
||||
|
||||
if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
|
||||
const readySignal = resolvePasteReadySignal(agentConfig, submit === true)
|
||||
const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs)
|
||||
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
|
||||
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal)
|
||||
if (!ready) {
|
||||
const fallbackReady =
|
||||
agentConfig && agent !== 'codex'
|
||||
? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings)
|
||||
? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000)
|
||||
: false
|
||||
if (!fallbackReady) {
|
||||
onTimeout?.()
|
||||
@@ -198,7 +168,6 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
|
||||
}
|
||||
|
||||
return await sendBracketedPasteToAgent({
|
||||
settings,
|
||||
ptyId,
|
||||
content,
|
||||
submit: submit === true,
|
||||
@@ -214,7 +183,6 @@ export async function submitPromptToAgentPty(args: {
|
||||
content: string
|
||||
}): Promise<boolean> {
|
||||
return await sendBracketedPasteToAgent({
|
||||
settings: getSettingsForAgentTabRuntimeOwner(args.tabId),
|
||||
ptyId: args.ptyId,
|
||||
content: args.content,
|
||||
submit: true,
|
||||
@@ -230,21 +198,20 @@ export async function sendBracketedPasteToRunningAgent(args: {
|
||||
}
|
||||
|
||||
async function sendBracketedPasteToAgent(args: {
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
ptyId: string
|
||||
content: string
|
||||
submit: boolean
|
||||
agent?: TuiAgent
|
||||
inputKind: TerminalInputKind
|
||||
}): Promise<boolean> {
|
||||
const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args
|
||||
const { ptyId, content, submit, agent } = args
|
||||
const { inputKind } = args
|
||||
const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined
|
||||
try {
|
||||
// Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent
|
||||
// paste on this PTY can slip between them and submit a half-written prompt.
|
||||
return await runTerminalPtyInputTransaction(ptyId, async () => {
|
||||
const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind)
|
||||
const pasted = await sendAgentDraftPasteContentNow(ptyId, content, inputKind)
|
||||
if (!pasted || !submit) {
|
||||
return pasted
|
||||
}
|
||||
@@ -253,14 +220,14 @@ async function sendBracketedPasteToAgent(args: {
|
||||
// Enter arrive in the same PTY write. Split the submit into the next turn so
|
||||
// the TUI processes bracketed-paste termination before handling Enter.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS))
|
||||
const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind)
|
||||
const submitted = await sendRuntimePtyInputVerified(ptyId, '\r', inputKind)
|
||||
|
||||
if (submitRetryDelayMs !== undefined) {
|
||||
// Why: agents that render their composer before Enter is live silently eat
|
||||
// the first Enter; the retry is best-effort and never downgrades `submitted`.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, submitRetryDelayMs))
|
||||
try {
|
||||
await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind)
|
||||
await sendRuntimePtyInputVerified(ptyId, '\r', inputKind)
|
||||
} catch {
|
||||
// Why: a rejected retry leaves the first Enter's verdict untouched.
|
||||
}
|
||||
@@ -278,7 +245,6 @@ function waitForAgentDraftInputReadyOnTab(args: {
|
||||
spawnTimeoutMs: number
|
||||
readinessTimeoutMs: number
|
||||
readySignal: Parameters<typeof waitForAgentDraftInputReady>[2]
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
}): Promise<{ ptyId: string; ready: boolean } | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selectedPtyId: string | null = null
|
||||
@@ -308,12 +274,9 @@ function waitForAgentDraftInputReadyOnTab(args: {
|
||||
unsubscribeStore?.()
|
||||
// Why: Zustand subscribers run inside updateTabPtyId. Registering the
|
||||
// sidecar here precedes the transport's immediate pre-handler drain.
|
||||
void waitForAgentDraftInputReady(
|
||||
ptyId,
|
||||
args.readinessTimeoutMs,
|
||||
args.readySignal,
|
||||
args.settings
|
||||
).then((ready) => finish({ ptyId, ready }))
|
||||
void waitForAgentDraftInputReady(ptyId, args.readinessTimeoutMs, args.readySignal).then(
|
||||
(ready) => finish({ ptyId, ready })
|
||||
)
|
||||
}
|
||||
const bindFromState = (state: ReturnType<typeof useAppStore.getState>): void => {
|
||||
const ptyId = state.ptyIdsByTabId[args.tabId]?.[0]
|
||||
@@ -331,14 +294,13 @@ function waitForAgentDraftInputReadyOnTab(args: {
|
||||
async function waitForExpectedAgentOnPty(
|
||||
ptyId: string,
|
||||
expectedProcess: string,
|
||||
timeoutMs: number,
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const process = await withDeadline(
|
||||
inspectRuntimeTerminalProcess(settings, ptyId),
|
||||
inspectRuntimeTerminalProcess(ptyId),
|
||||
Math.max(0, deadline - Date.now())
|
||||
)
|
||||
if (!process) {
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function waitForAgentReady(
|
||||
}
|
||||
|
||||
try {
|
||||
const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId)
|
||||
const process = await inspectRuntimeTerminalProcess(ptyId)
|
||||
const foreground = process.foregroundProcess?.toLowerCase() ?? ''
|
||||
if (isExpectedAgentProcess(foreground, expectedProcess)) {
|
||||
return { ready: true, reason: 'foreground-match' }
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('spurious shell readings on Codex-launched panes', () => {
|
||||
})
|
||||
|
||||
it('never routes a remote runtime pane through the local confirm bridge', async () => {
|
||||
seedPane({ ptyId: 'remote:term-1', launchAgent: 'codex' })
|
||||
seedPane({ ptyId: 'remote:env-1@@term-1', launchAgent: 'codex' })
|
||||
useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
|
||||
@@ -388,14 +388,14 @@ describe('markLiveCodexSessionsForRestart', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('inspects remote runtime PTYs through the active runtime environment', async () => {
|
||||
it('inspects remote runtime PTYs on the server their id names', async () => {
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
tabsByWorktree: {
|
||||
wt1: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'remote:term-1',
|
||||
ptyId: 'remote:env-1@@term-1',
|
||||
worktreeId: 'wt1',
|
||||
title: 'orca-1',
|
||||
customTitle: null,
|
||||
@@ -406,7 +406,7 @@ describe('markLiveCodexSessionsForRestart', () => {
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['remote:term-1']
|
||||
'tab-1': ['remote:env-1@@term-1']
|
||||
}
|
||||
})
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
@@ -434,7 +434,7 @@ describe('markLiveCodexSessionsForRestart', () => {
|
||||
params: { terminal: 'term-1' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:term-1']).toEqual({
|
||||
expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:env-1@@term-1']).toEqual({
|
||||
previousAccountLabel: ACCOUNT_A,
|
||||
nextAccountLabel: ACCOUNT_B
|
||||
})
|
||||
|
||||
@@ -92,7 +92,6 @@ async function readRecordedCodexPaneLanes(
|
||||
* null (scan failed or unsupported) both keep today's ineligible outcome.
|
||||
*/
|
||||
async function isConfirmedCodexForegroundDespiteShellReading(
|
||||
state: AppState,
|
||||
ptyId: string,
|
||||
launchAgent: TuiAgent | undefined,
|
||||
inspection: RuntimeTerminalProcessInspection
|
||||
@@ -105,7 +104,7 @@ async function isConfirmedCodexForegroundDespiteShellReading(
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const confirmed = await confirmRuntimeTerminalForegroundProcess(state.settings, ptyId)
|
||||
const confirmed = await confirmRuntimeTerminalForegroundProcess(ptyId)
|
||||
return isCodexForegroundProcess(confirmed)
|
||||
}
|
||||
|
||||
@@ -159,7 +158,7 @@ async function scanCodexPanes(
|
||||
laneSource: lane.source
|
||||
}
|
||||
}
|
||||
const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then(
|
||||
const inspection = await inspectRuntimeTerminalProcess(ptyId).then(
|
||||
(result) => result,
|
||||
// Why: one stale remote pane must not hide restart notices for other confirmed Codex panes.
|
||||
() => null
|
||||
@@ -167,12 +166,7 @@ async function scanCodexPanes(
|
||||
const eligible =
|
||||
inspection !== null &&
|
||||
(isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }) ||
|
||||
(await isConfirmedCodexForegroundDespiteShellReading(
|
||||
state,
|
||||
ptyId,
|
||||
tab.launchAgent,
|
||||
inspection
|
||||
)))
|
||||
(await isConfirmedCodexForegroundDespiteShellReading(ptyId, tab.launchAgent, inspection)))
|
||||
return {
|
||||
ptyId,
|
||||
eligible,
|
||||
|
||||
@@ -244,7 +244,6 @@ export async function launchAgentBackgroundSession(
|
||||
throw new Error('Runtime terminal id is invalid.')
|
||||
}
|
||||
unsubscribeData = await subscribeToRuntimeTerminalData(
|
||||
store.settings,
|
||||
ptyId,
|
||||
`desktop:background:${tab.id}`,
|
||||
handleData
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
inspectRuntimeTerminalProcess: vi.fn(async () => null),
|
||||
sendRuntimePtyInputVerified: async (_settings: unknown, _ptyId: string, data: string) => {
|
||||
sendRuntimePtyInputVerified: async (_ptyId: string, data: string) => {
|
||||
testState.writes.push({ at: Date.now(), data })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: () => store.settings,
|
||||
pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady
|
||||
}))
|
||||
|
||||
@@ -294,7 +293,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
|
||||
})
|
||||
|
||||
expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'fix the spinner\r',
|
||||
'launch'
|
||||
@@ -411,7 +409,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
|
||||
})
|
||||
|
||||
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: 'review this before sending',
|
||||
agent: 'claude',
|
||||
@@ -455,7 +452,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
|
||||
})
|
||||
|
||||
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({
|
||||
tabId: 'agent-tab',
|
||||
ptyId: 'agent-pty',
|
||||
content: 'Linear context draft',
|
||||
agent: 'codex',
|
||||
@@ -512,7 +508,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
|
||||
|
||||
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1)
|
||||
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-delayed',
|
||||
content: 'https://github.com/stablyai/orca/pull/2051',
|
||||
agent: 'codex',
|
||||
@@ -594,7 +589,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
|
||||
|
||||
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1)
|
||||
expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'startup-pty',
|
||||
content: 'linked draft',
|
||||
agent: 'codex',
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getSettingsForAgentTabRuntimeOwner,
|
||||
pasteDraftToAgentPtyWhenReady
|
||||
} from '@/lib/agent-paste-draft'
|
||||
import { pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft'
|
||||
import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery'
|
||||
import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast'
|
||||
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
@@ -245,12 +242,11 @@ export async function ensureAgentStartupInTerminal(args: {
|
||||
}
|
||||
|
||||
async function deliverAgentStartupToTerminal(
|
||||
tabId: string,
|
||||
_tabId: string,
|
||||
ptyId: string,
|
||||
startup: AgentStartupPlan
|
||||
): Promise<void> {
|
||||
const draftPrompt = startup.draftPrompt ?? null
|
||||
const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId)
|
||||
// Why: followupPrompt is the legacy path for stdin-after-start agents
|
||||
// (aider, goose, etc.) that need their initial prompt typed into the live
|
||||
// session and submitted. Wait until the agent owns the PTY before writing.
|
||||
@@ -258,8 +254,7 @@ async function deliverAgentStartupToTerminal(
|
||||
const delivered = await sendFollowupPromptWhenAgentReady({
|
||||
ptyId,
|
||||
expectedProcess: startup.expectedProcess,
|
||||
prompt: startup.followupPrompt,
|
||||
settings: runtimeSettings
|
||||
prompt: startup.followupPrompt
|
||||
})
|
||||
// Why: a dropped follow-up is otherwise silent — surface the same toast the
|
||||
// draft path uses so the user knows to open the workspace and paste it.
|
||||
@@ -273,7 +268,6 @@ async function deliverAgentStartupToTerminal(
|
||||
// launch-work-item-direct flow so both behave identically.
|
||||
if (draftPrompt) {
|
||||
await pasteDraftToAgentPtyWhenReady({
|
||||
tabId,
|
||||
ptyId,
|
||||
content: draftPrompt,
|
||||
agent: startup.agent,
|
||||
|
||||
@@ -86,7 +86,6 @@ async function paneRouteKinds(): Promise<TerminalInputKind[]> {
|
||||
async function helperRouteKinds(): Promise<TerminalInputKind[]> {
|
||||
const { kinds } = stubPtyApi()
|
||||
const pasted = pasteDraftToAgentPtyWhenReady({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
content: DRAFT,
|
||||
agent: 'aider'
|
||||
|
||||
@@ -109,14 +109,7 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
it('sends input through the PTY owning environment instead of the active one', async () => {
|
||||
expect(
|
||||
sendRuntimePtyInput(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
@@ -134,12 +127,10 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
it('inspects the PTY owning environment instead of the active one', async () => {
|
||||
await expect(
|
||||
inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1'
|
||||
)
|
||||
).resolves.toMatchObject({ foregroundProcess: 'bash', hasChildProcesses: true })
|
||||
await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-1')).resolves.toMatchObject({
|
||||
foregroundProcess: 'bash',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
@@ -156,11 +147,7 @@ describe('runtime terminal owner routing', () => {
|
||||
// scan and answered `unverifiable`, which the guard reads as running work -- a confirmation
|
||||
// dialog on every idle close of a remote Windows pane. Found by review on #18591.
|
||||
it('forwards scanChildProcesses to the PTY owning environment', async () => {
|
||||
await inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
{ scanChildProcesses: true }
|
||||
)
|
||||
await inspectRuntimeTerminalProcess('remote:env-1@@terminal-1', { scanChildProcesses: true })
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
@@ -171,11 +158,10 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
it('forwards scanChildProcesses alongside the incarnation fence', async () => {
|
||||
await inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
{ expectedIncarnationId: 'incarnation-1', scanChildProcesses: true }
|
||||
)
|
||||
await inspectRuntimeTerminalProcess('remote:env-1@@terminal-1', {
|
||||
expectedIncarnationId: 'incarnation-1',
|
||||
scanChildProcesses: true
|
||||
})
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
@@ -190,10 +176,7 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
it('omits scanChildProcesses when the caller is only polling', async () => {
|
||||
await inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1'
|
||||
)
|
||||
await inspectRuntimeTerminalProcess('remote:env-1@@terminal-1')
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
@@ -212,12 +195,7 @@ describe('runtime terminal owner routing', () => {
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-1')).resolves.toEqual({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
verdict: 'unverifiable',
|
||||
@@ -232,7 +210,7 @@ describe('runtime terminal owner routing', () => {
|
||||
foregroundProcessEvidence: liveEvidence('pty-1', 'codex')
|
||||
})
|
||||
|
||||
await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toMatchObject({
|
||||
await expect(inspectRuntimeTerminalProcess('ssh:host@@pty-1')).resolves.toMatchObject({
|
||||
foregroundProcess: 'codex',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
@@ -247,7 +225,7 @@ describe('runtime terminal owner routing', () => {
|
||||
hasChildProcesses: true
|
||||
})
|
||||
|
||||
await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toEqual({
|
||||
await expect(inspectRuntimeTerminalProcess('ssh:host@@pty-1')).resolves.toEqual({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
verdict: 'unverifiable',
|
||||
@@ -263,12 +241,7 @@ describe('runtime terminal owner routing', () => {
|
||||
error: { code, message: code }
|
||||
})
|
||||
|
||||
await expect(
|
||||
inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-stale'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-stale')).resolves.toEqual({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
verdict: 'unverifiable',
|
||||
@@ -280,10 +253,7 @@ describe('runtime terminal owner routing', () => {
|
||||
it('maps lost contact and timeout to client-only unverifiable results', async () => {
|
||||
runtimeCall.mockRejectedValueOnce(new Error('SSH connection lost, reconnecting...'))
|
||||
await expect(
|
||||
inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-transport'
|
||||
)
|
||||
inspectRuntimeTerminalProcess('remote:env-1@@terminal-transport')
|
||||
).resolves.toEqual({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
@@ -293,10 +263,7 @@ describe('runtime terminal owner routing', () => {
|
||||
|
||||
runtimeCall.mockRejectedValueOnce(new Error('Request timed out before completion'))
|
||||
await expect(
|
||||
inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-timeout'
|
||||
)
|
||||
inspectRuntimeTerminalProcess('remote:env-1@@terminal-timeout')
|
||||
).resolves.toMatchObject({ verdict: 'unverifiable', reason: 'timeout' })
|
||||
})
|
||||
|
||||
@@ -319,12 +286,9 @@ describe('runtime terminal owner routing', () => {
|
||||
|
||||
it('still throws an unclassified programming error', async () => {
|
||||
runtimeCall.mockRejectedValueOnce(new Error('inspection invariant violated'))
|
||||
await expect(
|
||||
inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-bug'
|
||||
)
|
||||
).rejects.toThrow('inspection invariant violated')
|
||||
await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-bug')).rejects.toThrow(
|
||||
'inspection invariant violated'
|
||||
)
|
||||
})
|
||||
|
||||
it('records accepted fire-and-forget runtime input against the owning pane key', async () => {
|
||||
@@ -345,14 +309,7 @@ describe('runtime terminal owner routing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useAppStore.getState().lastTerminalInputAtByPaneKey[PANE_KEY]).toEqual(
|
||||
@@ -381,14 +338,7 @@ describe('runtime terminal owner routing', () => {
|
||||
recordRuntimeTerminalInputForPtyId('remote:env-1@@terminal-1', 123)
|
||||
useAppStore.setState({ lastTerminalInputAtByPaneKey: {} })
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true)
|
||||
const nextLeafId = '22222222-2222-4222-8222-222222222222'
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
@@ -431,14 +381,7 @@ describe('runtime terminal owner routing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeCall).toHaveBeenCalled()
|
||||
@@ -453,12 +396,7 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-stale',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
sendRuntimePtyInputVerified('remote:env-1@@terminal-stale', 'x', 'driving')
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
@@ -470,12 +408,7 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
sendRuntimePtyInputVerified('remote:env-1@@terminal-1', 'x', 'driving')
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
@@ -493,9 +426,7 @@ describe('runtime terminal owner routing', () => {
|
||||
it('uses accepted local writes for verified input', async () => {
|
||||
localWriteAccepted.mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving')
|
||||
).resolves.toBe(true)
|
||||
await expect(sendRuntimePtyInputVerified('local-pty', 'x', 'driving')).resolves.toBe(true)
|
||||
|
||||
expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving')
|
||||
expect(localWrite).not.toHaveBeenCalled()
|
||||
@@ -504,9 +435,7 @@ describe('runtime terminal owner routing', () => {
|
||||
it('rejects oversized fire-and-forget local input before IPC writes', () => {
|
||||
const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving')
|
||||
).toBe(false)
|
||||
expect(sendRuntimePtyInput('local-pty', text, 'driving')).toBe(false)
|
||||
|
||||
expect(localWrite).not.toHaveBeenCalled()
|
||||
expect(localWriteAccepted).not.toHaveBeenCalled()
|
||||
@@ -515,14 +444,7 @@ describe('runtime terminal owner routing', () => {
|
||||
it('rejects oversized fire-and-forget remote input before runtime RPC', () => {
|
||||
const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
text,
|
||||
'driving'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(sendRuntimePtyInput('remote:env-1@@terminal-1', text, 'driving')).toBe(false)
|
||||
|
||||
expect(runtimeTransportCall).not.toHaveBeenCalled()
|
||||
expect(localWrite).not.toHaveBeenCalled()
|
||||
@@ -533,9 +455,7 @@ describe('runtime terminal owner routing', () => {
|
||||
try {
|
||||
const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving')
|
||||
).toBe(true)
|
||||
expect(sendRuntimePtyInput('local-pty', text, 'driving')).toBe(true)
|
||||
expect(localWrite).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -552,9 +472,7 @@ describe('runtime terminal owner routing', () => {
|
||||
try {
|
||||
const text = makeByteOversizedTerminalInput()
|
||||
|
||||
expect(
|
||||
sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving')
|
||||
).toBe(true)
|
||||
expect(sendRuntimePtyInput('local-pty', text, 'driving')).toBe(true)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
@@ -568,14 +486,7 @@ describe('runtime terminal owner routing', () => {
|
||||
it('rejects oversized verified input before fallback fire-and-forget writes', async () => {
|
||||
const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
'local-pty',
|
||||
text,
|
||||
'driving'
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
await expect(sendRuntimePtyInputVerified('local-pty', text, 'driving')).resolves.toBe(false)
|
||||
|
||||
expect(localWriteAccepted).not.toHaveBeenCalled()
|
||||
expect(localWrite).not.toHaveBeenCalled()
|
||||
@@ -586,12 +497,7 @@ describe('runtime terminal owner routing', () => {
|
||||
localWriteAccepted.mockResolvedValue(true)
|
||||
try {
|
||||
const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
|
||||
const accepted = sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
'local-pty',
|
||||
text,
|
||||
'driving'
|
||||
)
|
||||
const accepted = sendRuntimePtyInputVerified('local-pty', text, 'driving')
|
||||
|
||||
expect(localWriteAccepted).not.toHaveBeenCalled()
|
||||
|
||||
@@ -609,12 +515,7 @@ describe('runtime terminal owner routing', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const text = makeByteOversizedTerminalInput()
|
||||
const accepted = sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
'local-pty',
|
||||
text,
|
||||
'driving'
|
||||
)
|
||||
const accepted = sendRuntimePtyInputVerified('local-pty', text, 'driving')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
@@ -645,12 +546,7 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
sendRuntimePtyInputVerified('remote:env-1@@terminal-1', 'x', 'driving')
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(useAppStore.getState().lastTerminalInputAtByPaneKey[PANE_KEY]).toEqual(
|
||||
@@ -677,12 +573,7 @@ describe('runtime terminal owner routing', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'x',
|
||||
'driving'
|
||||
)
|
||||
sendRuntimePtyInputVerified('remote:env-1@@terminal-1', 'x', 'driving')
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(useAppStore.getState().lastTerminalInputAtByPaneKey[PANE_KEY]).toBeUndefined()
|
||||
@@ -848,9 +739,7 @@ describe('runtime terminal owner routing', () => {
|
||||
it('reports success after fallback fire-and-forget writes when local acceptance cannot be verified', async () => {
|
||||
localWriteAccepted.mockResolvedValue(false)
|
||||
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving')
|
||||
).resolves.toBe(true)
|
||||
await expect(sendRuntimePtyInputVerified('local-pty', 'x', 'driving')).resolves.toBe(true)
|
||||
|
||||
expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving')
|
||||
expect(localWrite).toHaveBeenCalledWith('local-pty', 'x', 'driving')
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
|
||||
import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input'
|
||||
import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording'
|
||||
export { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import { callRuntimeRpc } from './runtime-rpc-client'
|
||||
import {
|
||||
getRemoteRuntimePtyEnvironmentId,
|
||||
getRemoteRuntimeTerminalHandle
|
||||
getRemoteRuntimePtyOwner
|
||||
} from './runtime-terminal-stream'
|
||||
import { parseAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
|
||||
import {
|
||||
classifyTerminalProcessInspectionFailure,
|
||||
clientOnlyUnverifiableInspection,
|
||||
@@ -67,17 +67,12 @@ function normalizeInspectionResult(
|
||||
}
|
||||
|
||||
export async function inspectRuntimeTerminalProcess(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean; steadyState?: boolean }
|
||||
): Promise<RuntimeTerminalProcessInspection> {
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
const target = ownerEnvironmentId
|
||||
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
|
||||
: getActiveRuntimeTarget(settings)
|
||||
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
const owner = getRemoteRuntimePtyOwner(ptyId)
|
||||
const remote = isRemoteInspectionPtyId(ptyId)
|
||||
if (target.kind !== 'environment' || !terminal) {
|
||||
if (!owner) {
|
||||
try {
|
||||
const result = await (options
|
||||
? window.api.pty.inspectProcess(ptyId, options)
|
||||
@@ -94,10 +89,10 @@ export async function inspectRuntimeTerminalProcess(
|
||||
|
||||
try {
|
||||
const result = await callRuntimeRpc<{ process: RuntimeTerminalProcessInspection }>(
|
||||
target,
|
||||
owner.target,
|
||||
'terminal.inspectProcess',
|
||||
{
|
||||
terminal,
|
||||
terminal: owner.terminal,
|
||||
...(options?.expectedIncarnationId
|
||||
? { expectedIncarnationId: options.expectedIncarnationId }
|
||||
: {}),
|
||||
@@ -126,14 +121,10 @@ export async function inspectRuntimeTerminalProcess(
|
||||
* must read as "no new evidence", never as a shell confirmation.
|
||||
*/
|
||||
export async function confirmRuntimeTerminalForegroundProcess(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string
|
||||
): Promise<string | null> {
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
const target = ownerEnvironmentId
|
||||
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
|
||||
: getActiveRuntimeTarget(settings)
|
||||
if (target.kind === 'environment' && getRemoteRuntimeTerminalHandle(ptyId)) {
|
||||
// Why any remote id: an id without an owner segment still runs off this host, never locally.
|
||||
if (isRemoteRuntimePtyId(ptyId)) {
|
||||
return null
|
||||
}
|
||||
const confirmForegroundProcess = window.api.pty.confirmForegroundProcess
|
||||
@@ -145,7 +136,6 @@ export async function confirmRuntimeTerminalForegroundProcess(
|
||||
}
|
||||
|
||||
export function sendRuntimePtyInput(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
data: string,
|
||||
inputKind: TerminalInputKind
|
||||
@@ -160,38 +150,33 @@ export function sendRuntimePtyInput(
|
||||
void tooLarge
|
||||
.then((resolvedTooLarge) => {
|
||||
if (!resolvedTooLarge) {
|
||||
sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind)
|
||||
sendRuntimePtyInputWithinLimit(ptyId, data, inputKind)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return true
|
||||
}
|
||||
return sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind)
|
||||
return sendRuntimePtyInputWithinLimit(ptyId, data, inputKind)
|
||||
}
|
||||
|
||||
// Why the kind reaches only the local write: terminal.send has no launch kind, and its query-reply
|
||||
// kind is for mobile clients, so the host classifies a desktop's environment write by its bytes.
|
||||
function sendRuntimePtyInputWithinLimit(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
data: string,
|
||||
inputKind: TerminalInputKind
|
||||
): boolean {
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
const target = ownerEnvironmentId
|
||||
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
|
||||
: getActiveRuntimeTarget(settings)
|
||||
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
if (target.kind !== 'environment' || !terminal) {
|
||||
const owner = getRemoteRuntimePtyOwner(ptyId)
|
||||
if (!owner) {
|
||||
window.api.pty.write(ptyId, data, inputKind)
|
||||
recordRuntimeTerminalInputForPtyId(ptyId)
|
||||
return true
|
||||
}
|
||||
|
||||
void callRuntimeRpc<{ send: RuntimeTerminalSend }>(
|
||||
target,
|
||||
owner.target,
|
||||
'terminal.send',
|
||||
{ terminal, text: data, client: DESKTOP_RUNTIME_CLIENT },
|
||||
{ terminal: owner.terminal, text: data, client: DESKTOP_RUNTIME_CLIENT },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@@ -82,7 +82,6 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
const watcher = vi.fn()
|
||||
|
||||
const dispose = await subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'watcher-1',
|
||||
watcher
|
||||
@@ -151,7 +150,6 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
|
||||
it('echoes the opaque stream generation in cumulative source-range ACKs', async () => {
|
||||
const dispose = await subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'watcher-1',
|
||||
vi.fn()
|
||||
@@ -195,7 +193,6 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
it('can start at the live tail without replaying the initial snapshot', async () => {
|
||||
const watcher = vi.fn()
|
||||
const subscription = subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'watcher-1',
|
||||
watcher,
|
||||
@@ -248,13 +245,11 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
|
||||
it('keeps the shared terminal multiplexer until the last watcher closes', async () => {
|
||||
const firstDispose = await subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'watcher-1',
|
||||
vi.fn()
|
||||
)
|
||||
const secondDispose = await subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-2',
|
||||
'watcher-2',
|
||||
vi.fn()
|
||||
@@ -276,12 +271,7 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
runtimeSubscribe.mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
await expect(
|
||||
subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'watcher-1',
|
||||
vi.fn()
|
||||
)
|
||||
subscribeToRuntimeTerminalData('remote:env-1@@terminal-1', 'watcher-1', vi.fn())
|
||||
).rejects.toThrow('offline')
|
||||
|
||||
expect(sendBinary).not.toHaveBeenCalled()
|
||||
@@ -302,7 +292,6 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
})
|
||||
|
||||
const subscriptionPromise = subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
'remote:env-1@@terminal-1',
|
||||
'watcher-1',
|
||||
vi.fn()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import { RuntimeRpcCallError, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import { RuntimeRpcCallError } from './runtime-rpc-client'
|
||||
import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer'
|
||||
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
|
||||
|
||||
@@ -27,6 +26,22 @@ export function getRemoteRuntimePtyEnvironmentId(ptyId: string): string | null {
|
||||
return parseRemoteRuntimePtyId(ptyId)?.environmentId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The server a remote PTY belongs to, named by the id itself. An id without an owner segment names
|
||||
* no server, so it is never dialed on whichever server happens to be focused.
|
||||
*/
|
||||
export function getRemoteRuntimePtyOwner(
|
||||
ptyId: string
|
||||
): { target: { kind: 'environment'; environmentId: string }; terminal: string } | null {
|
||||
const parsed = parseRemoteRuntimePtyId(ptyId)
|
||||
return parsed?.environmentId && parsed.handle
|
||||
? {
|
||||
target: { kind: 'environment', environmentId: parsed.environmentId },
|
||||
terminal: parsed.handle
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export function runtimeTerminalErrorMessage(error: unknown): string {
|
||||
if (error instanceof RuntimeRpcCallError) {
|
||||
return error.message
|
||||
@@ -35,20 +50,16 @@ export function runtimeTerminalErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function subscribeToRuntimeTerminalData(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
clientId: string,
|
||||
watcher: (data: string) => void,
|
||||
options?: RuntimeTerminalDataSubscriptionOptions
|
||||
): Promise<() => void> {
|
||||
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
const target = ownerEnvironmentId
|
||||
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
|
||||
: getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment' || !terminal) {
|
||||
const owner = getRemoteRuntimePtyOwner(ptyId)
|
||||
if (!owner) {
|
||||
return () => {}
|
||||
}
|
||||
const { target, terminal } = owner
|
||||
|
||||
let resolveLiveTail: (() => void) | null = null
|
||||
let rejectLiveTail: ((error: Error) => void) | null = null
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('verified input that requires provider settlement', () => {
|
||||
] as const)('reads the %s remote verdict', async (_label, send, accepted) => {
|
||||
hostReply(send)
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, REMOTE_PTY, 'x', 'driving', {
|
||||
sendRuntimePtyInputVerified(REMOTE_PTY, 'x', 'driving', {
|
||||
requireWriteSettlement: true
|
||||
})
|
||||
).resolves.toBe(accepted)
|
||||
@@ -67,7 +67,7 @@ describe('verified input that requires provider settlement', () => {
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, REMOTE_PTY, 'x', 'driving', {
|
||||
sendRuntimePtyInputVerified(REMOTE_PTY, 'x', 'driving', {
|
||||
requireWriteSettlement: true
|
||||
})
|
||||
).rejects.toThrow('acknowledgment unavailable')
|
||||
@@ -79,9 +79,9 @@ describe('verified input that requires provider settlement', () => {
|
||||
describe('local verified input', () => {
|
||||
it('keeps the fire-and-forget fallback for ordinary input the provider cannot accept', async () => {
|
||||
localWriteAccepted.mockResolvedValue(false)
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', 'x', 'driving')
|
||||
).resolves.toBe(true)
|
||||
await expect(sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', 'x', 'driving')).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(localWriteAccepted).toHaveBeenCalledWith('ssh:conn-1@@pty-1', 'x', 'driving')
|
||||
expect(localWrite).toHaveBeenCalledWith('ssh:conn-1@@pty-1', 'x', 'driving')
|
||||
})
|
||||
@@ -90,17 +90,36 @@ describe('local verified input', () => {
|
||||
const settled = { requireWriteSettlement: true } as const
|
||||
localWriteAccepted.mockResolvedValueOnce(true)
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', 'answer', 'driving', settled)
|
||||
sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', 'answer', 'driving', settled)
|
||||
).resolves.toBe(true)
|
||||
localWriteAccepted.mockResolvedValueOnce(false)
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', '1', 'driving', settled)
|
||||
sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', '1', 'driving', settled)
|
||||
).resolves.toBe(false)
|
||||
localWriteAccepted.mockRejectedValueOnce(new Error('acknowledgment unavailable'))
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', '\r', 'driving', settled)
|
||||
sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', '\r', 'driving', settled)
|
||||
).rejects.toThrow('acknowledgment unavailable')
|
||||
expect(localWriteAccepted).toHaveBeenCalledWith('ssh:conn-1@@pty-1', '\r', 'driving', settled)
|
||||
expect(localWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('owner of the PTY', () => {
|
||||
it('never dials a server for a remote id that names no owner', async () => {
|
||||
localWriteAccepted.mockResolvedValue(false)
|
||||
// Why: this id used to reach whichever server was focused, even when another one owned it.
|
||||
await sendRuntimePtyInputVerified('remote:terminal-1', 'x', 'driving', {
|
||||
requireWriteSettlement: true
|
||||
})
|
||||
expect(runtimeSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends to the server the id names', async () => {
|
||||
hostReply({ accepted: true })
|
||||
await sendRuntimePtyInputVerified(REMOTE_PTY, 'x', 'driving')
|
||||
expect(runtimeSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selector: 'env-1', method: 'terminal.send' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
|
||||
import type { TerminalInputKind } from '../../../shared/terminal-input-kind'
|
||||
import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input'
|
||||
import { readTerminalSendAcknowledgment } from '../../../shared/terminal-send-acknowledgment'
|
||||
import { classifyTerminalProcessInspectionFailure } from '../../../shared/terminal-process-inspection'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import {
|
||||
getRemoteRuntimePtyEnvironmentId,
|
||||
getRemoteRuntimeTerminalHandle
|
||||
} from './runtime-terminal-stream'
|
||||
import { callRuntimeRpc } from './runtime-rpc-client'
|
||||
import { getRemoteRuntimePtyOwner } from './runtime-terminal-stream'
|
||||
import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording'
|
||||
|
||||
const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const
|
||||
@@ -18,7 +14,6 @@ const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const
|
||||
* current host for its acknowledgment; a lost one rejects.
|
||||
*/
|
||||
export async function sendRuntimePtyInputVerified(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
data: string,
|
||||
inputKind: TerminalInputKind,
|
||||
@@ -28,12 +23,8 @@ export async function sendRuntimePtyInputVerified(
|
||||
if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) {
|
||||
return false
|
||||
}
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
const target = ownerEnvironmentId
|
||||
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
|
||||
: getActiveRuntimeTarget(settings)
|
||||
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
if (target.kind !== 'environment' || !terminal) {
|
||||
const owner = getRemoteRuntimePtyOwner(ptyId)
|
||||
if (!owner) {
|
||||
if (options?.requireWriteSettlement) {
|
||||
// Why: an answer that dismisses its card must not fall back to an unacknowledged write.
|
||||
const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind, options)
|
||||
@@ -54,6 +45,7 @@ export async function sendRuntimePtyInputVerified(
|
||||
return accepted
|
||||
}
|
||||
|
||||
const { target, terminal } = owner
|
||||
try {
|
||||
const result = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
|
||||
target,
|
||||
|
||||
@@ -131,7 +131,6 @@ describe('paired session-tab agent completion notifications', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID),
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -169,7 +168,6 @@ describe('paired session-tab agent completion notifications', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey,
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -263,7 +261,6 @@ describe('paired session-tab agent completion notifications', () => {
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID),
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -285,7 +282,6 @@ describe('paired session-tab agent completion notifications', () => {
|
||||
paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID),
|
||||
statusLane: 'hook',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
|
||||
@@ -36,12 +36,15 @@ vi.mock('../../../src/main/ipc/pty/provider/registry', async (importOriginal) =>
|
||||
})
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
getActiveRuntimeTarget: () => ({ kind: 'local' }),
|
||||
callRuntimeRpc: (...args: unknown[]) => io.rpc(...args)
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-terminal-stream', () => ({
|
||||
getRemoteRuntimePtyEnvironmentId: (id: string) => (id.startsWith('remote:') ? 'owner' : null),
|
||||
getRemoteRuntimeTerminalHandle: (id: string) => (id.startsWith('remote:') ? 'terminal' : null)
|
||||
getRemoteRuntimeTerminalHandle: (id: string) => (id.startsWith('remote:') ? 'terminal' : null),
|
||||
getRemoteRuntimePtyOwner: (id: string) =>
|
||||
id.startsWith('remote:')
|
||||
? { target: { kind: 'environment', environmentId: 'owner' }, terminal: 'terminal' }
|
||||
: null
|
||||
}))
|
||||
|
||||
export function createSshDelivery(mode: 'accepted' | 'lost' | 'slow' = 'accepted') {
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
[{ indices: [], other: text }]
|
||||
)
|
||||
const onSettled = vi.fn()
|
||||
sendNativeChatAskAnswer(null, ssh.id, groups, onSettled)
|
||||
sendNativeChatAskAnswer(ssh.id, groups, onSettled)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(ssh.bytes).not.toContain('\r')
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
@@ -60,7 +60,6 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
close = ssh.close
|
||||
const onSettled = vi.fn()
|
||||
const handle = sendNativeChatAskAnswer(
|
||||
null,
|
||||
ssh.id,
|
||||
[{ text: `${'X'.repeat(32768)}TAIL` }, { raw: '\r' }],
|
||||
onSettled
|
||||
@@ -78,7 +77,7 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
const ssh = createSshDelivery()
|
||||
close = ssh.close
|
||||
const onDeliverySettled = vi.fn()
|
||||
sendNativeChatMessage(null, ssh.id, 'body', { onDeliverySettled })
|
||||
sendNativeChatMessage(ssh.id, 'body', { onDeliverySettled })
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(ssh.bytes.join('')).toBe('\x15body\r')
|
||||
expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(true)
|
||||
@@ -103,15 +102,9 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
}
|
||||
return reply.result
|
||||
})
|
||||
const pending = sendRuntimePtyInputVerified(
|
||||
null,
|
||||
'remote:owner@@terminal',
|
||||
'\x1b',
|
||||
'driving',
|
||||
{
|
||||
requireWriteSettlement: true
|
||||
}
|
||||
)
|
||||
const pending = sendRuntimePtyInputVerified('remote:owner@@terminal', '\x1b', 'driving', {
|
||||
requireWriteSettlement: true
|
||||
})
|
||||
await (mode === 'accepted'
|
||||
? expect(pending).resolves.toBe(true)
|
||||
: expect(pending).rejects.toThrow('acknowledgment unavailable'))
|
||||
@@ -145,15 +138,15 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
return reply.result
|
||||
})
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, 'remote:owner@@terminal', 'answer', 'driving', {
|
||||
sendRuntimePtyInputVerified('remote:owner@@terminal', 'answer', 'driving', {
|
||||
requireWriteSettlement: true
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
await expect(sendNativeChatMessageVerified('remote:owner@@terminal', 'body')).resolves.toBe(
|
||||
true
|
||||
)
|
||||
await expect(
|
||||
sendNativeChatMessageVerified(null, 'remote:owner@@terminal', 'body')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
sendAgentDraftPasteContentNow(null, 'remote:owner@@terminal', 'launch', 'launch')
|
||||
sendAgentDraftPasteContentNow('remote:owner@@terminal', 'launch', 'launch')
|
||||
).resolves.toBe(true)
|
||||
expect(ssh.bytes).toEqual(['answer', 'body', '\r', '\x1b[200~launch\x1b[201~'])
|
||||
expect(ssh.settlement).not.toHaveBeenCalled()
|
||||
@@ -164,12 +157,12 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
io.rpc.mockResolvedValue({ send: { handle: 'terminal', accepted: true, bytesWritten: 1 } })
|
||||
const onWriteUnconfirmed = vi.fn()
|
||||
const onWriteRejected = vi.fn()
|
||||
sendNativeChatMessage(null, 'remote:owner@@terminal', 'hello', {
|
||||
sendNativeChatMessage('remote:owner@@terminal', 'hello', {
|
||||
onWriteRejected,
|
||||
onWriteUnconfirmed
|
||||
})
|
||||
const onDeliverySettled = vi.fn()
|
||||
sendNativeChatMessage(null, 'remote:owner@@terminal', 'answer', { onDeliverySettled })
|
||||
sendNativeChatMessage('remote:owner@@terminal', 'answer', { onDeliverySettled })
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(onWriteUnconfirmed).not.toHaveBeenCalled()
|
||||
expect(onWriteRejected).not.toHaveBeenCalled()
|
||||
@@ -179,7 +172,7 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
it('sends ordinary paired input without asking the host for provider settlement', async () => {
|
||||
io.rpc.mockResolvedValue({ send: { handle: 'terminal', accepted: true, bytesWritten: 4 } })
|
||||
await expect(
|
||||
sendRuntimePtyInputVerified(null, 'remote:owner@@terminal', 'body', 'driving')
|
||||
sendRuntimePtyInputVerified('remote:owner@@terminal', 'body', 'driving')
|
||||
).resolves.toBe(true)
|
||||
expect(io.rpc).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -253,7 +246,7 @@ describe('prompt delivery through production IPC, provider and paired host', ()
|
||||
[{ indices: [], other: 'custom answer' }]
|
||||
)
|
||||
const onSettled = vi.fn()
|
||||
sendNativeChatAskAnswer(null, 'remote:owner@@terminal', groups, onSettled)
|
||||
sendNativeChatAskAnswer('remote:owner@@terminal', groups, onSettled)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
expect(ssh.bytes).toEqual(groups.map((group) => ('raw' in group ? group.raw : group.text)))
|
||||
expect(ssh.settlement).not.toHaveBeenCalled()
|
||||
|
||||
@@ -50,7 +50,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => REMOTE_PTY_ID,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -71,7 +70,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => `${REMOTE_PTY_ID}-${index}`,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true
|
||||
@@ -92,7 +90,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => REMOTE_PTY_ID,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -116,7 +113,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => REMOTE_PTY_ID,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -143,7 +139,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => REMOTE_PTY_ID,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -169,7 +164,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => REMOTE_PTY_ID,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
@@ -211,7 +205,6 @@ describe('remote agent completion authority', () => {
|
||||
getPtyId: () => REMOTE_PTY_ID,
|
||||
isRemotePtyId: () => true,
|
||||
getExpectedIncarnationId: () => 'inc-remote',
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion: (_title: string, meta?: AgentCompletionDispatchMeta) => {
|
||||
outcomes.push({
|
||||
|
||||
Reference in New Issue
Block a user