mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Add per-model session-option pickers with a verified Claude model switch
Native chat's model/effort picker now dispatches option commands through a body-then-verified-Enter write path and, for Claude, arms a PTY observer that classifies the cached-history confirmation prompt as applied/rejected/needs interaction before the picker returns — falling back to the terminal only when Claude genuinely requires manual input, and clearing stale truth otherwise. Also reorders the composer's model/effort pills, disambiguates their tooltip labels, and switches disabled-reason strings to a closed enum so producer and localized copy can't drift.
This commit is contained in:
@@ -11,8 +11,18 @@ const mocks = vi.hoisted(() => ({
|
||||
onStop?: () => void
|
||||
sessionOptionsSurface?: SessionOptionsSurface | null
|
||||
} | null,
|
||||
modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown',
|
||||
confirmationObserver: null as {
|
||||
ready: Promise<void>
|
||||
result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'>
|
||||
arm: ReturnType<typeof vi.fn>
|
||||
startDetection: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
} | null,
|
||||
createClaudeModelSwitchConfirmationObserver: vi.fn(),
|
||||
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
sendNativeChatMessageVerified: vi.fn(),
|
||||
trackPendingSend: vi.fn(),
|
||||
setDraft: vi.fn()
|
||||
}))
|
||||
@@ -37,15 +47,27 @@ vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args),
|
||||
sendNativeChatMessageVerified: (...args: unknown[]) =>
|
||||
mocks.sendNativeChatMessageVerified(...args),
|
||||
sendNativeChatMessageWithImageAttachments: vi.fn(),
|
||||
submitNativeChatPrompt: vi.fn()
|
||||
}))
|
||||
vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] }))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() }))
|
||||
vi.mock('./claude-model-switch-confirmation', () => ({
|
||||
createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) =>
|
||||
mocks.createClaudeModelSwitchConfirmationObserver(...args)
|
||||
}))
|
||||
vi.mock('./native-chat-agent-commands', () => ({
|
||||
getAgentSlashCommands: () => []
|
||||
}))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({
|
||||
emitNativeChatMessageSent: vi.fn()
|
||||
}))
|
||||
vi.mock('./use-native-chat-draft', () => ({
|
||||
useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft })
|
||||
}))
|
||||
vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' }))
|
||||
vi.mock('./native-chat-draft-cache', () => ({
|
||||
readNativeChatDraftCache: () => ''
|
||||
}))
|
||||
vi.mock('./NativeChatComposerField', () => ({
|
||||
NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => {
|
||||
mocks.fieldProps = props
|
||||
@@ -62,7 +84,10 @@ vi.mock('./use-native-chat-composer-attachments', () => ({
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-native-chat-composer-paste', () => ({
|
||||
useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() })
|
||||
useNativeChatComposerPaste: () => ({
|
||||
handlePaste: vi.fn(),
|
||||
pasteFromClipboard: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-native-chat-external-attachments', () => ({
|
||||
useNativeChatExternalAttachments: () => ({
|
||||
@@ -70,7 +95,9 @@ vi.mock('./use-native-chat-external-attachments', () => ({
|
||||
resolveAttachmentOwner: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() }))
|
||||
vi.mock('../dictation/dictation-control-events', () => ({
|
||||
dispatchDictationControl: vi.fn()
|
||||
}))
|
||||
vi.mock('./use-native-chat-composer-keydown', () => ({
|
||||
useNativeChatComposerKeyDown: () => vi.fn()
|
||||
}))
|
||||
@@ -87,7 +114,21 @@ describe('NativeChatComposer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.fieldProps = null
|
||||
mocks.modelSwitchOutcome = 'applied'
|
||||
mocks.confirmationObserver = null
|
||||
mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => {
|
||||
const observer = {
|
||||
ready: Promise.resolve(),
|
||||
result: Promise.resolve(mocks.modelSwitchOutcome),
|
||||
arm: vi.fn(),
|
||||
startDetection: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
mocks.confirmationObserver = observer
|
||||
return observer
|
||||
})
|
||||
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
|
||||
mocks.sendNativeChatMessageVerified.mockResolvedValue(true)
|
||||
mocks.sendHandle.settleAfterMs = 500
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
@@ -135,10 +176,11 @@ describe('NativeChatComposer', () => {
|
||||
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
|
||||
})
|
||||
|
||||
it('dispatches a model pick through the slash path without a user bubble', async () => {
|
||||
it('observes a fresh Claude model choice and stays native on success', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
const onSlashCommand = vi.fn()
|
||||
const onOptimisticSend = vi.fn()
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
@@ -146,6 +188,7 @@ describe('NativeChatComposer', () => {
|
||||
agent="claude"
|
||||
onSlashCommand={onSlashCommand}
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -153,9 +196,85 @@ describe('NativeChatComposer', () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/model opus')
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model opus',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(onSlashCommand).toHaveBeenCalledWith('/model opus')
|
||||
expect(onOptimisticSend).not.toHaveBeenCalled()
|
||||
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Opus 4.8'
|
||||
})
|
||||
expect(onSwitchToTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a successful Claude model change after a conversation in native chat', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model fable',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5'
|
||||
})
|
||||
expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce()
|
||||
expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
expect(mocks.confirmationObserver?.startDetection).toHaveBeenCalledOnce()
|
||||
expect(mocks.confirmationObserver?.startDetection.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY
|
||||
)
|
||||
expect(mocks.confirmationObserver?.dispose).toHaveBeenCalledOnce()
|
||||
expect(onSwitchToTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reveals Claude interaction only when the model switch needs user input', async () => {
|
||||
mocks.sendHandle.settleAfterMs = 0
|
||||
mocks.modelSwitchOutcome = 'interaction-required'
|
||||
const onSwitchToTerminal = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model fable',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits for the Codex picker command before switching to the terminal', async () => {
|
||||
@@ -174,7 +293,12 @@ describe('NativeChatComposer', () => {
|
||||
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '')
|
||||
})
|
||||
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/model')
|
||||
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
'/model',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,14 +248,14 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths)
|
||||
const { toggleDictation, startHoldDictation, stopHoldDictation } =
|
||||
useNativeChatDictationActions({ textareaRef, setDictationPressed })
|
||||
const dispatchSessionOptionCommand = useNativeChatSessionOptionCommand({
|
||||
agent,
|
||||
disabled,
|
||||
onSlashCommand,
|
||||
resolveTarget,
|
||||
setHistory,
|
||||
trackPendingSend
|
||||
})
|
||||
const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } =
|
||||
useNativeChatSessionOptionCommand({
|
||||
agent,
|
||||
disabled,
|
||||
onSlashCommand,
|
||||
resolveTarget,
|
||||
setHistory
|
||||
})
|
||||
|
||||
const { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } =
|
||||
useNativeChatSessionOptions({
|
||||
@@ -272,6 +272,12 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
if ((text.trim() === '' && imagePaths.length === 0) || disabled) {
|
||||
return
|
||||
}
|
||||
// Why: block a normal send while a session-option command (e.g. /model) is
|
||||
// still writing its body+delayed-Enter to the same pty, so the two write
|
||||
// sequences can't interleave on one input line.
|
||||
if (isDispatchingSessionOption) {
|
||||
return
|
||||
}
|
||||
const target = resolveTarget()
|
||||
if (!target) {
|
||||
return
|
||||
@@ -328,6 +334,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
draft,
|
||||
imageAttachments,
|
||||
disabled,
|
||||
isDispatchingSessionOption,
|
||||
resolveTarget,
|
||||
onOptimisticSend,
|
||||
onSlashCommand,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode
|
||||
variant?: string
|
||||
size?: string
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('./NativeChatSessionOptionPickers', () => ({
|
||||
NativeChatSessionOptionPickers: () => <div data-testid="session-option-pickers" />
|
||||
}))
|
||||
|
||||
import { NativeChatComposerActions } from './NativeChatComposerActions'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('NativeChatComposerActions', () => {
|
||||
it('places session option pickers immediately beside dictation', () => {
|
||||
render(
|
||||
<NativeChatComposerActions
|
||||
attachDisabled={false}
|
||||
dictationDisabled={false}
|
||||
sendDisabled={false}
|
||||
isWorking={false}
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
onAttach={vi.fn()}
|
||||
onDictationToggle={vi.fn()}
|
||||
onDictationHoldStart={vi.fn()}
|
||||
onDictationHoldEnd={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
sessionOptionsSurface={null}
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
)
|
||||
|
||||
const pickers = screen.getByTestId('session-option-pickers')
|
||||
const dictation = screen.getByRole('button', { name: 'Start dictation' })
|
||||
expect(pickers.nextElementSibling).toBe(dictation)
|
||||
})
|
||||
})
|
||||
@@ -65,13 +65,15 @@ export function NativeChatComposerActions({
|
||||
{translate('components.native-chat.composer.attach', 'Attach file')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{/* Why: keep session controls beside the actions they affect; the
|
||||
model trigger is ordered last so it sits directly next to dictation. */}
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={sessionOptionsSurface}
|
||||
snapshot={sessionOptionsSnapshot}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -117,15 +117,36 @@ describe('NativeChatSessionOptionPickers', () => {
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Opus 4.8')
|
||||
expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain(
|
||||
'High · Fast'
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model: Opus 4.8')
|
||||
expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain(
|
||||
'Effort: High · Fast'
|
||||
)
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Effort' })
|
||||
.compareDocumentPosition(screen.getByRole('button', { name: 'Model' })) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).not.toBe(0)
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers surface={surface} snapshot={[model()]} isWorking={false} />
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Session options' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull()
|
||||
})
|
||||
|
||||
it('names a lone unknown effort control explicitly', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[
|
||||
model(),
|
||||
{ ...effort, kind: { ...effort.kind, currentValue: undefined }, valueSource: 'unknown' }
|
||||
]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort')
|
||||
})
|
||||
|
||||
it('disables both picker triggers while the agent is working', () => {
|
||||
@@ -136,12 +157,41 @@ describe('NativeChatSessionOptionPickers', () => {
|
||||
screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled')
|
||||
).toBe('true')
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Session options' })
|
||||
.parentElement?.getAttribute('data-disabled')
|
||||
screen.getByRole('button', { name: 'Effort' }).parentElement?.getAttribute('data-disabled')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('does not duplicate titles for unknown values or misname generic controls', () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[
|
||||
model({
|
||||
kind: { type: 'select', choices: [] },
|
||||
valueSource: 'unknown'
|
||||
}),
|
||||
{ ...effort, kind: { ...effort.kind, currentValue: undefined }, valueSource: 'unknown' }
|
||||
]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model')
|
||||
expect(screen.getByRole('button', { name: 'Model' }).textContent).not.toContain('Model: Model')
|
||||
expect(screen.getByRole('button', { name: 'Effort' }).textContent).not.toContain(
|
||||
'Effort: Effort'
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), fast]}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain('Fast')
|
||||
expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the unconfirmed hint for dispatched values', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import {
|
||||
nativeChatModelPillLabel,
|
||||
nativeChatOptionsPillLabel,
|
||||
nativeChatOptionsPillTitle,
|
||||
nativeChatSessionChoiceLabel,
|
||||
nativeChatSessionOptionDisabledReason,
|
||||
nativeChatSessionOptionLabel
|
||||
@@ -211,10 +212,7 @@ function NativeChatSessionOptionPickersInner({
|
||||
|
||||
const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason)
|
||||
const modelTooltip = translate('components.native-chat.composer.model', 'Model')
|
||||
const optionsTooltip = translate(
|
||||
'components.native-chat.composer.sessionOptions',
|
||||
'Session options'
|
||||
)
|
||||
const optionsTooltip = nativeChatOptionsPillTitle(options)
|
||||
const optionsReason =
|
||||
options.length > 0 && options.every((descriptor) => !descriptor.settable)
|
||||
? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason)
|
||||
@@ -222,25 +220,6 @@ function NativeChatSessionOptionPickersInner({
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<DropdownMenu>
|
||||
<PickerTrigger
|
||||
label={nativeChatModelPillLabel(model)}
|
||||
tooltipLabel={modelTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabledReason={modelReason}
|
||||
dispatched={model.valueSource === 'dispatched'}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
{modelReason && !model.settable ? (
|
||||
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
|
||||
) : null}
|
||||
<DescriptorMenuRows
|
||||
descriptor={model}
|
||||
pending={pendingId !== null}
|
||||
setValue={(value) => setOption(model, value)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{options.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<PickerTrigger
|
||||
@@ -271,6 +250,25 @@ function NativeChatSessionOptionPickersInner({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<PickerTrigger
|
||||
label={nativeChatModelPillLabel(model)}
|
||||
tooltipLabel={modelTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabledReason={modelReason}
|
||||
dispatched={model.valueSource === 'dispatched'}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
{modelReason && !model.settable ? (
|
||||
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
|
||||
) : null}
|
||||
<DescriptorMenuRows
|
||||
descriptor={model}
|
||||
pending={pendingId !== null}
|
||||
setValue={(value) => setOption(model, value)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createClaudeModelSwitchConfirmationObserver,
|
||||
hasClaudeModelSwitchConfirmation
|
||||
} from './claude-model-switch-confirmation'
|
||||
|
||||
describe('Claude model switch confirmation detection', () => {
|
||||
it('recognizes the rendered cache warning across ANSI and wrapped whitespace', () => {
|
||||
expect(
|
||||
hasClaudeModelSwitchConfirmation(
|
||||
'\u001b[1mSwitch model?\u001b[0m\r\nThis\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached\r\nfor the current model.'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(hasClaudeModelSwitchConfirmation('Set model to Haiku 4.5')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports matching success output split across PTY chunks', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const unsubscribe = vi.fn(() => {})
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return unsubscribe
|
||||
},
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
dataObserver.current('historical Set model to Fable 5')
|
||||
observer.arm()
|
||||
dataObserver.current('\u001b[2mSet\u001b[10Gmo')
|
||||
dataObserver.current('del\u001b[16Gto\u001b[19GFable 5 and saved as your default\u001b[0m')
|
||||
|
||||
await expect(observer.result).resolves.toBe('applied')
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts the exact cached-history confirmation once and keeps observing', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const submitConfirmation = vi.fn()
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return vi.fn(() => {})
|
||||
},
|
||||
submitConfirmation,
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
dataObserver.current('Switch model? This\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached ')
|
||||
dataObserver.current('for\u001b[36Gthe\u001b[40Gcurrent\u001b[48Gmodel.')
|
||||
dataObserver.current(' redraw of the same Switch model? prompt')
|
||||
|
||||
expect(submitConfirmation).toHaveBeenCalledOnce()
|
||||
dataObserver.current('Set model to Fable 5 and saved as your default for new sessions')
|
||||
|
||||
await expect(observer.result).resolves.toBe('applied')
|
||||
})
|
||||
|
||||
it('reports a canceled model switch without opening an interaction', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Haiku',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return vi.fn(() => {})
|
||||
},
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
dataObserver.current('\u001b[2mKept\u001b[9Gmodel\u001b[16Gas ')
|
||||
dataObserver.current('Fable 5\u001b[0m')
|
||||
|
||||
await expect(observer.result).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('requests interaction for Fable one-time usage-credit consent', async () => {
|
||||
const dataObserver = { current: (_data: string): void => {} }
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: (watcher) => {
|
||||
dataObserver.current = watcher
|
||||
return vi.fn(() => {})
|
||||
},
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
dataObserver.current('Fable 5 uses usage credits and needs a one-time consent — ')
|
||||
dataObserver.current('pick Fable from /model in an interactive session to set it up')
|
||||
|
||||
await expect(observer.result).resolves.toBe('interaction-required')
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
await expect(observer.result).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports unknown on timeout instead of requesting the terminal', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => vi.fn(() => {}),
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
observer.startDetection()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await expect(observer.result).resolves.toBe('unknown')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not start the detection timeout until startDetection() is called', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let settled = false
|
||||
const observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: 'pty-1',
|
||||
settings: {},
|
||||
expectedModelLabel: 'Fable 5',
|
||||
subscribeToData: () => vi.fn(() => {}),
|
||||
timeoutMs: 100
|
||||
})
|
||||
void observer.result.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await observer.ready
|
||||
observer.arm()
|
||||
// Simulates slow SSH send latency between arm() and command delivery: the
|
||||
// clock must not run yet, or a successful switch would time out to unknown.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
observer.startDetection()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await expect(observer.result).resolves.toBe('unknown')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { GlobalSettings } from '../../../../shared/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'
|
||||
import { NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
|
||||
|
||||
const DETECTION_TIMEOUT_MS = 5_000
|
||||
const MAX_OBSERVED_BYTES = 64 * 1024
|
||||
|
||||
type SubscribeToData = (watcher: (data: string) => void) => Promise<() => void> | (() => void)
|
||||
|
||||
export type ClaudeModelSwitchOutcome = 'applied' | 'rejected' | 'interaction-required' | 'unknown'
|
||||
|
||||
export type ClaudeModelSwitchConfirmationObserver = {
|
||||
ready: Promise<void>
|
||||
result: Promise<ClaudeModelSwitchOutcome>
|
||||
arm(): void
|
||||
startDetection(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function hasClaudeModelSwitchConfirmation(buffer: string): boolean {
|
||||
const text = compactTerminalText(buffer)
|
||||
return (
|
||||
text.includes('switchmodel?') && text.includes('thisconversationiscachedforthecurrentmodel')
|
||||
)
|
||||
}
|
||||
|
||||
function compactTerminalText(buffer: string): string {
|
||||
// Why: Claude positions TUI words with cursor-column escapes instead of
|
||||
// emitting literal spaces, so matching must not depend on rendered gaps.
|
||||
return stripScrollbackAnsi(buffer).replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean {
|
||||
const text = compactTerminalText(buffer)
|
||||
const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}`
|
||||
return text.includes(marker)
|
||||
}
|
||||
|
||||
function hasClaudeModelSwitchRejection(buffer: string): boolean {
|
||||
return compactTerminalText(buffer).includes('keptmodelas')
|
||||
}
|
||||
|
||||
function hasClaudeModelSwitchInteraction(buffer: string): boolean {
|
||||
const text = compactTerminalText(buffer)
|
||||
return (
|
||||
text.includes('fable5usesusagecreditsandneedsaone-timeconsent') ||
|
||||
text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') ||
|
||||
(text.includes('switchtofable5?') && text.includes('usagecredits'))
|
||||
)
|
||||
}
|
||||
|
||||
function subscribeToClaudeModelSwitchData(args: {
|
||||
ptyId: string
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
subscribeToData?: SubscribeToData
|
||||
watcher: (data: string) => void
|
||||
}): Promise<() => void> | (() => void) {
|
||||
if (args.subscribeToData) {
|
||||
return args.subscribeToData(args.watcher)
|
||||
}
|
||||
if (isRemoteRuntimePtyId(args.ptyId)) {
|
||||
return subscribeToRuntimeTerminalData(
|
||||
args.settings,
|
||||
args.ptyId,
|
||||
`desktop:native-chat-model-switch:${args.ptyId}`,
|
||||
args.watcher,
|
||||
{ startAtLiveTail: true }
|
||||
)
|
||||
}
|
||||
return subscribeToPtyData(args.ptyId, args.watcher)
|
||||
}
|
||||
|
||||
export function createClaudeModelSwitchConfirmationObserver(args: {
|
||||
ptyId: string
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
expectedModelLabel: string | null
|
||||
subscribeToData?: SubscribeToData
|
||||
submitConfirmation?: () => boolean | void
|
||||
timeoutMs?: number
|
||||
}): ClaudeModelSwitchConfirmationObserver {
|
||||
let armed = false
|
||||
let settled = false
|
||||
let confirmationSubmitted = false
|
||||
let observed = ''
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void
|
||||
let resolveReady!: () => void
|
||||
const result = new Promise<ClaudeModelSwitchOutcome>((resolve) => {
|
||||
resolveResult = resolve
|
||||
})
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = resolve
|
||||
})
|
||||
|
||||
const finish = (outcome: ClaudeModelSwitchOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
unsubscribe?.()
|
||||
unsubscribe = null
|
||||
resolveResult(outcome)
|
||||
}
|
||||
|
||||
const scheduleTimeout = (): void => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => finish('unknown'), args.timeoutMs ?? DETECTION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const observeData = (data: string): void => {
|
||||
if (!armed || settled) {
|
||||
return
|
||||
}
|
||||
observed = `${observed}${data}`.slice(-MAX_OBSERVED_BYTES)
|
||||
if (args.expectedModelLabel && hasClaudeModelSwitchSuccess(observed, args.expectedModelLabel)) {
|
||||
finish('applied')
|
||||
return
|
||||
}
|
||||
if (hasClaudeModelSwitchRejection(observed)) {
|
||||
finish('rejected')
|
||||
return
|
||||
}
|
||||
if (hasClaudeModelSwitchInteraction(observed)) {
|
||||
finish('interaction-required')
|
||||
return
|
||||
}
|
||||
if (!confirmationSubmitted && hasClaudeModelSwitchConfirmation(observed)) {
|
||||
confirmationSubmitted = true
|
||||
try {
|
||||
// Why: the picker selection already expresses consent to switch; this
|
||||
// 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)
|
||||
if (!accepted) {
|
||||
finish('unknown')
|
||||
return
|
||||
}
|
||||
scheduleTimeout()
|
||||
} catch {
|
||||
finish('unknown')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = subscribeToClaudeModelSwitchData({
|
||||
ptyId: args.ptyId,
|
||||
settings: args.settings,
|
||||
subscribeToData: args.subscribeToData,
|
||||
watcher: observeData
|
||||
})
|
||||
void Promise.resolve(subscription)
|
||||
.then((dispose) => {
|
||||
if (settled) {
|
||||
dispose()
|
||||
} else {
|
||||
unsubscribe = dispose
|
||||
}
|
||||
})
|
||||
.catch(() => finish('unknown'))
|
||||
.finally(resolveReady)
|
||||
} catch {
|
||||
finish('unknown')
|
||||
resolveReady()
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
result,
|
||||
arm: () => {
|
||||
if (settled || armed) {
|
||||
return
|
||||
}
|
||||
armed = true
|
||||
},
|
||||
startDetection: () => {
|
||||
// Why: measure the detection window from when the command is actually
|
||||
// delivered, not from arm(). On SSH/remote the body+Enter round-trips can
|
||||
// otherwise burn the timeout before the agent has even responded, turning
|
||||
// a successful switch into a false "could not verify the model change".
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
scheduleTimeout()
|
||||
},
|
||||
dispose: () => finish('unknown')
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ describe('native chat PTY session options', () => {
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
expect(surface.getSnapshot()).toHaveLength(1)
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({ id: 'model', valueSource: 'unknown' })
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({
|
||||
id: 'model',
|
||||
valueSource: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('restores launch-backed values through the tab-to-PTY cache handoff', () => {
|
||||
@@ -40,7 +43,11 @@ describe('native chat PTY session options', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches Claude setters and publishes full dependent snapshots', async () => {
|
||||
it('dispatches a Claude effort setter and publishes the full snapshot', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
const dispatch = vi.fn()
|
||||
const persist = vi.fn()
|
||||
const listener = vi.fn()
|
||||
@@ -53,25 +60,132 @@ describe('native chat PTY session options', () => {
|
||||
})!
|
||||
surface.subscribe(listener)
|
||||
|
||||
const modelResult = await surface.setOption('model', 'opus')
|
||||
expect(dispatch).toHaveBeenCalledWith('/model opus')
|
||||
expect(modelResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode'])
|
||||
expect(modelResult.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
|
||||
expect(modelResult.snapshot[1]).toMatchObject({ valueSource: 'unknown' })
|
||||
|
||||
const effortResult = await surface.setOption('effort', 'high')
|
||||
expect(dispatch).toHaveBeenLastCalledWith('/effort high')
|
||||
expect(dispatch).toHaveBeenCalledWith('/effort high')
|
||||
expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode'])
|
||||
expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'high' }
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true)
|
||||
expect(persist).toHaveBeenCalledWith({ modelId: 'opus', optionId: 'effort', value: 'high' })
|
||||
expect(persist).toHaveBeenCalledWith({
|
||||
modelId: 'opus',
|
||||
optionId: 'effort',
|
||||
value: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a normal Claude model choice native and dispatches the selected model', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'sonnet',
|
||||
effort: 'high'
|
||||
})
|
||||
const dispatch = vi.fn().mockResolvedValue({ outcome: 'applied' })
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch,
|
||||
onAgentPicker
|
||||
})!
|
||||
expect(surface.getSnapshot()[0]?.action).toBeUndefined()
|
||||
|
||||
const result = await surface.setOption('model', 'fable')
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('/model fable', {
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation',
|
||||
expectedChoiceLabel: 'Fable 5'
|
||||
})
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
expect(result.snapshot[0]).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'fable' }
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals the terminal only when Claude actually requires model-switch interaction', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'sonnet' })
|
||||
const dispatch = vi.fn().mockResolvedValue({ outcome: 'interaction-required' })
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: dispatch,
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
const result = await surface.setOption('model', 'haiku')
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('/model haiku', {
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation',
|
||||
expectedChoiceLabel: 'Haiku'
|
||||
})
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('keeps the prior model and persistence when Claude rejects the switch', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'fable',
|
||||
effort: 'high'
|
||||
})
|
||||
const persist = vi.fn()
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'rejected' }),
|
||||
persistSelection: persist,
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
await expect(surface.setOption('model', 'haiku')).rejects.toThrow(
|
||||
'Claude kept the current model.'
|
||||
)
|
||||
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({
|
||||
valueSource: 'applied',
|
||||
kind: { currentValue: 'fable' }
|
||||
})
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays native and clears stale truth when the switch cannot be verified', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'fable',
|
||||
effort: 'high'
|
||||
})
|
||||
const persist = vi.fn()
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'unknown' }),
|
||||
persistSelection: persist,
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
await expect(surface.setOption('model', 'haiku')).rejects.toThrow(
|
||||
'Could not verify the model change; open the terminal to check.'
|
||||
)
|
||||
|
||||
expect(surface.getSnapshot()).toHaveLength(1)
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown toggle unknown after the one-shot action', async () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'opus', effort: 'high' })
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'high'
|
||||
})
|
||||
const dispatch = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
@@ -115,30 +229,7 @@ describe('native chat PTY session options', () => {
|
||||
expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('tracks typed direct commands and downgrades typed toggles', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'sonnet',
|
||||
effort: 'high'
|
||||
})
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
surface.recordOutgoingCommand('/model opus')
|
||||
expect(surface.getSnapshot().map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode'])
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'opus' }
|
||||
})
|
||||
surface.recordOutgoingCommand('/fast')
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({
|
||||
valueSource: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops stale model-scoped truth when typed model commands switch away and back', () => {
|
||||
it('tracks typed effort commands and downgrades typed toggles', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
@@ -149,17 +240,42 @@ describe('native chat PTY session options', () => {
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn()
|
||||
})!
|
||||
surface.recordOutgoingCommand('/effort high')
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({
|
||||
valueSource: 'dispatched',
|
||||
kind: { currentValue: 'high' }
|
||||
})
|
||||
surface.recordOutgoingCommand('/fast')
|
||||
expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({
|
||||
valueSource: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
surface.recordOutgoingCommand('/model sonnet')
|
||||
surface.recordOutgoingCommand('/model opus')
|
||||
it('switches to the terminal and drops stale truth for a typed picker command', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
const onAgentPicker = vi.fn()
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
mode: 'live',
|
||||
dispatchCommand: vi.fn(),
|
||||
onAgentPicker
|
||||
})!
|
||||
|
||||
const effort = surface.getSnapshot().find(({ id }) => id === 'effort')
|
||||
expect(effort).toMatchObject({ valueSource: 'unknown' })
|
||||
expect(effort?.kind).not.toHaveProperty('currentValue')
|
||||
surface.recordOutgoingCommand('/model')
|
||||
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
expect(surface.getSnapshot()).toHaveLength(1)
|
||||
expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('passes an unknown persisted model through as a literal choice', () => {
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'future-model' })
|
||||
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
|
||||
model: 'future-model'
|
||||
})
|
||||
const surface = createNativeChatPtySessionOptions({
|
||||
agent: 'claude',
|
||||
scopeKey: 'pty-1',
|
||||
|
||||
@@ -22,6 +22,15 @@ import {
|
||||
flattenNativeChatSessionOptionRecord,
|
||||
type NativeChatSessionOptionMode
|
||||
} from './native-chat-session-option-snapshot'
|
||||
import {
|
||||
isSessionOptionAgentPickerCommand,
|
||||
parseBuiltSessionOptionCommand
|
||||
} from './native-chat-session-option-command-matching'
|
||||
import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder'
|
||||
import type {
|
||||
NativeChatSessionOptionDispatchCommand,
|
||||
NativeChatSessionOptionDispatchResult
|
||||
} from './native-chat-session-option-command-dispatch'
|
||||
|
||||
type PersistSelection = (args: {
|
||||
modelId: string
|
||||
@@ -40,31 +49,12 @@ export type CreateNativeChatPtySessionOptionsArgs = {
|
||||
fallbackScopeKey?: string
|
||||
initialModels?: readonly CatalogModel[]
|
||||
mode: NativeChatSessionOptionMode
|
||||
dispatchCommand: (command: string) => Promise<void> | void
|
||||
dispatchCommand: NativeChatSessionOptionDispatchCommand
|
||||
onAgentPicker?: () => void
|
||||
persistSelection?: PersistSelection
|
||||
onDraftValuesChanged?: (values: Record<string, SessionOptionValue>) => void
|
||||
}
|
||||
|
||||
function parseBuiltCommand(
|
||||
build: (value: SessionOptionValue) => string,
|
||||
command: string
|
||||
): string | null {
|
||||
const marker = '__orca_session_option_value__'
|
||||
const template = build(marker)
|
||||
const markerIndex = template.indexOf(marker)
|
||||
if (markerIndex < 0) {
|
||||
return null
|
||||
}
|
||||
const prefix = template.slice(0, markerIndex)
|
||||
const suffix = template.slice(markerIndex + marker.length)
|
||||
if (!command.startsWith(prefix) || !command.endsWith(suffix)) {
|
||||
return null
|
||||
}
|
||||
const value = command.slice(prefix.length, command.length - suffix.length).trim()
|
||||
return value || null
|
||||
}
|
||||
|
||||
export function createNativeChatPtySessionOptions(
|
||||
args: CreateNativeChatPtySessionOptionsArgs
|
||||
): NativeChatPtySessionOptionsSurface | null {
|
||||
@@ -79,12 +69,22 @@ export function createNativeChatPtySessionOptions(
|
||||
if (record.agent !== args.agent) {
|
||||
record = createNativeChatSessionOptionRecord(args.agent)
|
||||
}
|
||||
let snapshot = buildNativeChatSessionOptionSnapshot({ catalog, models, record, mode: args.mode })
|
||||
let snapshot = buildNativeChatSessionOptionSnapshot({
|
||||
catalog,
|
||||
models,
|
||||
record,
|
||||
mode: args.mode
|
||||
})
|
||||
const listeners = new Set<(value: SessionOptionDescriptor[]) => void>()
|
||||
|
||||
const publish = (): SessionOptionDescriptor[] => {
|
||||
writeNativeChatSessionOptionCache(args.scopeKey, record)
|
||||
snapshot = buildNativeChatSessionOptionSnapshot({ catalog, models, record, mode: args.mode })
|
||||
snapshot = buildNativeChatSessionOptionSnapshot({
|
||||
catalog,
|
||||
models,
|
||||
record,
|
||||
mode: args.mode
|
||||
})
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot)
|
||||
}
|
||||
@@ -137,34 +137,6 @@ export function createNativeChatPtySessionOptions(
|
||||
return option ? { apply: option.apply, modelId } : null
|
||||
}
|
||||
|
||||
const commandForValue = (
|
||||
optionId: string,
|
||||
value: SessionOptionValue,
|
||||
apply: CatalogOptionApply,
|
||||
modelId: string | null
|
||||
): string | null => {
|
||||
const midSession = apply.midSession
|
||||
if (midSession?.kind === 'command') {
|
||||
return midSession.build(value)
|
||||
}
|
||||
if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') {
|
||||
return midSession.command
|
||||
}
|
||||
if (!apply.composedIntoModel || !modelId || !catalog.composeModelValue) {
|
||||
return null
|
||||
}
|
||||
const model = findCatalogModel({ ...catalog, models }, modelId)
|
||||
const values = flattenNativeChatSessionOptionRecord(record, modelId)
|
||||
for (const option of model?.options ?? []) {
|
||||
values[option.id] ??= option.kind.defaultValue
|
||||
}
|
||||
values[optionId] = value
|
||||
const composed = catalog.composeModelValue(modelId, values)
|
||||
return catalog.modelApply.midSession?.kind === 'command'
|
||||
? catalog.modelApply.midSession.build(composed)
|
||||
: null
|
||||
}
|
||||
|
||||
const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise<void> => {
|
||||
if (midSession.kind !== 'agent-picker') {
|
||||
return
|
||||
@@ -186,25 +158,61 @@ export function createNativeChatPtySessionOptions(
|
||||
return { snapshot }
|
||||
}
|
||||
const source = args.mode === 'live' ? 'dispatched' : 'applied'
|
||||
let dispatchResult: NativeChatSessionOptionDispatchResult | void = undefined
|
||||
const toggleWasKnown =
|
||||
apply.midSession?.kind === 'toggle-command' && previousModelId
|
||||
? record.valuesByModel[previousModelId]?.[id] !== undefined
|
||||
: false
|
||||
if (args.mode === 'live') {
|
||||
const command = commandForValue(id, value, apply, previousModelId)
|
||||
const command = buildNativeChatSessionOptionCommand({
|
||||
optionId: id,
|
||||
value,
|
||||
apply,
|
||||
modelId: previousModelId,
|
||||
catalog,
|
||||
models,
|
||||
record
|
||||
})
|
||||
if (!command) {
|
||||
throw new Error('This option can only be set when the session starts.')
|
||||
}
|
||||
await args.dispatchCommand(command)
|
||||
const detectAgentInteraction =
|
||||
apply.midSession?.kind === 'command'
|
||||
? apply.midSession.detectAgentInteraction
|
||||
: apply.composedIntoModel && catalog.modelApply.midSession?.kind === 'command'
|
||||
? catalog.modelApply.midSession.detectAgentInteraction
|
||||
: undefined
|
||||
const expectedChoiceLabel =
|
||||
id === 'model' && typeof value === 'string'
|
||||
? (findCatalogModel({ ...catalog, models }, value)?.label ?? value)
|
||||
: undefined
|
||||
dispatchResult = detectAgentInteraction
|
||||
? await args.dispatchCommand(command, {
|
||||
detectAgentInteraction,
|
||||
expectedChoiceLabel
|
||||
})
|
||||
: await args.dispatchCommand(command)
|
||||
} else if (!apply.launchArgs && !apply.composedIntoModel) {
|
||||
throw new Error('This option is only available after the session starts.')
|
||||
}
|
||||
|
||||
if (dispatchResult?.outcome === 'rejected') {
|
||||
throw new Error('Claude kept the current model.')
|
||||
}
|
||||
if (dispatchResult?.outcome === 'unknown') {
|
||||
clearModelTruth()
|
||||
publish()
|
||||
throw new Error('Could not verify the model change; open the terminal to check.')
|
||||
}
|
||||
if (dispatchResult?.outcome === 'interaction-required') {
|
||||
clearModelTruth()
|
||||
publish()
|
||||
args.onAgentPicker?.()
|
||||
return { snapshot }
|
||||
}
|
||||
|
||||
if (apply.midSession?.kind === 'toggle-command' && !toggleWasKnown) {
|
||||
return {
|
||||
snapshot: publish(),
|
||||
notice: 'Sent to the agent — current value is unknown'
|
||||
}
|
||||
return { snapshot: publish() }
|
||||
}
|
||||
if (id === 'model' && previousModelId !== value) {
|
||||
record.model = undefined
|
||||
@@ -226,10 +234,7 @@ export function createNativeChatPtySessionOptions(
|
||||
if (args.mode === 'draft' && typeof record.model?.value === 'string') {
|
||||
args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value))
|
||||
}
|
||||
return {
|
||||
snapshot: next,
|
||||
...(source === 'dispatched' ? { notice: 'Sent to the agent — not confirmed' } : {})
|
||||
}
|
||||
return { snapshot: next }
|
||||
}
|
||||
|
||||
const recordCommandApply = (
|
||||
@@ -247,14 +252,14 @@ export function createNativeChatPtySessionOptions(
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (midSession.kind === 'agent-picker' && command === midSession.command) {
|
||||
if (isSessionOptionAgentPickerCommand(midSession, command)) {
|
||||
clearModelTruth()
|
||||
return true
|
||||
}
|
||||
if (midSession.kind !== 'command') {
|
||||
return false
|
||||
}
|
||||
const value = parseBuiltCommand(midSession.build, command)
|
||||
const value = parseBuiltSessionOptionCommand(midSession.build, command)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
@@ -280,15 +285,24 @@ export function createNativeChatPtySessionOptions(
|
||||
},
|
||||
recordOutgoingCommand: (command) => {
|
||||
const trimmed = command.trim()
|
||||
let opensAgentPicker = isSessionOptionAgentPickerCommand(
|
||||
catalog.modelApply.midSession,
|
||||
trimmed
|
||||
)
|
||||
let changed = recordCommandApply('model', catalog.modelApply.midSession, trimmed)
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined
|
||||
for (const option of model?.options ?? []) {
|
||||
opensAgentPicker =
|
||||
opensAgentPicker || isSessionOptionAgentPickerCommand(option.apply.midSession, trimmed)
|
||||
changed = recordCommandApply(option.id, option.apply.midSession, trimmed) || changed
|
||||
}
|
||||
if (changed) {
|
||||
publish()
|
||||
}
|
||||
if (opensAgentPicker) {
|
||||
args.onAgentPicker?.()
|
||||
}
|
||||
},
|
||||
replaceModels: (nextModels) => {
|
||||
models = [...nextModels]
|
||||
|
||||
@@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Mock the IO seam so the test stays pure: we only assert the write order and
|
||||
// the inter-write delay, not the local-vs-remote pty branching.
|
||||
const sendRuntimePtyInput = vi.fn()
|
||||
const sendRuntimePtyInputVerified = vi.fn()
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args)
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args),
|
||||
sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args)
|
||||
}))
|
||||
|
||||
import {
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageVerified,
|
||||
sendNativeChatMessageWithImageAttachments,
|
||||
submitNativeChatPrompt,
|
||||
sendNativeChatAskAnswer,
|
||||
@@ -75,6 +78,52 @@ describe('sendNativeChatMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessageVerified', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('awaits body acceptance before the delayed Enter write', async () => {
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('/model sonnet')
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(await result).toBe(true)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
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 vi.runAllTimersAsync()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels the pending Enter when its composer detaches', async () => {
|
||||
const controller = new AbortController()
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
|
||||
expect(await result).toBe(false)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessageWithImageAttachments', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in
|
||||
// native-chat-send.ts so those stay IO-free and unit-testable without aliases.
|
||||
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
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 {
|
||||
@@ -47,6 +50,46 @@ export function sendNativeChatMessage(
|
||||
return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS }
|
||||
}
|
||||
|
||||
function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (completed: boolean): void => {
|
||||
if (timer === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(completed)
|
||||
}
|
||||
const onAbort = (): void => finish(false)
|
||||
timer = setTimeout(() => finish(true), NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendNativeChatMessageVerified(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
// 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)
|
||||
)
|
||||
if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) {
|
||||
return false
|
||||
}
|
||||
return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}
|
||||
|
||||
export function sendNativeChatMessageWithImageAttachments(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
findCatalogModel,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogModel,
|
||||
type CatalogOptionApply
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache'
|
||||
import { flattenNativeChatSessionOptionRecord } from './native-chat-session-option-snapshot'
|
||||
|
||||
export function buildNativeChatSessionOptionCommand(args: {
|
||||
optionId: string
|
||||
value: SessionOptionValue
|
||||
apply: CatalogOptionApply
|
||||
modelId: string | null
|
||||
catalog: AgentSessionOptionCatalog
|
||||
models: CatalogModel[]
|
||||
record: NativeChatSessionOptionRecord
|
||||
}): string | null {
|
||||
const midSession = args.apply.midSession
|
||||
if (midSession?.kind === 'command') {
|
||||
return midSession.build(args.value)
|
||||
}
|
||||
if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') {
|
||||
return midSession.command
|
||||
}
|
||||
if (!args.apply.composedIntoModel || !args.modelId || !args.catalog.composeModelValue) {
|
||||
return null
|
||||
}
|
||||
const model = findCatalogModel({ ...args.catalog, models: args.models }, args.modelId)
|
||||
const values = flattenNativeChatSessionOptionRecord(args.record, args.modelId)
|
||||
for (const option of model?.options ?? []) {
|
||||
values[option.id] ??= option.kind.defaultValue
|
||||
}
|
||||
values[args.optionId] = args.value
|
||||
const composed = args.catalog.composeModelValue(args.modelId, values)
|
||||
return args.catalog.modelApply.midSession?.kind === 'command'
|
||||
? args.catalog.modelApply.midSession.build(composed)
|
||||
: null
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog'
|
||||
import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation'
|
||||
|
||||
export type NativeChatSessionOptionDispatchResult = {
|
||||
outcome?: ClaudeModelSwitchOutcome
|
||||
}
|
||||
|
||||
export type NativeChatSessionOptionDispatchCommand = (
|
||||
command: string,
|
||||
options?: {
|
||||
detectAgentInteraction?: CatalogAgentInteractionDetection
|
||||
expectedChoiceLabel?: string
|
||||
}
|
||||
) =>
|
||||
| Promise<NativeChatSessionOptionDispatchResult | void>
|
||||
| NativeChatSessionOptionDispatchResult
|
||||
| void
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { CatalogMidSessionApply } from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
|
||||
|
||||
export function parseBuiltSessionOptionCommand(
|
||||
build: (value: SessionOptionValue) => string,
|
||||
command: string
|
||||
): string | null {
|
||||
const marker = '__orca_session_option_value__'
|
||||
const template = build(marker)
|
||||
const markerIndex = template.indexOf(marker)
|
||||
if (markerIndex < 0) {
|
||||
return null
|
||||
}
|
||||
const prefix = template.slice(0, markerIndex)
|
||||
const suffix = template.slice(markerIndex + marker.length)
|
||||
if (!command.startsWith(prefix) || !command.endsWith(suffix)) {
|
||||
return null
|
||||
}
|
||||
const value = command.slice(prefix.length, command.length - suffix.length).trim()
|
||||
return value || null
|
||||
}
|
||||
|
||||
export function isSessionOptionAgentPickerCommand(
|
||||
midSession: CatalogMidSessionApply | undefined,
|
||||
command: string
|
||||
): boolean {
|
||||
return (
|
||||
(midSession?.kind === 'agent-picker' && command === midSession.command) ||
|
||||
(midSession?.kind === 'command' && command === midSession.pickerCommand)
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionDisabledReason,
|
||||
SessionOptionSelectChoice
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -38,37 +39,61 @@ export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice):
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeChatSessionOptionDisabledReason(reason: string | undefined): string | null {
|
||||
if (reason === 'Set when the session starts.') {
|
||||
return translate(
|
||||
'components.native-chat.composer.setWhenSessionStarts',
|
||||
'Set when the session starts.'
|
||||
)
|
||||
export function nativeChatSessionOptionDisabledReason(
|
||||
reason: SessionOptionDisabledReason | undefined
|
||||
): string | null {
|
||||
// Exhaustive over SessionOptionDisabledReason: a new key is a compile error
|
||||
// here, so the localized label can never silently drift from the producer.
|
||||
switch (reason) {
|
||||
case 'set-when-session-starts':
|
||||
return translate(
|
||||
'components.native-chat.composer.setWhenSessionStarts',
|
||||
'Set when the session starts.'
|
||||
)
|
||||
case 'available-after-session-start':
|
||||
return translate(
|
||||
'components.native-chat.composer.availableAfterSessionStarts',
|
||||
'Available after the session starts.'
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
if (reason === 'Available after the session starts.') {
|
||||
return translate(
|
||||
'components.native-chat.composer.availableAfterSessionStarts',
|
||||
'Available after the session starts.'
|
||||
)
|
||||
}
|
||||
return reason ?? null
|
||||
}
|
||||
|
||||
export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string {
|
||||
if (descriptor.valueSource === 'unknown' || descriptor.kind.type !== 'select') {
|
||||
return translate('components.native-chat.composer.model', 'Model')
|
||||
const modelLabel = translate('components.native-chat.composer.model', 'Model')
|
||||
if (
|
||||
descriptor.valueSource === 'unknown' ||
|
||||
descriptor.kind.type !== 'select' ||
|
||||
!descriptor.kind.currentValue
|
||||
) {
|
||||
return modelLabel
|
||||
}
|
||||
return nativeChatSessionChoiceLabel(
|
||||
const valueLabel = nativeChatSessionChoiceLabel(
|
||||
descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? {
|
||||
value: descriptor.kind.currentValue ?? '',
|
||||
label: descriptor.kind.currentValue ?? ''
|
||||
value: descriptor.kind.currentValue,
|
||||
label: descriptor.kind.currentValue
|
||||
}
|
||||
)
|
||||
return translate('components.native-chat.composer.modelWithValue', 'Model: {{value0}}', {
|
||||
value0: valueLabel
|
||||
})
|
||||
}
|
||||
|
||||
export function nativeChatOptionsPillTitle(
|
||||
descriptors: readonly SessionOptionDescriptor[]
|
||||
): string {
|
||||
const effort = descriptors.find((descriptor) => descriptor.id === 'effort')
|
||||
// Why: an effort-backed group is primarily the effort picker, even when it also reports modes.
|
||||
return effort
|
||||
? nativeChatSessionOptionLabel(effort)
|
||||
: translate('components.native-chat.composer.sessionOptions', 'Session options')
|
||||
}
|
||||
|
||||
export function nativeChatOptionsPillLabel(
|
||||
descriptors: readonly SessionOptionDescriptor[]
|
||||
): string {
|
||||
const effort = descriptors.find((descriptor) => descriptor.id === 'effort')
|
||||
const labels: string[] = []
|
||||
for (const descriptor of descriptors) {
|
||||
if (descriptor.valueSource === 'unknown') {
|
||||
@@ -94,7 +119,17 @@ export function nativeChatOptionsPillLabel(
|
||||
)
|
||||
}
|
||||
}
|
||||
return labels.length > 0
|
||||
? labels.join(' · ')
|
||||
: translate('components.native-chat.composer.options', 'Options')
|
||||
if (labels.length > 0) {
|
||||
const joinedLabels = labels.join(' · ')
|
||||
if (effort) {
|
||||
return translate('components.native-chat.composer.effortWithValue', 'Effort: {{value0}}', {
|
||||
value0: joinedLabels
|
||||
})
|
||||
}
|
||||
return joinedLabels
|
||||
}
|
||||
if (effort) {
|
||||
return nativeChatSessionOptionLabel(effort)
|
||||
}
|
||||
return translate('components.native-chat.composer.options', 'Options')
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function settableState(args: {
|
||||
if (args.mode === 'draft') {
|
||||
return args.apply.launchArgs || args.apply.composedIntoModel
|
||||
? { settable: true }
|
||||
: { settable: false, disabledReason: 'Available after the session starts.' }
|
||||
: { settable: false, disabledReason: 'available-after-session-start' }
|
||||
}
|
||||
const midSession = args.apply.midSession
|
||||
if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') {
|
||||
@@ -46,7 +46,7 @@ function settableState(args: {
|
||||
}
|
||||
return midSession && midSession.kind !== 'unsupported'
|
||||
? { settable: true }
|
||||
: { settable: false, disabledReason: 'Set when the session starts.' }
|
||||
: { settable: false, disabledReason: 'set-when-session-starts' }
|
||||
}
|
||||
|
||||
function actionForApply(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
@@ -6,8 +6,12 @@ import {
|
||||
type NativeChatResolvedTarget
|
||||
} from './native-chat-composer-target'
|
||||
import { pushHistory, type HistoryState } from './native-chat-composer-state'
|
||||
import { sendNativeChatMessage } from './native-chat-runtime-send'
|
||||
import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle'
|
||||
import { sendNativeChatMessageVerified } from './native-chat-runtime-send'
|
||||
import {
|
||||
createClaudeModelSwitchConfirmationObserver,
|
||||
type ClaudeModelSwitchConfirmationObserver
|
||||
} from './claude-model-switch-confirmation'
|
||||
import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch'
|
||||
|
||||
export function useNativeChatSessionOptionCommand(args: {
|
||||
agent: AgentType
|
||||
@@ -15,27 +19,96 @@ export function useNativeChatSessionOptionCommand(args: {
|
||||
onSlashCommand?: (command: string) => void
|
||||
resolveTarget: () => NativeChatResolvedTarget | null
|
||||
setHistory: Dispatch<SetStateAction<HistoryState>>
|
||||
trackPendingSend: NativeChatSendLifecycle['trackPendingSend']
|
||||
}): (command: string) => Promise<void> {
|
||||
const { agent, disabled, onSlashCommand, resolveTarget, setHistory, trackPendingSend } = args
|
||||
return useCallback(
|
||||
async (command: string): Promise<void> => {
|
||||
}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } {
|
||||
const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args
|
||||
const mountedRef = useRef(true)
|
||||
const activeObserversRef = useRef(new Set<ClaudeModelSwitchConfirmationObserver>())
|
||||
const activeSendsRef = useRef(new Set<AbortController>())
|
||||
// Why: expose an in-flight flag so the composer can block a normal send while
|
||||
// an option command's body+delayed-Enter (and its confirmation observer) is
|
||||
// still writing to the same pty — otherwise the two write sequences interleave
|
||||
// on one input line and corrupt both.
|
||||
const [isDispatching, setIsDispatching] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const observers = activeObserversRef.current
|
||||
const sends = activeSendsRef.current
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
for (const send of sends) {
|
||||
send.abort()
|
||||
}
|
||||
sends.clear()
|
||||
for (const observer of observers) {
|
||||
observer.dispose()
|
||||
}
|
||||
observers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dispatch = useCallback(
|
||||
async (command, options) => {
|
||||
const target = resolveTarget()
|
||||
if (!target || disabled) {
|
||||
throw new Error('No live terminal is available.')
|
||||
}
|
||||
const handle = sendNativeChatMessage(target.settings, target.ptyId, command)
|
||||
trackPendingSend(handle)
|
||||
onSlashCommand?.(command.trim())
|
||||
emitNativeChatMessageSent({
|
||||
agent,
|
||||
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
|
||||
})
|
||||
setHistory((previous) => pushHistory(previous, command))
|
||||
// Why: picker actions switch views; wait for the delayed Enter so
|
||||
// unmount cleanup cannot cancel the command halfway through dispatch.
|
||||
await new Promise((resolve) => setTimeout(resolve, handle.settleAfterMs + 10))
|
||||
const detectClaudeConfirmation =
|
||||
options?.detectAgentInteraction === 'claude-model-switch-confirmation'
|
||||
let observer: ClaudeModelSwitchConfirmationObserver | null = null
|
||||
if (detectClaudeConfirmation) {
|
||||
observer = createClaudeModelSwitchConfirmationObserver({
|
||||
ptyId: target.ptyId,
|
||||
settings: target.settings,
|
||||
expectedModelLabel: options?.expectedChoiceLabel ?? null
|
||||
})
|
||||
activeObserversRef.current.add(observer)
|
||||
await observer.ready
|
||||
if (!mountedRef.current) {
|
||||
activeObserversRef.current.delete(observer)
|
||||
observer.dispose()
|
||||
throw new Error('Native chat command was canceled because the composer closed.')
|
||||
}
|
||||
// Why: arm only after the observer reaches the live PTY tail, then
|
||||
// submit immediately so historical output cannot satisfy the match.
|
||||
observer.arm()
|
||||
}
|
||||
const sendController = new AbortController()
|
||||
activeSendsRef.current.add(sendController)
|
||||
setIsDispatching(true)
|
||||
try {
|
||||
const accepted = await sendNativeChatMessageVerified(
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
command,
|
||||
sendController.signal
|
||||
)
|
||||
if (!accepted) {
|
||||
throw new Error('The terminal did not accept the command.')
|
||||
}
|
||||
// Why: start the model-switch detection clock only now that the command
|
||||
// has been accepted, so SSH/remote send latency doesn't eat the window
|
||||
// before the agent has responded.
|
||||
observer?.startDetection()
|
||||
onSlashCommand?.(command.trim())
|
||||
emitNativeChatMessageSent({
|
||||
agent,
|
||||
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
|
||||
})
|
||||
setHistory((previous) => pushHistory(previous, command))
|
||||
const outcome = observer ? await observer.result : undefined
|
||||
return { outcome }
|
||||
} finally {
|
||||
activeSendsRef.current.delete(sendController)
|
||||
setIsDispatching(activeSendsRef.current.size > 0)
|
||||
if (observer) {
|
||||
activeObserversRef.current.delete(observer)
|
||||
observer.dispose()
|
||||
}
|
||||
}
|
||||
},
|
||||
[agent, disabled, onSlashCommand, resolveTarget, setHistory, trackPendingSend]
|
||||
[agent, disabled, onSlashCommand, resolveTarget, setHistory]
|
||||
)
|
||||
|
||||
return { dispatch, isDispatching }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createNativeChatPtySessionOptions,
|
||||
type NativeChatPtySessionOptionsSurface
|
||||
} from './native-chat-pty-session-options'
|
||||
import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch'
|
||||
import {
|
||||
ensureNativeChatModelEnrichment,
|
||||
readNativeChatEnrichedModels,
|
||||
@@ -25,7 +26,7 @@ export function useNativeChatSessionOptions(args: {
|
||||
agent: AgentType
|
||||
terminalTabId: string
|
||||
targetPtyId: string | null
|
||||
dispatchCommand: (command: string) => Promise<void> | void
|
||||
dispatchCommand: NativeChatSessionOptionDispatchCommand
|
||||
onAgentPicker?: () => void
|
||||
}): {
|
||||
surface: NativeChatPtySessionOptionsSurface | null
|
||||
@@ -42,7 +43,6 @@ export function useNativeChatSessionOptions(args: {
|
||||
return null
|
||||
}
|
||||
const scopeKey = args.targetPtyId ?? args.terminalTabId
|
||||
let persisted = useAppStore.getState().settings?.nativeChatSessionOptions
|
||||
let settingsWrite = Promise.resolve()
|
||||
return createNativeChatPtySessionOptions({
|
||||
agent: args.agent,
|
||||
@@ -58,21 +58,25 @@ export function useNativeChatSessionOptions(args: {
|
||||
dispatchCommand: args.dispatchCommand,
|
||||
onAgentPicker: args.onAgentPicker,
|
||||
persistSelection: async ({ modelId, optionId, value }) => {
|
||||
persisted = updateNativeChatSessionOptionDefaults({
|
||||
persisted,
|
||||
agent: args.agent,
|
||||
modelId,
|
||||
optionId,
|
||||
value
|
||||
})
|
||||
const nextPersisted = persisted
|
||||
// Why: rapid consecutive picks must reach electron-store in selection
|
||||
// order or a slower older write can erase the newer model-scoped value.
|
||||
// Why: read the live persisted defaults at write time (after any prior
|
||||
// write in this chain settles) and merge only this selection onto them,
|
||||
// rather than a baseline captured once at surface creation. A frozen
|
||||
// baseline would let a second same-agent pane's write be clobbered,
|
||||
// since updateSettings shallow-merges nativeChatSessionOptions. Chaining
|
||||
// still keeps rapid consecutive picks in selection order.
|
||||
settingsWrite = settingsWrite
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
useAppStore.getState().updateSettings({ nativeChatSessionOptions: nextPersisted })
|
||||
)
|
||||
.then(() => {
|
||||
const base = useAppStore.getState().settings?.nativeChatSessionOptions
|
||||
const next = updateNativeChatSessionOptionDefaults({
|
||||
persisted: base,
|
||||
agent: args.agent,
|
||||
modelId,
|
||||
optionId,
|
||||
value
|
||||
})
|
||||
return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next })
|
||||
})
|
||||
await settingsWrite
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13186,7 +13186,9 @@
|
||||
"worktreeNotReady": "Worktree not ready — try again in a moment.",
|
||||
"uploadingAttachments": "Uploading {{value0}} file(s) to remote…",
|
||||
"model": "Model",
|
||||
"modelWithValue": "Model: {{value0}}",
|
||||
"effort": "Effort",
|
||||
"effortWithValue": "Effort: {{value0}}",
|
||||
"fastMode": "Fast mode",
|
||||
"thinking": "Thinking",
|
||||
"options": "Options",
|
||||
|
||||
@@ -13186,7 +13186,9 @@
|
||||
"worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.",
|
||||
"uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…",
|
||||
"model": "Modelo",
|
||||
"modelWithValue": "Modelo: {{value0}}",
|
||||
"effort": "Esfuerzo",
|
||||
"effortWithValue": "Esfuerzo: {{value0}}",
|
||||
"fastMode": "Modo rápido",
|
||||
"thinking": "Razonamiento",
|
||||
"options": "Opciones",
|
||||
|
||||
@@ -13186,7 +13186,9 @@
|
||||
"worktreeNotReady": "Worktreeが準備できていません — しばらくしてから再試行してください。",
|
||||
"uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…",
|
||||
"model": "モデル",
|
||||
"modelWithValue": "モデル: {{value0}}",
|
||||
"effort": "推論レベル",
|
||||
"effortWithValue": "推論レベル: {{value0}}",
|
||||
"fastMode": "高速モード",
|
||||
"thinking": "思考",
|
||||
"options": "オプション",
|
||||
|
||||
@@ -13186,7 +13186,9 @@
|
||||
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
|
||||
"uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…",
|
||||
"model": "모델",
|
||||
"modelWithValue": "모델: {{value0}}",
|
||||
"effort": "추론 수준",
|
||||
"effortWithValue": "추론 수준: {{value0}}",
|
||||
"fastMode": "빠른 모드",
|
||||
"thinking": "사고",
|
||||
"options": "옵션",
|
||||
|
||||
@@ -13186,7 +13186,9 @@
|
||||
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
|
||||
"uploadingAttachments": "正在上传 {{value0}} 个文件到远程…",
|
||||
"model": "模型",
|
||||
"modelWithValue": "模型:{{value0}}",
|
||||
"effort": "推理强度",
|
||||
"effortWithValue": "推理强度:{{value0}}",
|
||||
"fastMode": "快速模式",
|
||||
"thinking": "思考",
|
||||
"options": "选项",
|
||||
|
||||
@@ -29,7 +29,9 @@ describe('native chat locale copy', () => {
|
||||
const composer = catalog.components['native-chat'].composer
|
||||
for (const key of [
|
||||
'model',
|
||||
'modelWithValue',
|
||||
'effort',
|
||||
'effortWithValue',
|
||||
'fastMode',
|
||||
'thinking',
|
||||
'options',
|
||||
|
||||
@@ -132,6 +132,60 @@ describe('remote runtime terminal data subscriptions', () => {
|
||||
expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0)
|
||||
})
|
||||
|
||||
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,
|
||||
{ startAtLiveTail: true }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled())
|
||||
const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0])
|
||||
const subscribePayload =
|
||||
subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload)
|
||||
const streamId = subscribePayload!.streamId
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotStart,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamJson({ seq: 0 })
|
||||
})
|
||||
)
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotChunk,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamText('historical')
|
||||
})
|
||||
)
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.SnapshotEnd,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)
|
||||
const dispose = await subscription
|
||||
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
callbacks?.onBinary?.(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId,
|
||||
seq: 4,
|
||||
payload: encodeTerminalStreamText('live')
|
||||
})
|
||||
)
|
||||
expect(watcher).toHaveBeenCalledWith('live')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('keeps the shared terminal multiplexer until the last watcher closes', async () => {
|
||||
const firstDispose = await subscribeToRuntimeTerminalData(
|
||||
{ activeRuntimeEnvironmentId: 'env-fallback' },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-m
|
||||
|
||||
const REMOTE_PTY_ID_PREFIX = 'remote:'
|
||||
const REMOTE_PTY_OWNER_SEPARATOR = '@@'
|
||||
const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000
|
||||
|
||||
export type RemoteRuntimePtyIdParts = {
|
||||
environmentId: string | null
|
||||
@@ -56,7 +57,8 @@ export async function subscribeToRuntimeTerminalData(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
clientId: string,
|
||||
watcher: (data: string) => void
|
||||
watcher: (data: string) => void,
|
||||
options?: { startAtLiveTail?: boolean }
|
||||
): Promise<() => void> {
|
||||
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
@@ -67,14 +69,61 @@ export async function subscribeToRuntimeTerminalData(
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let resolveLiveTail: (() => void) | null = null
|
||||
let rejectLiveTail: ((error: Error) => void) | null = null
|
||||
const liveTailReady = options?.startAtLiveTail
|
||||
? new Promise<void>((resolve, reject) => {
|
||||
resolveLiveTail = resolve
|
||||
rejectLiveTail = reject
|
||||
})
|
||||
: null
|
||||
const rejectPendingLiveTail = (message: string): void => {
|
||||
rejectLiveTail?.(new Error(message))
|
||||
resolveLiveTail = null
|
||||
rejectLiveTail = null
|
||||
}
|
||||
|
||||
const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({
|
||||
terminal,
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: (data) => watcher(data),
|
||||
onSnapshot: watcher
|
||||
onSnapshot: (data) => {
|
||||
if (!options?.startAtLiveTail) {
|
||||
watcher(data)
|
||||
}
|
||||
},
|
||||
onSubscribed: () => {
|
||||
resolveLiveTail?.()
|
||||
resolveLiveTail = null
|
||||
rejectLiveTail = null
|
||||
},
|
||||
onEnd: () => rejectPendingLiveTail('Remote terminal ended before live output was ready.'),
|
||||
onError: (message) => rejectPendingLiveTail(message),
|
||||
onTransportClose: () =>
|
||||
rejectPendingLiveTail('Remote terminal closed before live output was ready.')
|
||||
}
|
||||
})
|
||||
|
||||
if (liveTailReady) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = setTimeout(
|
||||
() => rejectPendingLiveTail('Timed out waiting for remote terminal live output.'),
|
||||
LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS
|
||||
)
|
||||
try {
|
||||
// Why: outcome observers must ignore historical snapshots and be armed
|
||||
// before the command whose output they classify, including over SSH.
|
||||
await liveTailReady
|
||||
} catch (error) {
|
||||
stream.close()
|
||||
throw error
|
||||
} finally {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => stream.close()
|
||||
}
|
||||
|
||||
@@ -92,7 +92,14 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
modelApply: {
|
||||
launchArgs: (value) => ['--model', String(value)],
|
||||
agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']),
|
||||
midSession: { kind: 'command', build: (value) => `/model ${String(value)}` }
|
||||
midSession: {
|
||||
kind: 'command',
|
||||
build: (value) => `/model ${String(value)}`,
|
||||
pickerCommand: '/model',
|
||||
// Why: Claude sometimes confirms a cached-history switch. Detect the
|
||||
// actual prompt so ordinary model changes stay in native chat.
|
||||
detectAgentInteraction: 'claude-model-switch-confirmation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,15 @@ import type {
|
||||
SessionOptionValue
|
||||
} from './native-chat-session-options'
|
||||
|
||||
export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation'
|
||||
|
||||
export type CatalogMidSessionApply =
|
||||
| { kind: 'command'; build: (value: SessionOptionValue) => string }
|
||||
| {
|
||||
kind: 'command'
|
||||
build: (value: SessionOptionValue) => string
|
||||
pickerCommand?: string
|
||||
detectAgentInteraction?: CatalogAgentInteractionDetection
|
||||
}
|
||||
| { kind: 'toggle-command'; command: string }
|
||||
| { kind: 'agent-picker'; command: string }
|
||||
| { kind: 'unsupported' }
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { SessionOptionValue } from './native-chat-session-options'
|
||||
|
||||
export type {
|
||||
AgentSessionOptionCatalog,
|
||||
CatalogAgentInteractionDetection,
|
||||
CatalogMidSessionApply,
|
||||
CatalogModel,
|
||||
CatalogOption,
|
||||
|
||||
@@ -8,6 +8,14 @@ export type SessionOptionSelectChoice = {
|
||||
|
||||
export type SessionOptionValueSource = 'applied' | 'dispatched' | 'reported' | 'unknown'
|
||||
|
||||
/** Closed set of reasons an option is not settable in the current mode. A key
|
||||
* (not free English) so the producer and the localized label stay in sync —
|
||||
* an exhaustive switch turns any drift into a type error instead of leaking
|
||||
* untranslated text. */
|
||||
export type SessionOptionDisabledReason =
|
||||
| 'available-after-session-start'
|
||||
| 'set-when-session-starts'
|
||||
|
||||
export type SessionOptionDescriptor = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -22,7 +30,7 @@ export type SessionOptionDescriptor = {
|
||||
| { type: 'boolean'; currentValue?: boolean }
|
||||
valueSource: SessionOptionValueSource
|
||||
settable: boolean
|
||||
disabledReason?: string
|
||||
disabledReason?: SessionOptionDisabledReason
|
||||
/** Why: picker-only and toggle-only PTY commands cannot be represented as
|
||||
* a truthful radio/checkbox state, so the producer exposes an action row. */
|
||||
action?: { type: 'agent-picker' | 'toggle-command' }
|
||||
@@ -30,7 +38,6 @@ export type SessionOptionDescriptor = {
|
||||
|
||||
export type SessionOptionSetResult = {
|
||||
snapshot: SessionOptionDescriptor[]
|
||||
notice?: string
|
||||
}
|
||||
|
||||
export type PersistedNativeChatSessionOptions = Partial<
|
||||
|
||||
Reference in New Issue
Block a user