orca/mobile/src/session/mobile-native-chat-send.tes...

372 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import {
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
openMobileNativeChatSendBudget,
clearMobileNativeChatInput,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
} from './mobile-native-chat-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
function clientWithResponse(response: unknown): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue(response)
} as unknown as RpcClient
}
describe('sendMobileNativeChatMessage', () => {
it('returns true only when the terminal accepts the send', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: 'hello',
enter: true,
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
client: { id: 'device', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('returns false when the terminal rejects the send', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('returns false when the RPC fails', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('disconnected'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('reports a definite rejection when the RPC failed before the frame was written', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('Connection interrupted'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('rejected')
})
it('reports an unknown outcome when the RPC failed after the frame hit the wire', async () => {
const client = {
sendRequest: vi
.fn()
.mockRejectedValue(markRpcDeliveryUnknown(new Error('Connection interrupted')))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('unknown')
// The boolean wrapper still treats unknown as not-accepted.
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('reports an unknown outcome when the request timeout expires', async () => {
// The request-timeout path: the frame was written and only the ack is missing,
// so calling it "Message not sent" would hide a message the desktop received.
const client = {
sendRequest: vi
.fn()
.mockRejectedValue(markRpcDeliveryUnknown(new Error('Request timed out: terminal.send')))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('unknown')
})
it('reports a definite rejection when the connect wait times out', async () => {
// The same timeout budget also covers the pre-connect wait, which is deliberately
// NOT marked delivery-unknown — no frame was ever written.
const client = {
sendRequest: vi
.fn()
.mockRejectedValue(new Error('Timed out while connecting to the remote Orca runtime.'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('rejected')
})
it('reports an unknown outcome when a logical cutover interrupts the send', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new LogicalClientCutoverError())
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('unknown')
// The boolean wrapper still treats unknown as not-accepted (never retried here).
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('treats a cross-bundle cutover error (matched by message) as unknown', async () => {
// Why: instanceof can miss across bundle copies, so cutover is also matched
// by its message — that path must still land on ambiguous, not rejected.
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('RPC interrupted by connection migration'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
).resolves.toBe('unknown')
})
it('reports acceptance and host rejection as definite outcomes', async () => {
const accepted = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessageWithOutcome({ client: accepted, terminal: 'term', text: 'hi' })
).resolves.toBe('accepted')
const rejected = clientWithResponse({
id: 'request',
ok: false,
error: { message: 'no pane' },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessageWithOutcome({ client: rejected, terminal: 'term', text: 'hi' })
).resolves.toBe('rejected')
})
it('prepends the input-line clear byte when clearInputFirst is set', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: '\x15hello',
enter: true
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('sends the text verbatim when clearInputFirst is not set', async () => {
// An image send pastes the image (behind its own leading Ctrl+U) before this
// text write; a clear byte here would kill the pasted image off the input line.
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'what is this',
clearInputFirst: false
})
const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string }
expect(sent.text).toBe('what is this')
expect(sent.text.startsWith('\x15')).toBe(false)
})
it('sends a single non-submitting Escape for prompt cancellation', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: String.fromCharCode(27),
enter: false
})
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: String.fromCharCode(27),
enter: false
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('spends only what is left of a shared budget', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessageWithOutcome({
client,
terminal: 'term',
text: 'hi',
deadline: Date.now() + 4_000
})
const options = vi.mocked(client.sendRequest).mock.calls[0]?.[2] as { timeoutMs: number }
expect(options.timeoutMs).toBeGreaterThan(3_000)
expect(options.timeoutMs).toBeLessThanOrEqual(4_000)
})
it('refuses a write whose shared budget cannot fund the final acknowledgement', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
// Nothing reaches the wire, so this is a definite non-send rather than ambiguous.
await expect(
sendMobileNativeChatMessageWithOutcome({
client,
terminal: 'term',
text: 'hi',
deadline: Date.now() + 400
})
).resolves.toBe('rejected')
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('opens a budget bounded by the send timeout', () => {
const budget = openMobileNativeChatSendBudget() - Date.now()
expect(budget).toBeGreaterThan(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS - 1_000)
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
})
})
describe('clearMobileNativeChatInput', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const params = (client: RpcClient) =>
vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean }
it('writes the burst as its OWN non-submitting write', async () => {
// Bundling the burst into the body write reached the agent as LITERAL Ctrl+U
// text and the parked draft concatenated (observed live).
const client = clientWithResponse(accepted)
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
await expect(
clearMobileNativeChatInput({ client, terminal: 'term', clearInput })
).resolves.toBe(true)
expect(params(client)).toMatchObject({ text: clearInput, enter: false })
})
it('reports failure when the host rejects the clear', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await expect(
clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' })
).resolves.toBe(false)
})
it('refuses to start an underfunded clear rather than half-clearing', async () => {
const client = clientWithResponse(accepted)
await expect(
clearMobileNativeChatInput({
client,
terminal: 'term',
clearInput: '\x15',
deadline: Date.now() + 10
})
).resolves.toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
describe('the body write never carries a multi-line burst', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const sentText = (client: RpcClient): string =>
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(sentText(client)).toBe('\x15hello')
})
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'caption',
clearInputFirst: false
})
expect(sentText(client)).toBe('caption')
})
})