mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
* fix(mobile): open Grok, OpenCode, OMP and Pi chats on the phone The phone advertised only the Claude chat lane, so the host retitled every other agent's chat tab 'Update to view' and the phone's resolver refused non-Claude/Codex chat tabs anyway. Mobile now reads any agent the host lists, passes the chat's real agent to its send and slash-command paths, advertises the registered-agents and Pi dialog capabilities, hides image attach for agents the host says take none (as desktop does), and draws Pi's input dialog as one line. * test(mobile): drive the agents read through the shared fake client * fix(mobile): read options reports with no model Grok, OpenCode and OMP can report no current model, and a chat at rest may have none saved. The host refuses those reports to clients that don't advertise agent-session.options.optional-model.v1, which left the phone's model picker on its seed list and dropped /clear and /compact. * fix(mobile): reserve the chat session for every launched agent The phone matches a launched chat tab only by its session id, which it minted only for Claude and Codex. A Grok, OpenCode, OMP or Pi chat started from + landed only once the host replied, and a lost reply read as "Couldn't confirm the agent started" even with the chat listed. Hosts accept an agent-named id for any agent and ignore it for a terminal launch. * fix(mobile): read the host's agents once per connection, retry after a failure Every session-screen mount asked agentSession.agents again, so the attach button popped in late for OpenCode and Pi, and a read that failed while connected was never retried. Share one read per client, drop it on disconnect, and forget a failed read so the next chat asks again. * test(mobile): pin the chat's own agent on the send path and attach gating The send-bridge test passed with the claude/codex narrowing reverted. Drive an OpenCode chat through the controller and the structured session's send, where /review is the agent's own command but Codex's host claims it, and check the overlay offers attach only when the agent takes images. * fix(mobile): offer a card's X only while a turn is running The X on a structured prompt card stops its turn. Pi opens dialogs outside a turn too, and tapping X there could only say "Stop not sent". Claude and Codex cards arrive inside a running turn, so they keep their X. * fix(runtime): let a phone read the host's registered agents The phone now asks agentSession.agents to decide whether a chat's agent takes images, but the mobile allowlist refused the method, so OpenCode and Pi never got an attach button. It returns only each agent's id and its fixed capability record. * fix(mobile): keep a host's refusal of the agents list for the connection Released hosts advertise registered agents but refuse agentSession.agents to phones (forbidden, not available to mobile clients). Treat that answer as no list until the next connection instead of re-asking on every chat; keep retrying transient failures. * fix(mobile): shrink the model pill before it pushes Send off the composer row On a narrow phone, attach + a long model label + mic left no room for Send (seen with Pi's resting label). The pill's slot is now the row's only shrinkable item, so its label truncates and the buttons keep their size. Rows that already fit look the same. * fix(mobile): keep /clear and /compact for a chat whose agent lists no models The phone dropped a structured chat's option controller when its picker had nothing to show, and the / menu reads the chat's conversation commands from that controller. OMP lists no models, so its menu lost /clear and /compact. Keep the controller for every structured chat; the picker still renders nothing without a model. * feat(runtime): say in a chat's options report whether it takes images The host's options read now carries acceptsImages for every session: the agent definition's imagePrompts, narrowed by a running ACP child to what its initialize advertised (the same rule its sends use). Absent means an older host. * refactor(mobile): read whether a chat takes images from its options report The phone no longer reads the host's agent list for the attach button. It takes acceptsImages from the agentSession.options read it already makes, keyed to the chat, and kept through a pick made during that read. Before the read answers, and from an older host, Claude and Codex keep attach and other agents don't, as before. Drops the agent-list hook with its cache and refusal latch, the host-support flag only it used, and agentSession.agents from the host's mobile allowlist. * refactor(native-chat): gate desktop chat images on the session's options report The composer's image gate takes the session's acceptsImages answer first, falling back to the host's agent list before the options read lands (a new chat's start) and for an older host that sends no answer. With the phone no longer using it, structuredAgentAcceptsImages moves back to the renderer, where it was. * test(native-chat): prove the session's image answer reaches the composer gate
244 lines
8.8 KiB
TypeScript
244 lines
8.8 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
|
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
|
|
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
|
|
|
vi.mock('react-native', () => ({
|
|
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
|
|
View: 'View'
|
|
}))
|
|
|
|
vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
|
|
vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' }))
|
|
vi.mock('./MobileNativeChatBackgroundTasks', () => ({
|
|
MobileNativeChatBackgroundTasks: 'BackgroundTasks'
|
|
}))
|
|
vi.mock('./MobileNativeChatVisual', () => ({ useMobileNativeChatVisualRenderer: () => null }))
|
|
|
|
function assistantTurn(id: string, text: string): NativeChatMessage {
|
|
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
|
}
|
|
|
|
/** One render of the route: chat visible or not, the transcript it currently
|
|
* holds, and the agent-status stream behind it. */
|
|
type Tick = {
|
|
show?: boolean
|
|
messages?: NativeChatMessage[]
|
|
streamingText?: string
|
|
streamLive?: boolean
|
|
identity?: string
|
|
/** The host says a person's Stop is ending the turn. */
|
|
stopping?: boolean
|
|
acceptsImages?: boolean
|
|
}
|
|
|
|
function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members; the rest of the controller is unreachable from it.
|
|
const controller = {
|
|
showNativeChat: tick.show ?? true,
|
|
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
|
|
nativeChatAgent: 'claude',
|
|
nativeChatAcceptsImages: tick.acceptsImages ?? true,
|
|
nativeChatAgentWorking: tick.streamLive ?? false,
|
|
nativeChatTurnIndicator: {
|
|
thinking: false,
|
|
activityText: null,
|
|
stopping: tick.stopping ?? false
|
|
},
|
|
nativeChatStreamingText: tick.streamingText,
|
|
nativeChatStreamLive: tick.streamLive ?? false,
|
|
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
|
|
chatPending: [],
|
|
chatImagePreviewsByMessageId: {},
|
|
chatComposerText: '',
|
|
setChatComposerText: vi.fn(),
|
|
nativeChatQueued: {
|
|
cards: [],
|
|
send: vi.fn(),
|
|
delete: vi.fn(),
|
|
edit: vi.fn(),
|
|
pause: null,
|
|
resume: vi.fn(),
|
|
sessionKey: 'session-a'
|
|
}
|
|
} as unknown as MobileNativeChatController
|
|
return createElement(MobileNativeChatOverlay, {
|
|
controller,
|
|
images: {} as never,
|
|
onMicPress: vi.fn(),
|
|
micActive: false,
|
|
dictationMode: 'toggle',
|
|
onMicPressIn: vi.fn(),
|
|
onMicPressOut: vi.fn(),
|
|
inputLockReason: null,
|
|
sendErrorMessage: null,
|
|
onClearSendError: vi.fn(),
|
|
sendSurfaceId: tick.identity ?? 'tab-a',
|
|
getSendCompletionGeneration: () => 0,
|
|
keyboardInset: 0
|
|
})
|
|
}
|
|
|
|
describe('MobileNativeChatOverlay streaming gate', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
async function render(tick: Tick): Promise<void> {
|
|
await act(async () => {
|
|
renderer = create(overlayElement(tick))
|
|
})
|
|
}
|
|
|
|
async function update(tick: Tick): Promise<void> {
|
|
await act(async () => {
|
|
renderer?.update(overlayElement(tick))
|
|
})
|
|
}
|
|
|
|
/** The bubble text handed to the chat list, or `'hidden'` when chat is off. */
|
|
function streaming(): string | null | 'hidden' {
|
|
const views = renderer!.root.findAll((node) => node.type === 'ChatView')
|
|
return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null)
|
|
}
|
|
|
|
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
|
|
const prior = [assistantTurn('a1', 'The tests pass.')]
|
|
await render({ messages: prior })
|
|
expect(streaming()).toBeNull()
|
|
|
|
await update({ messages: prior, streamingText: 'The tests', streamLive: true })
|
|
|
|
expect(streaming()).toBe('The tests')
|
|
})
|
|
|
|
it('drops the streaming bubble once the reply lands as its own turn', async () => {
|
|
const prior = [assistantTurn('a1', 'Done.')]
|
|
await render({ messages: prior })
|
|
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
|
expect(streaming()).toBe('Done.')
|
|
|
|
await update({
|
|
messages: [...prior, assistantTurn('a2', 'Done.')],
|
|
streamingText: 'Done.',
|
|
streamLive: true
|
|
})
|
|
|
|
expect(streaming()).toBeNull()
|
|
})
|
|
|
|
it('keeps the bubble across a peek at the terminal view', async () => {
|
|
// Toggling to the terminal unmounts the chat list and unsubscribes its
|
|
// transcript. The gate lives above that boundary, so the baseline survives
|
|
// and the repeated-prefix reply keeps streaming on the way back.
|
|
const prior = [assistantTurn('a1', 'Done.')]
|
|
await render({ messages: prior })
|
|
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
|
expect(streaming()).toBe('Done.')
|
|
|
|
await update({ show: false, messages: [], streamLive: true })
|
|
expect(streaming()).toBe('hidden')
|
|
// Back on chat the session withholds its transcript until a fresh read
|
|
// settles, so the throttled stream text returns a round trip ahead of it.
|
|
await update({ messages: [], streamLive: true })
|
|
await update({ messages: [], streamingText: 'Done.', streamLive: true })
|
|
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
|
|
|
expect(streaming()).toBe('Done.')
|
|
})
|
|
|
|
it('keeps the bubble across a peek at the terminal taken between turns', async () => {
|
|
// Same toggle, but taken while idle: the transcript empties before the next
|
|
// turn starts, so the gate has to reject that empty tail as a baseline.
|
|
const prior = [assistantTurn('a1', 'Done.')]
|
|
await render({ messages: prior })
|
|
|
|
await update({ show: false, messages: [] })
|
|
await update({ show: false, messages: [], streamLive: true })
|
|
await update({ messages: [], streamLive: true })
|
|
await update({ messages: [], streamingText: 'Done.', streamLive: true })
|
|
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
|
|
|
expect(streaming()).toBe('Done.')
|
|
})
|
|
|
|
it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
|
|
// Between parts the status frame carries no assistant text (a tool call), so
|
|
// the stream goes textless while the turn is still live and the part that
|
|
// just finished lands in the transcript. Re-anchoring on that tick would
|
|
// adopt it as history and render it a second time.
|
|
const prior = [assistantTurn('a1', 'Done.')]
|
|
await render({ messages: prior })
|
|
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
|
expect(streaming()).toBe('Done.')
|
|
|
|
const landed = [...prior, assistantTurn('a2', 'Done.')]
|
|
await update({ messages: landed, streamLive: true })
|
|
await update({ messages: landed, streamingText: 'Done.', streamLive: true })
|
|
|
|
expect(streaming()).toBeNull()
|
|
})
|
|
|
|
it("does not carry one chat's baseline into another stream identity", async () => {
|
|
const prior = [assistantTurn('a1', 'Shared answer text')]
|
|
await render({ messages: prior, identity: 'tab-a' })
|
|
|
|
await update({
|
|
messages: prior,
|
|
streamingText: 'Shared answer',
|
|
streamLive: true,
|
|
identity: 'tab-b'
|
|
})
|
|
|
|
expect(streaming()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
async function steerHeld(tick: Tick): Promise<unknown> {
|
|
await act(async () => {
|
|
renderer = create(overlayElement(tick))
|
|
})
|
|
const view = renderer!.root.find((node) => node.type === 'ChatView')
|
|
// The tray's second child is the queued box; the first is the child-work strip.
|
|
return view.props.composerTray?.content?.props?.children?.[1]?.props?.steerHeld
|
|
}
|
|
|
|
it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => {
|
|
expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true)
|
|
})
|
|
|
|
it('leaves Steer to the cards otherwise', async () => {
|
|
expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MobileNativeChatOverlay attach button', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
it.each([true, false])('offers attach only when the agent takes images (%s)', async (accepts) => {
|
|
await act(async () => {
|
|
renderer = create(overlayElement({ acceptsImages: accepts }))
|
|
})
|
|
const view = renderer!.root.find((node) => node.type === 'ChatView')
|
|
expect(typeof view.props.onAttachImage === 'function').toBe(accepts)
|
|
})
|
|
})
|