mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix structured session startup chrome
This commit is contained in:
@@ -114,6 +114,27 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables zero-turn TUI resume with truthful guidance', () => {
|
||||
render({
|
||||
handoff: {
|
||||
owner: 'native',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null
|
||||
}
|
||||
})
|
||||
|
||||
const label = renderer!.root
|
||||
.findAllByType('Text')
|
||||
.find((node) => node.children.includes('Open agent TUI'))
|
||||
expect(label?.parent?.props).toMatchObject({
|
||||
disabled: true,
|
||||
accessibilityState: { disabled: true },
|
||||
accessibilityHint: 'Send a message first to open the agent TUI.'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders an explicit refusal for unsupported advertised commands', async () => {
|
||||
await submit('/review')
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
restoreMobileStructuredAttachments,
|
||||
type MobileStructuredTimelineRow
|
||||
} from './mobile-structured-session-timeline'
|
||||
import { hasPersistedStructuredAgentSessionTurn } from '../../../src/shared/structured-agent-session-projection'
|
||||
import {
|
||||
admitStructuredOlderPage,
|
||||
beginStructuredUserScroll,
|
||||
@@ -128,6 +129,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
<MobileStructuredHandoffBanner
|
||||
status={props.handoff}
|
||||
isWorking={turnId !== null}
|
||||
hasPersistedTurn={hasPersistedStructuredAgentSessionTurn(props.items)}
|
||||
onRequest={props.onRequestHandoff}
|
||||
/>
|
||||
<FlatList
|
||||
@@ -295,6 +297,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
function MobileStructuredHandoffBanner(props: {
|
||||
status: AgentSessionHandoffStatus | null
|
||||
isWorking: boolean
|
||||
hasPersistedTurn: boolean
|
||||
onRequest: Props['onRequestHandoff']
|
||||
}): React.JSX.Element | null {
|
||||
const status = props.status
|
||||
@@ -321,7 +324,17 @@ function MobileStructuredHandoffBanner(props: {
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
|
||||
style={({ pressed }) => [
|
||||
styles.handoffButton,
|
||||
!props.hasPersistedTurn && styles.disabled,
|
||||
pressed && props.hasPersistedTurn && styles.pressed
|
||||
]}
|
||||
disabled={!props.hasPersistedTurn}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: !props.hasPersistedTurn }}
|
||||
accessibilityHint={
|
||||
props.hasPersistedTurn ? undefined : 'Send a message first to open the agent TUI.'
|
||||
}
|
||||
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
|
||||
onPress={() => void props.onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
|
||||
@@ -36,6 +36,7 @@ export const styles = StyleSheet.create({
|
||||
},
|
||||
cancelText: { color: colors.statusRed, fontSize: typography.metaSize, fontWeight: '600' },
|
||||
pressed: { opacity: 0.7 },
|
||||
disabled: { opacity: 0.45 },
|
||||
writeError: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
|
||||
@@ -243,10 +243,8 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
...unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item),
|
||||
handled: false
|
||||
}
|
||||
const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item)
|
||||
return unhandled ? { ...unhandled, handled: false } : { body: null, blobs: [], handled: true }
|
||||
}
|
||||
|
||||
export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import {
|
||||
@@ -442,6 +443,32 @@ describe('codex journal translation', () => {
|
||||
).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified'])
|
||||
})
|
||||
|
||||
it('keeps a fresh session timeline empty through startup and status notifications', () => {
|
||||
const { translator, tap } = translatorWith()
|
||||
|
||||
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
translator.handle(
|
||||
notification('mcpServer/startupStatus/updated', {
|
||||
server: `server-${index}`,
|
||||
status: 'starting'
|
||||
})
|
||||
)
|
||||
}
|
||||
translator.handle(notification('remoteControl/status/changed', { status: 'disabled' }))
|
||||
|
||||
const timeline = projectStructuredItemsToNativeChat(
|
||||
tap.rows.map((row, index) => ({
|
||||
itemId: row.key,
|
||||
revision: 1,
|
||||
sequence: index + 1,
|
||||
observedAt: index + 1,
|
||||
body: row.body
|
||||
}))
|
||||
)
|
||||
expect(timeline).toEqual([])
|
||||
})
|
||||
|
||||
it('writes nothing more after dispose', () => {
|
||||
const { translator, tap, window } = translatorWith()
|
||||
|
||||
|
||||
@@ -70,8 +70,11 @@ export function createCodexJournalTranslator(
|
||||
let fallbackSequence = 0
|
||||
|
||||
const appendUnhandled = (kind: string, payload: unknown): void => {
|
||||
fallbackSequence += 1
|
||||
const translated = unhandledProviderFrameJournalItem('codex', kind, payload)
|
||||
if (!translated) {
|
||||
return
|
||||
}
|
||||
fallbackSequence += 1
|
||||
deps.sink.appendItem(
|
||||
{ provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` },
|
||||
translated.body,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ProviderFrameDisposition = 'debug-only' | 'timeline'
|
||||
|
||||
function notificationKind(kind: string): string {
|
||||
return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind
|
||||
}
|
||||
|
||||
function isCodexStatusFrame(kind: string): boolean {
|
||||
const method = notificationKind(kind)
|
||||
return (
|
||||
/^(?:thread\/(?:started|closed|archived|unarchived)|thread\/(?:name|status)\/(?:changed|updated))$/.test(
|
||||
method
|
||||
) ||
|
||||
/^thread\/(?:tokenUsage\/updated|goal\/(?:updated|cleared))$/.test(method) ||
|
||||
method === 'mcpServer/startupStatus/updated' ||
|
||||
method === 'remoteControl/status/changed'
|
||||
)
|
||||
}
|
||||
|
||||
function isClaudeStatusFrame(kind: string): boolean {
|
||||
return (
|
||||
kind === 'message:system:init' ||
|
||||
kind === 'message:system:status' ||
|
||||
kind === 'message:result' ||
|
||||
/^message:stream_event:(?:message_start|message_stop|content_block_start|content_block_stop)$/.test(
|
||||
kind
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function classifyProviderFrame(provider: string, kind: string): ProviderFrameDisposition {
|
||||
if (provider === 'codex' && isCodexStatusFrame(kind)) {
|
||||
return 'debug-only'
|
||||
}
|
||||
if (provider === 'claude' && isClaudeStatusFrame(kind)) {
|
||||
return 'debug-only'
|
||||
}
|
||||
return 'timeline'
|
||||
}
|
||||
@@ -15,6 +15,10 @@ describe('unhandled provider frame journal fallback', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(item).not.toBeNull()
|
||||
if (!item) {
|
||||
throw new Error('expected substantive provider frame')
|
||||
}
|
||||
expect(item.body).toMatchObject({
|
||||
kind: 'status',
|
||||
text: 'future-provider · notification:new/event',
|
||||
@@ -41,6 +45,31 @@ describe('unhandled provider frame journal fallback', () => {
|
||||
|
||||
const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic)
|
||||
|
||||
expect(item.body.providerFrame?.payload.head).toContain('unserializable payload')
|
||||
expect(item?.body.providerFrame?.payload.head).toContain('unserializable payload')
|
||||
})
|
||||
|
||||
it('routes provider lifecycle, startup, and status frames away from the timeline', () => {
|
||||
expect(unhandledProviderFrameJournalItem('codex', 'notification:thread/started', {})).toBeNull()
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', {})
|
||||
).toBeNull()
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'notification:remoteControl/status/changed', {})
|
||||
).toBeNull()
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'notification:thread/tokenUsage/updated', {})
|
||||
).toBeNull()
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {})
|
||||
).toBeNull()
|
||||
expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull()
|
||||
expect(unhandledProviderFrameJournalItem('claude', 'message:result', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unknown substantive frames visible for both providers', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'notification:future/event', {})
|
||||
).not.toBeNull()
|
||||
expect(unhandledProviderFrameJournalItem('claude', 'message:future/event', {})).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
type JournalPayloadLimits
|
||||
} from '../agent-session-journal/journal-payload-bounds'
|
||||
import { classifyProviderFrame } from './provider-frame-disposition'
|
||||
|
||||
export type UnhandledProviderFrameJournalItem = {
|
||||
body: AgentJournalStatusItem
|
||||
@@ -19,13 +20,16 @@ function serializeProviderPayload(payload: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every adapter fallback becomes a visible, bounded journal row. */
|
||||
/** Substantive adapter fallbacks become visible, bounded journal rows. */
|
||||
export function unhandledProviderFrameJournalItem(
|
||||
provider: string,
|
||||
kind: string,
|
||||
payload: unknown,
|
||||
limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
): UnhandledProviderFrameJournalItem {
|
||||
): UnhandledProviderFrameJournalItem | null {
|
||||
if (classifyProviderFrame(provider, kind) === 'debug-only') {
|
||||
return null
|
||||
}
|
||||
const serialized = serializeProviderPayload(payload)
|
||||
const bounded = boundPayload(serialized, limits)
|
||||
return {
|
||||
|
||||
@@ -103,6 +103,7 @@ export function NativeChatStructuredSession(props: {
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={controller.handoff}
|
||||
isWorking={controller.isWorking}
|
||||
hasPersistedTurn={controller.hasPersistedTurn}
|
||||
onRequest={(direction, mode, action) => {
|
||||
void controller.requestHandoff(direction, mode, action)
|
||||
}}
|
||||
|
||||
+26
-2
@@ -3,6 +3,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
|
||||
|
||||
const IDLE_NATIVE: AgentSessionHandoffStatus = {
|
||||
@@ -22,8 +23,10 @@ describe('StructuredAgentSessionHandoffChrome', () => {
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={IDLE_NATIVE}
|
||||
isWorking={false}
|
||||
hasPersistedTurn
|
||||
onRequest={onRequest}
|
||||
/>
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' }))
|
||||
@@ -31,6 +34,25 @@ describe('StructuredAgentSessionHandoffChrome', () => {
|
||||
expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn')
|
||||
})
|
||||
|
||||
it('disables zero-turn TUI resume with truthful guidance', async () => {
|
||||
render(
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={IDLE_NATIVE}
|
||||
isWorking={false}
|
||||
hasPersistedTurn={false}
|
||||
onRequest={vi.fn()}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Open agent TUI' })
|
||||
expect(button.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.pointerMove(button.parentElement!)
|
||||
expect(
|
||||
await screen.findAllByText('Send a message first to open the agent TUI.')
|
||||
).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers one Retry action for a recoverable dead TUI owner', () => {
|
||||
const onRequest = vi.fn()
|
||||
render(
|
||||
@@ -47,8 +69,10 @@ describe('StructuredAgentSessionHandoffChrome', () => {
|
||||
}
|
||||
}}
|
||||
isWorking={false}
|
||||
hasPersistedTurn
|
||||
onRequest={onRequest}
|
||||
/>
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull()
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Props = {
|
||||
status: AgentSessionHandoffStatus | null
|
||||
isWorking: boolean
|
||||
hasPersistedTurn: boolean
|
||||
onRequest: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
@@ -54,6 +56,7 @@ function handoffStageCopy(status: AgentSessionHandoffStatus): string {
|
||||
export function StructuredAgentSessionHandoffChrome({
|
||||
status,
|
||||
isWorking,
|
||||
hasPersistedTurn,
|
||||
onRequest
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!status) {
|
||||
@@ -122,15 +125,30 @@ export function StructuredAgentSessionHandoffChrome({
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
|
||||
onClick={() => onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hasPersistedTurn}
|
||||
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
|
||||
onClick={() => onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasPersistedTurn ? (
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'components.native-chat.handoff.sendFirst',
|
||||
'Send a message first to open the agent TUI.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
)
|
||||
) : owner === 'tui' && phase === 'idle' ? (
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../../../shared/structured-agent-session-options'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
hasPersistedStructuredAgentSessionTurn,
|
||||
projectStructuredItemsToNativeChat
|
||||
} from '../../../../shared/structured-agent-session-projection'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
@@ -233,6 +234,7 @@ export function useStructuredAgentSession(args: {
|
||||
send: outboxController.send,
|
||||
retry: outboxController.retry,
|
||||
isWorking: turnId !== null,
|
||||
hasPersistedTurn: hasPersistedStructuredAgentSessionTurn(state.items),
|
||||
turnId,
|
||||
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
|
||||
respond: (item: StructuredPromptItem, optionId: string) =>
|
||||
|
||||
@@ -15131,6 +15131,7 @@
|
||||
"switchAfterTurn": "Switch after this turn",
|
||||
"stopTurnAndSwitch": "Stop turn and switch",
|
||||
"openAgentTui": "Open agent TUI",
|
||||
"sendFirst": "Send a message first to open the agent TUI.",
|
||||
"returnAfterTurn": "Return after this turn",
|
||||
"returnToChat": "Return to chat",
|
||||
"agentOpen": "Agent is open in terminal.",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentJournalRenderItem } from './agent-session-journal-types'
|
||||
import { parsePaneKey } from './stable-pane-id'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
hasPersistedStructuredAgentSessionTurn,
|
||||
projectStructuredItemToNativeChat,
|
||||
projectStructuredAgentSessionStatus,
|
||||
structuredAgentSessionPaneKey
|
||||
@@ -50,6 +51,15 @@ describe('structured agent session status projection', () => {
|
||||
expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' })
|
||||
})
|
||||
|
||||
it('requires a persisted provider conversation turn before TUI resume', () => {
|
||||
const status = item('status', 1, { kind: 'status', text: 'Connected' })
|
||||
const user = item('user', 2, { kind: 'message', role: 'user', blocks: [] })
|
||||
|
||||
expect(hasPersistedStructuredAgentSessionTurn([])).toBe(false)
|
||||
expect(hasPersistedStructuredAgentSessionTurn([status])).toBe(false)
|
||||
expect(hasPersistedStructuredAgentSessionTurn([status, user])).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves provider-frame detail on the backward-compatible status line', () => {
|
||||
const projected = projectStructuredItemToNativeChat(
|
||||
item('frame', 1, {
|
||||
|
||||
@@ -116,6 +116,15 @@ export function activeStructuredAgentSessionTurnId(
|
||||
return null
|
||||
}
|
||||
|
||||
export function hasPersistedStructuredAgentSessionTurn(
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
): boolean {
|
||||
return items.some(
|
||||
(item) =>
|
||||
item.body.kind === 'message' && (item.body.role === 'user' || item.body.role === 'assistant')
|
||||
)
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle'
|
||||
|
||||
export function projectStructuredAgentSessionStatus(
|
||||
|
||||
Reference in New Issue
Block a user