mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* refactor(mobile): share the queued-card projection with desktop Image-only queued drafts showed a blank card on the phone. * fix(mobile): honor the host's Queue follow-ups setting With it off, a mid-turn send from the phone still queued while desktop sent it into the running turn. * fix(mobile): keep a queued-card edit when the card leaves the queue The phone closed the editor and dropped the typing; it now goes to the chat box as on desktop. * fix(mobile): hold /clear and /compact for background work and sends The phone fired the command where desktop holds it, showed start failures twice, and left the agent unnamed in failures. * fix(mobile): hand a queued edit back to the chat it came from * fix(mobile): scope a command's send hold to its own chat * fix(mobile): read a published null queue as empty * refactor(mobile): share the returned queued-card caption rule * test(mobile): drop queued-card tests the shared rules already own * refactor(native-chat): share the needs-attention rule and drop leftover wrappers * fix(mobile): type the structured sends mock, drop the moved queued-cards path and ship queued edit copy at boot * fix(native-chat): drop the queued card's unused image-label import after the rebase * fix(mobile): order the phone's queue reorder preview by position, as the shared cards sort * test(mobile): keep the queued-messages test within its line budget * fix(mobile): settle terminal chat working state and turn timing with desktop's rules Move desktop's live-status merge, Stop suppression, hook-state resolver and terminal turn timing to src/shared/native-chat. Mobile reads the transcript lifecycle the host already sends, so a provider turn end settles Working the same way desktop does. Terminal chats now get the turn bar, timer and folding, and the streaming preview uses the shared deriveNativeChatStreamingText. * fix(mobile): send desktop's terminal Stop keys, a second Escape only for OpenCode Desktop sends one Escape on Stop. It adds a paced second Escape only for OpenCode, which confirms an interrupt that way. Claude Code and Codex read Esc-Esc at an idle prompt as rewind. The rule moves to native-chat-agent-support, and the phone now paces the second Escape at desktop's answer step instead of 80ms. * fix(mobile): word Orca-stop and usage-limit notices like desktop Move desktop's Orca-stop sentences to src/shared with an English table that desktop translates. The phone names the Orca host it pairs with, and when that host is unnamed it says "the remote server". Usage-limit rows rebuild desktop's sentence from the failure fact. The usageLimitLineText fallback, which only the phone used, is deleted. * test(mobile): pass the overlay's host name in the prompt controller harness * test(mobile): re-record Stop goldens at desktop's step, split status-notice tests, pin the turn clock and ship notice copy at boot * fix(mobile): offer structured Stop while a send is out and read queue-sends-next as working Move desktop's structured Stop rule, its Stop-joining helpers and the queue-sends-next rule to src/shared. Both sides now use them. The phone counts its in-flight sends, so a conversation-stop host offers Stop before the turn opens. Between queued turns the phone reads as working, and commands that can't wait in line hold like they do on desktop. Untargeted Stops are no longer joined on legacy hosts. * fix(mobile): offer terminal Stop again when its Escape went nowhere Stop hides Working on the phone like it does on desktop. The phone's Escape can fail outright, though, while desktop writes to a local PTY. When no Escape could have landed, the phone now drops that suppression. The streaming preview also treats a pending echo as the transcript's tail, as desktop does, so an earlier reply can't hide a new preview. * fix(mobile): hold structured Stop while a send it covers is in flight On desktop, Stop's held and Stopping state depends on whether the chat can stop, and that includes a send still in flight. The phone now does the same. Once Stop is pressed before the turn opens, the button holds and repeated presses send no more untargeted cancels. * fix(mobile): end the terminal Stop hold when a new message is sent Desktop clears Stop's working suppression on an optimistic send, so a new turn that keeps the same hook epoch (Claude with working subagents) still offers Stop. The phone now does the same when a new pending echo appears. * refactor(mobile): trim chat turn wiring after the desktop rule move Drop the controller's unread nativeChatAgentWorking field. Re-render only when a chat starts or stops having a send out. Keep the structured turn indicator stable across stream frames. Type the Stop test table instead of casting. * refactor(mobile): track structured sends in flight once for Stop and command holds * test(mobile): re-record Stop goldens now that Stop settles with its Escape delivery * fix(mobile): escape terminal image paths like desktop drops * fix(mobile): keep launch-draft prefill over older user turns * fix(mobile): rank @ file matches with desktop's fuzzy Quick Open search * fix(mobile): use desktop's composer triggers for mid-prompt / and $ picks Picked @ files are now quoted when the path has spaces. * fix(mobile): send picked skill paths on structured chats * fix(mobile): list the host's disk-scanned skills in the / and $ menus The phone names the workspace and the host resolves its folder, refusing SSH workspaces as desktop does; hosts without skills.discover-workspace.v1 list none. * fix(mobile): forget a picked skill once its token is deleted * fix(mobile): drop a picked skill as soon as its token is deleted, and retry failed skill scans * fix(mobile): treat a picked leading skill as chat and stop failed skill scans from refiring * test(mobile): drop the menu ranking case the shared picker tests own * refactor(mobile): keep skill picks on the draft record and reuse shared send rules A rejected send now keeps its picks and leading-skill origin on both send paths. * fix(mobile): replace a re-picked skill's file and restore picks with a rejected send * fix(mobile): read the chat agent for the skill catalog from the active resolution * style(native-chat): format the composer autocomplete files * fix(native-chat): import the picker's apply and send-classify rules from the shared composer module * refactor(mobile): import the composer's draft-store hooks once * style(mobile): format the notice row's style import
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import type { NativeChatMessage } from '../../src/shared/native-chat-types'
|
|
import { projectNativeChatTranscriptMessages } from '../../src/shared/native-chat-transcript-projection'
|
|
import { createNativeChatMerger, replaceList } from '../../src/shared/native-chat-merge'
|
|
import { readNativeChatTranscript } from '../../src/main/native-chat/transcript-reader'
|
|
import {
|
|
readNativeChatTranscriptTail,
|
|
subscribeNativeChatTranscript
|
|
} from '../../src/main/native-chat/transcript-watch'
|
|
|
|
// Claude timestamps queued prompts at send time but writes them after the tool result that absorbed them.
|
|
const PROMPT = {
|
|
type: 'user',
|
|
uuid: 'u-1',
|
|
timestamp: '2026-06-01T10:00:00.000Z',
|
|
message: { role: 'user', content: 'start the long task' }
|
|
}
|
|
const TOOL_CALL = {
|
|
type: 'assistant',
|
|
uuid: 'a-1',
|
|
timestamp: '2026-06-01T10:00:01.000Z',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'sleep 30' } }]
|
|
}
|
|
}
|
|
const TOOL_RESULT = {
|
|
type: 'user',
|
|
uuid: 'r-1',
|
|
timestamp: '2026-06-01T10:00:30.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }]
|
|
}
|
|
}
|
|
const QUEUED = {
|
|
type: 'attachment',
|
|
uuid: 'q-1',
|
|
parentUuid: 'r-1',
|
|
timestamp: '2026-06-01T10:00:10.000Z',
|
|
attachment: {
|
|
type: 'queued_command',
|
|
prompt: 'also check the config',
|
|
commandMode: 'prompt',
|
|
origin: { kind: 'human' },
|
|
timestamp: '2026-06-01T10:00:10.000Z'
|
|
}
|
|
}
|
|
const REPLY = {
|
|
type: 'assistant',
|
|
uuid: 'a-2',
|
|
timestamp: '2026-06-01T10:00:35.000Z',
|
|
message: {
|
|
role: 'assistant',
|
|
stop_reason: 'end_turn',
|
|
content: [{ type: 'text', text: 'both done' }]
|
|
}
|
|
}
|
|
|
|
const lines = (...records: object[]): string =>
|
|
records.map((record) => `${JSON.stringify(record)}\n`).join('')
|
|
|
|
let roots: string[] = []
|
|
afterEach(async () => {
|
|
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))
|
|
roots = []
|
|
})
|
|
|
|
async function transcriptFile(content: string): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-file-order-'))
|
|
roots.push(root)
|
|
const filePath = join(root, 'session.jsonl')
|
|
await writeFile(filePath, content)
|
|
return filePath
|
|
}
|
|
|
|
function expectPromptAfterToolRun(messages: NativeChatMessage[]): void {
|
|
const rows = projectNativeChatTranscriptMessages(messages)
|
|
expect(rows.map((row) => row.id)).toEqual(['u-1', 'a-1', 'q-1', 'a-2'])
|
|
expect(rows[1]?.blocks.map((block) => block.type)).toEqual(['tool-call', 'tool-result'])
|
|
}
|
|
|
|
describe('transcript rows keep file order', () => {
|
|
it.each([
|
|
['full read', (filePath: string) => readNativeChatTranscript('claude', 's', { filePath })],
|
|
[
|
|
'modern full read',
|
|
(filePath: string) => readNativeChatTranscript('claude', 's', { filePath, sourceOrder: true })
|
|
],
|
|
[
|
|
'modern tail read',
|
|
(filePath: string) =>
|
|
readNativeChatTranscriptTail({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
filePath,
|
|
limit: 40,
|
|
sourceOrder: true
|
|
})
|
|
],
|
|
[
|
|
'tail read',
|
|
(filePath: string) =>
|
|
readNativeChatTranscriptTail({ agent: 'claude', sessionId: 's', filePath, limit: 40 })
|
|
]
|
|
])(
|
|
'places a mid-turn prompt after the tool result it was read with (%s)',
|
|
async (_name, read) => {
|
|
const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT, QUEUED, REPLY))
|
|
const result = await read(filePath)
|
|
if (!('messages' in result)) {
|
|
throw new Error(result.error)
|
|
}
|
|
expectPromptAfterToolRun(result.messages)
|
|
}
|
|
)
|
|
|
|
it.each([false, true])(
|
|
'places a mid-turn prompt appended after the initial snapshot (modern=%s)',
|
|
async (sourceOrder) => {
|
|
const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT))
|
|
const received: NativeChatMessage[] = []
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
filePath,
|
|
initialLimit: 40,
|
|
sourceOrder,
|
|
onInitialSnapshot: (messages) => received.push(...messages),
|
|
onAppend: (messages) => received.push(...messages),
|
|
debounceMs: 5,
|
|
reconciliationIntervalMs: 20
|
|
})
|
|
try {
|
|
await expect.poll(() => received.length).toBe(3)
|
|
await appendFile(filePath, lines(QUEUED, REPLY))
|
|
await expect.poll(() => received.length).toBe(5)
|
|
expectPromptAfterToolRun(received)
|
|
} finally {
|
|
subscription.unsubscribe()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('canonical source positions', () => {
|
|
const record = (id: string, timestamp: string | null, text = id, role = 'assistant') => ({
|
|
type: role,
|
|
uuid: id,
|
|
timestamp,
|
|
message: { role, content: text }
|
|
})
|
|
const read = async (filePath: string, limit: number, beforeOffset?: number) => {
|
|
const result = await readNativeChatTranscriptTail({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
filePath,
|
|
limit,
|
|
beforeOffset,
|
|
sourceOrder: true
|
|
})
|
|
if (!('messages' in result)) {
|
|
throw new Error(result.error)
|
|
}
|
|
return result
|
|
}
|
|
|
|
it('retains reader-produced tied image pages through prepend, append and reconnect into bubbles', async () => {
|
|
const { applyMobileNativeChatStreamFrame } =
|
|
await import('../../mobile/src/session/native-chat/mobile-native-chat-stream-frame')
|
|
const { foldMobileNativeChatMessages } =
|
|
await import('../../mobile/src/session/mobile-native-chat-render-data')
|
|
const filePath = await transcriptFile(
|
|
lines(
|
|
...[
|
|
['z-source', '[Image: source: /tmp/first.png]'],
|
|
['z-prompt', 'first [Image #1]'],
|
|
['a-source', '[Image: source: /tmp/second.png]'],
|
|
['a-prompt', 'second [Image #1]']
|
|
].map(([id, text]) => record(id, '2026-06-01T10:00:00Z', text, 'user'))
|
|
)
|
|
)
|
|
const tail = await read(filePath, 1)
|
|
const older = await read(filePath, 3, tail.beforeOffset)
|
|
const merger = createNativeChatMerger()
|
|
const frame = (
|
|
type: 'snapshot' | 'appended',
|
|
messages: NativeChatMessage[],
|
|
replaceSnapshot = false
|
|
) =>
|
|
applyMobileNativeChatStreamFrame({
|
|
merger,
|
|
frame: { type, messages, hasMore: true },
|
|
limit: 100,
|
|
replaceSnapshot
|
|
})
|
|
frame('snapshot', tail.messages, true)
|
|
replaceList(merger, [...older.messages, ...merger.list])
|
|
frame('appended', tail.messages)
|
|
frame('snapshot', tail.messages)
|
|
const bubbles = foldMobileNativeChatMessages(merger.list, 'claude')
|
|
expect(bubbles.map((row) => row.id)).toEqual(['z-prompt', 'a-prompt'])
|
|
expect(
|
|
bubbles.map((row) =>
|
|
row.blocks.filter((block) => block.type === 'image-ref').map((block) => block.path)
|
|
)
|
|
).toEqual([['/tmp/first.png'], ['/tmp/second.png']])
|
|
})
|
|
|
|
it.each(['tied', 'backward', 'null'] as const)(
|
|
'keeps absolute UTF8/CRLF positions across full, older and partial append reads (%s)',
|
|
async (clock) => {
|
|
const timestamps =
|
|
clock === 'null'
|
|
? [null, null, null]
|
|
: clock === 'tied'
|
|
? [PROMPT.timestamp, PROMPT.timestamp, PROMPT.timestamp]
|
|
: [TOOL_RESULT.timestamp, QUEUED.timestamp, PROMPT.timestamp]
|
|
const records = ['z-思考', 'm-emoji', 'a-answer'].map((id, i) =>
|
|
record(id, timestamps[i], `${id} 🐳`)
|
|
)
|
|
const encoded = records.map((item) => `${JSON.stringify(item)}\r\n`)
|
|
const filePath = await transcriptFile(encoded.join(''))
|
|
const full = await readNativeChatTranscript('claude', 's', { filePath, sourceOrder: true })
|
|
if (!('messages' in full)) {
|
|
throw new Error(full.error)
|
|
}
|
|
expect(full.messages.map((row) => row.transcriptOrder)).toEqual([
|
|
0,
|
|
Buffer.byteLength(encoded[0]),
|
|
Buffer.byteLength(encoded[0] + encoded[1])
|
|
])
|
|
expect(full.messages.map((row) => row.timestamp)).toEqual(
|
|
timestamps.map((time) => (time === null ? null : Date.parse(time)))
|
|
)
|
|
const tail = await read(filePath, 1)
|
|
const older = await read(filePath, 2, tail.beforeOffset)
|
|
expect([...older.messages, ...tail.messages]).toEqual(full.messages)
|
|
const { createIncrementalTranscriptState, readIncrementalTranscriptMessages } =
|
|
await import('../../src/main/native-chat/transcript-incremental-reader')
|
|
const { decodeClaudeTranscriptLine } =
|
|
await import('../../src/main/native-chat/transcript-line-decoders')
|
|
const state = createIncrementalTranscriptState()
|
|
state.offset = state.pendingStart = Buffer.byteLength(encoded[0])
|
|
await writeFile(filePath, encoded[0] + encoded[1].slice(0, -2))
|
|
expect(
|
|
await readIncrementalTranscriptMessages(
|
|
filePath,
|
|
state,
|
|
decodeClaudeTranscriptLine,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true
|
|
)
|
|
).toEqual([])
|
|
await appendFile(filePath, `\r\n${encoded[2]}`)
|
|
const appended = await readIncrementalTranscriptMessages(
|
|
filePath,
|
|
state,
|
|
decodeClaudeTranscriptLine,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true
|
|
)
|
|
expect(appended).toEqual(full.messages.slice(1))
|
|
expect(projectNativeChatTranscriptMessages(full.messages).map((row) => row.id)).toEqual([
|
|
'z-思考',
|
|
'm-emoji',
|
|
'a-answer'
|
|
])
|
|
}
|
|
)
|
|
|
|
it.each([false, true])(
|
|
'rejoins image units across page and append boundaries, source-first=%s',
|
|
async (sourceFirst) => {
|
|
const { createIncrementalAssembler, reset, applyAppends } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-incremental-assembler')
|
|
const { prepareNativeChatLiveMessages } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-live-message-preparation')
|
|
const { commandMarkersAsMessages } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-command-marker')
|
|
const { createNativeChatMessageListProjection } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-message-list-projection')
|
|
const pair = (id: string, path: string) => {
|
|
const prompt = record(id, PROMPT.timestamp, `${id} [Image #1]`, 'user')
|
|
const images = [1, 2].map((n) =>
|
|
record(
|
|
`${id}-image-${n}`,
|
|
PROMPT.timestamp,
|
|
`[Image: source: /tmp/${path}-${n}.png]`,
|
|
'user'
|
|
)
|
|
)
|
|
return sourceFirst ? [...images, prompt] : [prompt, ...images]
|
|
}
|
|
const records = [...pair('z-first', 'first'), ...pair('a-second', 'second')]
|
|
const filePath = await transcriptFile(lines(...records))
|
|
const page = await read(filePath, 2)
|
|
const older = await read(filePath, 4, page.beforeOffset)
|
|
const assembler = createIncrementalAssembler()
|
|
reset(assembler, older.messages)
|
|
for (const message of page.messages) {
|
|
applyAppends(assembler, [message])
|
|
}
|
|
const marker = commandMarkersAsMessages([
|
|
{ id: 'between', command: '/compact', sentAt: Date.parse(PROMPT.timestamp) }
|
|
])
|
|
const normalized = prepareNativeChatLiveMessages([...assembler.messages, ...marker], 'claude')
|
|
const bubbles = createNativeChatMessageListProjection()(normalized).conversation.filter(
|
|
(row) => row.role === 'user'
|
|
)
|
|
expect(bubbles.map((row) => row.id)).toEqual(['z-first', 'a-second'])
|
|
expect(
|
|
bubbles.map((row) =>
|
|
row.blocks.filter((block) => block.type === 'image-ref').map((block) => block.path)
|
|
)
|
|
).toEqual([
|
|
['/tmp/first-1.png', '/tmp/first-2.png'],
|
|
['/tmp/second-1.png', '/tmp/second-2.png']
|
|
])
|
|
expect(assembler.byId.size).toBe(6)
|
|
expect(bubbles.map((row) => row.transcriptOrder)).toEqual([
|
|
older.messages.find((row) => row.id === 'z-first')?.transcriptOrder,
|
|
[...older.messages, ...page.messages].find((row) => row.id === 'a-second')?.transcriptOrder
|
|
])
|
|
}
|
|
)
|
|
|
|
it('keeps factual clocks for status, clear boundaries, mixed sources and optimistic tiers', async () => {
|
|
const { mergeNativeChatLiveSession } =
|
|
await import('../../src/shared/native-chat/native-chat-live-status')
|
|
const { applyCommandMarkerBoundaries, commandMarkersAsMessages } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-command-marker')
|
|
const { assembleNativeChatSession } =
|
|
await import('../../src/renderer/src/components/native-chat/native-chat-session-assembler')
|
|
const { NATIVE_CHAT_STREAMING_ID } =
|
|
await import('../../src/shared/native-chat/native-chat-streaming')
|
|
const filePath = await transcriptFile(
|
|
lines(...Array.from({ length: 20 }, (_, i) => record(`old-${i}`, PROMPT.timestamp)))
|
|
)
|
|
const rows = (await read(filePath, 30)).messages
|
|
const time = Date.parse(PROMPT.timestamp)
|
|
expect(
|
|
mergeNativeChatLiveSession({
|
|
messages: rows,
|
|
sessionId: 's',
|
|
agent: 'grok',
|
|
hookState: 'working',
|
|
stateStartedAt: time + 10
|
|
}).status
|
|
).toBe('working')
|
|
expect(
|
|
applyCommandMarkerBoundaries(rows, [{ id: 'clear', command: '/clear', sentAt: time + 10 }])
|
|
).toEqual([])
|
|
const later = { ...rows[1], id: 'later', timestamp: time + 30 }
|
|
const marker = commandMarkersAsMessages([
|
|
{ id: 'compact', command: '/compact', sentAt: time + 20 }
|
|
])[0]
|
|
const echo = (
|
|
id: string,
|
|
timestamp: number | null,
|
|
source: NativeChatMessage['source'] = 'scrape'
|
|
): NativeChatMessage => ({
|
|
id,
|
|
timestamp,
|
|
source,
|
|
role: 'user',
|
|
blocks: [{ type: 'text', text: id }]
|
|
})
|
|
const transcript = [rows[0], later]
|
|
const result = assembleNativeChatSession({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
sources: {
|
|
transcript,
|
|
hook: [echo('hook', time + 10, 'hook')],
|
|
scrape: [
|
|
marker,
|
|
echo('null', null),
|
|
echo('after', time + 40),
|
|
echo(NATIVE_CHAT_STREAMING_ID, null),
|
|
echo('pending:1', null),
|
|
{ ...echo('queued', null), queued: true },
|
|
echo('launch-pending:1', null)
|
|
]
|
|
}
|
|
})
|
|
expect(result.messages.map((row) => row.id)).toEqual([
|
|
'null',
|
|
'old-0',
|
|
'hook',
|
|
marker.id,
|
|
'later',
|
|
'after',
|
|
NATIVE_CHAT_STREAMING_ID,
|
|
'launch-pending:1',
|
|
'pending:1',
|
|
'queued'
|
|
])
|
|
// Contradictory clocks preserve the canonical subsequence, with one explicit supplemental placement.
|
|
expect(
|
|
assembleNativeChatSession({
|
|
agent: 'claude',
|
|
sessionId: 's',
|
|
sources: {
|
|
transcript: [
|
|
{ ...rows[0], timestamp: 100 },
|
|
{ ...later, timestamp: 0 }
|
|
],
|
|
hook: [echo('middle', 50, 'hook')]
|
|
}
|
|
}).messages.map((row) => row.id)
|
|
).toEqual(['middle', 'old-0', 'later'])
|
|
})
|
|
})
|