Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { createElement } from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../src/shared/agent-session-journal-item-key.ts'
import { projectStructuredItemsToNativeChat } from '../../src/shared/structured-agent-session-projection.ts'
import { structuredAgentSessionStartFailure } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts'
import { structuredAgentSessionStartFailureRow } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts'
import { MessageRow } from '../../src/renderer/src/components/native-chat/NativeChatMessageRow.tsx'
import { i18n } from '../../src/renderer/src/i18n/i18n.ts'

afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})

describe('startup sign-in rows from host to renderer', () => {
it.each([
['Grok', 'Connectez-vous à Grok avec `grok login` sur l’ordinateur qui exécute ce chat.'],
[
'Pi',
'Connectez-vous à Pi en exécutant `pi` puis en utilisant `/login` sur l’ordinateur qui exécute ce chat.'
],
[
'OpenCode',
'Connectez-vous à OpenCode avec `opencode auth login` sur l’ordinateur qui exécute ce chat.'
],
['OMP', 'Connectez-vous à OMP.']
])(
'translates an actual %s startup row with its diagnostic and resend advice',
async (agentName, guidance) => {
await i18n.changeLanguage('fr')
const detail = 'Provider diagnostic: {{agent}} must remain literal.'
const startup = structuredAgentSessionStartFailure(
{
failure: {
kind: 'notSignedIn',
account: 'system',
detail: { text: detail, audience: 'person' }
}
},
{ agentName }
)
const mutation = structuredAgentSessionStartFailureRow('generation', startup)
if (mutation.kind !== 'item' || mutation.body.kind !== 'status') {
throw new Error('Expected the startup writer to produce a status row')
}
expect(mutation.body.text).toContain('Then send your message again.')
const [message] = projectStructuredItemsToNativeChat([
{
itemId: agentJournalItemKey(mutation.identity),
sequence: 1,
revision: 1,
observedAt: 1,
body: mutation.body,
turnScope: mutation.turnScope
}
])
render(
createElement(MessageRow, {
message,
agentName,
expandSignal: false,
onScrollMessageToTop: vi.fn()
})
)
expect(
screen.getByText(`${guidance} ${detail} Puis envoyez à nouveau votre message.`)
).toBeInTheDocument()
expect(screen.queryByText(mutation.body.text)).toBeNull()
}
)
})
6 changes: 6 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim
// Real SQLite fixtures keep Node publication and close semantics.
export const SQLITE_RUNTIME_INCLUDE = [
...SQLITE_DATABASE_RUNTIME_INCLUDE,
'src/main/acp/acp-structured-sign-in.test.ts',
'src/main/acp/acp-structured-auth.test.ts',
'src/main/acp/acp-timeline-turn-failures.test.ts',
'src/main/acp/acp-structured-session-adapter.test.ts',
'src/main/pi/rpc-turn-races.test.ts',
'src/main/active-view-persistence-boundary.test.ts',
'src/main/automations/automation-dispatch-host-fence.test.ts',
'src/main/automations/automation-owner-fencing.test.ts',
Expand Down Expand Up @@ -125,6 +130,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createElement, useRef } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakeRpcClient, type FakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes'
import type * as NativeChatSendError from './use-mobile-native-chat-send-error'

const seen = vi.hoisted(() => ({
toasts: new Array<string>(),
Expand Down Expand Up @@ -39,7 +40,8 @@ vi.mock('../platform/haptics', () => ({

// The native-chat surface this hook composes with. None of it is on the mic's path, and reaching
// the real modules would pull the Expo runtime in behind them.
vi.mock('./use-mobile-native-chat-send-error', () => ({
vi.mock('./use-mobile-native-chat-send-error', async (importOriginal) => ({
...(await importOriginal<typeof NativeChatSendError>()),
useMobileNativeChatSendError: () => ({
message: null,
show: () => {},
Expand All @@ -62,6 +64,7 @@ vi.mock('./use-mobile-native-chat-input-lease', () => ({
}))
vi.mock('./use-mobile-native-chat-controller', () => ({
useMobileNativeChatController: () => ({
nativeChatSession: { messages: [] },
toggleTabChatView: () => {},
showNativeChat: false,
showNativeChatRef: { current: false },
Expand Down
4 changes: 3 additions & 1 deletion mobile/src/session/mobile-native-chat-render-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export type MobileNativeChatPendingItem = {
baselineTailMessageId?: string | null
}

export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
export function foldMobileNativeChatMessages(
messages: readonly NativeChatMessage[]
): NativeChatMessage[] {
// The conversation only: a subagent's rows are that subagent's, and mobile shows
// each spawn as its roster row (one line per child) rather than the child's own rows.
// Normalize first (desktop assembler parity): image marker turns fold into
Expand Down
20 changes: 20 additions & 0 deletions mobile/src/session/mobile-structured-agent-session-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ const acceptedCreateResult = {
const acceptedCreate = { ok: true, result: acceptedCreateResult }

describe('mobile structured agent-session launch', () => {
it.each([false, true])(
'keeps the no-message startup fallback from the host (nested refusal %s)',
async (nested) => {
const message = "Codex isn't signed in. Run `codex login`. Credentials expired."
const refusal = {
code: 'agent_session_operation_invalid',
message,
details: { reason: 'notSignedIn' }
}
const client = clientReturning(
{ ok: true, result: { supported: true } },
nested ? { ok: true, result: { ok: false, refusal } } : { ok: false, error: refusal }
)
const result = await createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
expect(result).toEqual({ kind: 'unknown', message })
expect(
result.kind !== 'created' && result.kind !== 'unsupported' && result.message
).not.toContain('send your message again')
}
)
it('creates through the structured agent-session intent after support is confirmed', async () => {
const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate)

Expand Down
9 changes: 7 additions & 2 deletions mobile/src/session/mobile-structured-agent-session-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
timeoutForDeadline
} from './mobile-structured-agent-session-rpc'
import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery'
import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error'

export async function sendMobileStructuredAgentSessionMessage(input: {
client: RpcClient
Expand All @@ -20,7 +21,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
/** Sent only when the host advertises `agent-session.queued-messages.v1`. */
delivery?: 'queue-if-active'
deadline?: number
onError: (message: string) => void
onError: MobileNativeChatSendErrorReporter
}): Promise<MobileNativeChatSendOutcome> {
const timeoutMs = timeoutForDeadline(input.deadline)
if (timeoutMs === null) {
Expand All @@ -42,7 +43,11 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
})
const outcome = mobileStructuredSendDelivery(result)
if (outcome.error !== null) {
input.onError(outcome.error)
if (outcome.failure) {
input.onError(outcome.error, { failure: outcome.failure })
} else {
input.onError(outcome.error)
}
}
return outcome.outcome
}
8 changes: 4 additions & 4 deletions mobile/src/session/mobile-structured-composer-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,19 @@ describe('mobile structured conversation commands', () => {
expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected')
expect(input.onError).toHaveBeenLastCalledWith(
"The agent is still working. Run /clear when it's done.",
'working'
{ refusedWhile: 'working' }
)
input.busy = () => 'prompt'
expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected')
expect(input.onError).toHaveBeenLastCalledWith(
"Answer the agent's question or approval, then run /clear.",
'prompt'
{ refusedWhile: 'prompt' }
)
input.busy = () => 'working'
expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
expect(input.onError).toHaveBeenLastCalledWith(
"The agent is still working. Run /compact when it's done.",
'working'
{ refusedWhile: 'working' }
)
expect(sendRequest).not.toHaveBeenCalled()
})
Expand All @@ -188,7 +188,7 @@ describe('mobile structured conversation commands', () => {
expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
expect(input.onError).toHaveBeenLastCalledWith(
"The agent is still working. Run /compact when it's done.",
'working'
{ refusedWhile: 'working' }
)
// Ahead of the phone: said as any failure, so it can't go before it is read.
input.busy = () => null
Expand Down
9 changes: 6 additions & 3 deletions mobile/src/session/mobile-structured-composer-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import type {
import {
dispatchStructuredAgentSessionComposerCommand,
isStructuredAgentSessionComposerCommand,
type StructuredAgentSessionCommandRefusalCause,
type StructuredAgentSessionComposerOptions
} from '../../../src/shared/structured-agent-session-composer'
import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause'
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'
import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error'

/** What the person sees and can do, in the words the desktop uses. */
function busyCommandText(
Expand Down Expand Up @@ -40,7 +40,7 @@ export async function dispatchMobileStructuredCommand(input: {
/** The host holds this command as a card behind work in flight, so nothing here holds it. */
waitsInLine: (command: AgentSessionConversationCommand) => boolean
/** `refusedWhile`: what the phone showed the refused command waiting on. */
onError: (message: string, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => void
onError: MobileNativeChatSendErrorReporter
timeoutMs: number
}): Promise<MobileNativeChatSendOutcome | null> {
if (input.pending.current) {
Expand Down Expand Up @@ -103,7 +103,10 @@ export async function dispatchMobileStructuredCommand(input: {
}
})
if (outcome.error) {
input.onError(outcome.error, outcome.refusedWhile)
input.onError(
outcome.error,
outcome.refusedWhile ? { refusedWhile: outcome.refusedWhile } : undefined
)
}
return unknown ? 'unknown' : outcome.accepted ? 'accepted' : 'rejected'
}
37 changes: 37 additions & 0 deletions mobile/src/session/mobile-structured-queued-message-cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,43 @@ function draft(overrides: Partial<AgentSessionQueuedMessage> & { messageId: stri
}

describe('mobileQueuedMessageCards', () => {
it.each([
['Claude', 'claude auth login'],
['Codex', 'codex login'],
['Grok', 'grok login'],
['OpenCode', 'opencode auth login'],
['Pi', '/login'],
['OMP', 'Sign in to OMP.']
])('keeps %s identity and its sign-in action on returned cards', (agentName, guidance) => {
const [card] = mobileQueuedMessageCards(
[draft({ messageId: 'auth', ...returnedAs(agentSessionFailureFact('notSignedIn')) })],
[],
{ pendingPrompt: false, agentName }
)
expect(card?.caption).toContain(agentName)
expect(card?.caption).toContain(guidance)
expect(card?.caption).not.toContain('send your message again')
expect(card?.needsAttention).toBe(true)
})

it.each(['Claude', 'Codex'])(
'keeps %s managed guidance unless a row already explains it',
(agentName) => {
const fact = agentSessionFailureFact('notSignedIn', { account: 'managed' })
const drafts = [draft({ messageId: 'auth', ...returnedAs(fact) })]
const [card] = mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, agentName })
expect(card?.caption).toBe(
`This ${agentName} account isn't signed in. Sign in again in ${agentName} Accounts settings.`
)
const [stated] = mobileQueuedMessageCards(drafts, [], {
pendingPrompt: false,
agentName,
statedFailures: [fact]
})
expect(stated?.caption).toBe('Your message was not sent.')
}
)

it('marks a /compact card as a command, its text as typed', () => {
const [card] = mobileQueuedMessageCards(
[
Expand Down
30 changes: 24 additions & 6 deletions mobile/src/session/mobile-structured-queued-message-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@
// The wire carries no hold copy on purpose: the caption is derived here from the
// draft's own state plus the live facts the client already holds.

import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure'
import {
readWholeAgentSessionFailureFact,
type AgentSessionFailureFact
} from '../../../src/shared/agent-session-failure'
import { agentSessionFailureStatedByRow } from '../../../src/shared/agent-session-visible-failures'
import {
agentSessionWriteNotDoneParts,
agentSessionWriteNoticeEnglish
} from '../../../src/shared/agent-session-refusal-notice'
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-rejection-words'
import {
Expand Down Expand Up @@ -38,10 +45,15 @@ function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string
}

function returnedCaption(
draft: Pick<AgentSessionQueuedMessage, 'returnedReason' | 'returnedRejection'>
draft: Pick<AgentSessionQueuedMessage, 'returnedReason' | 'returnedRejection'>,
agentName?: string,
statedFailures: readonly AgentSessionFailureFact[] = []
): string {
const reason = draft.returnedReason ?? null
const rejection = draft.returnedRejection
if (agentSessionFailureStatedByRow(rejection, statedFailures)) {
return agentSessionWriteNoticeEnglish(agentSessionWriteNotDoneParts('send'))
}
if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) {
return 'Stopped before it was sent'
}
Expand All @@ -50,7 +62,7 @@ function returnedCaption(
return agentSessionWriteNoticeEnglish(
structuredAgentSessionAttemptFailureParts(
{ kind: 'rejected', reason },
{ retryControl: true },
{ agentName, retryControl: true },
readWholeAgentSessionFailureFact(rejection)
)
)
Expand Down Expand Up @@ -99,7 +111,13 @@ export function mobileQueuePauseLabel(pause: Pick<AgentSessionQueuePause, 'reaso
export function mobileQueuedMessageCards(
queuedMessages: readonly AgentSessionQueuedMessage[] | null,
submissions: readonly Pick<AgentJournalSubmission, 'queuedMessageId' | 'dispatchState'>[],
facts: { pendingPrompt: boolean; queuePaused?: boolean; agentWorking?: boolean }
facts: {
pendingPrompt: boolean
queuePaused?: boolean
agentWorking?: boolean
agentName?: string
statedFailures?: readonly AgentSessionFailureFact[]
}
): MobileQueuedMessageCard[] {
if (!queuedMessages || queuedMessages.length === 0) {
return []
Expand All @@ -120,7 +138,7 @@ export function mobileQueuedMessageCards(
const paused = draft.paused === true
const caption =
draft.state === 'returned'
? returnedCaption(draft)
? returnedCaption(draft, facts.agentName, facts.statedFailures)
: paused
? pausedCaption(
draft.pausedReason,
Expand Down
20 changes: 20 additions & 0 deletions mobile/src/session/mobile-structured-send-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ function accepted(
}

describe('mobileStructuredSendDelivery', () => {
it('keeps the typed auth fact beside guidance until the transcript can explain it', () => {
const result = structuredSendResultFixture('rejected', 'Sign in to Grok with `grok login`.')
if (!('submission' in result)) {
throw new Error('expected submission')
}
const fact = {
kind: 'notSignedIn' as const,
detail: { text: 'Key expired.', audience: 'person' as const }
}
result.submission.rejection = fact
expect(mobileStructuredSendDelivery({ status: 'accepted', value: result })).toEqual({
outcome: 'rejected',
error: 'Sign in to Grok with `grok login`.',
failure: fact
})
result.submission.rejection = { kind: 'providerRejected' }
expect(mobileStructuredSendDelivery({ status: 'accepted', value: result }).error).toContain(
'Sign in'
)
})
it('reports transport and host uncertainty on this send', () => {
expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({
outcome: 'unknown',
Expand Down
Loading
Loading