Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
20eb8df
refactor(native-chat): settle a dead agent's leftover work at its own…
brennanb2025 Oct 9, 2026
19612d5
feat(native-chat): one host view of an agent's current work, with exe…
brennanb2025 Oct 9, 2026
f122acb
feat(native-chat): settle an ended agent generation's work in the bac…
brennanb2025 Oct 9, 2026
f8de364
fix(native-chat): round-1 review fixes for settling a gone agent's le…
brennanb2025 Oct 9, 2026
edd0c63
fix(native-chat): startup settles at main's scope; an earlier process…
brennanb2025 Oct 9, 2026
f4ca43e
chore(native-chat): name the first-open cursor as process-scoped
brennanb2025 Oct 9, 2026
a072305
fix(native-chat): an earlier process's share runs whatever the lease;…
brennanb2025 Oct 9, 2026
86687f1
fix(native-chat): the worker retries a failed restart reconcile; the …
brennanb2025 Oct 9, 2026
d074bdf
fix(native-chat): one restart reconcile retry for the store; the queu…
brennanb2025 Oct 10, 2026
f4e9bce
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-set…
brennanb2025 Oct 10, 2026
9a0db04
refactor(native-chat): one retry coordinator for a gone agent's lefto…
brennanb2025 Oct 10, 2026
16d6a8e
fix(native-chat): background visits take turns; a held epoch's failed…
brennanb2025 Oct 10, 2026
a372491
fix(native-chat): background paths write through background-bound han…
brennanb2025 Oct 10, 2026
28cd521
fix(native-chat): startup bookkeeping fails fast; a dead turn reads i…
brennanb2025 Oct 10, 2026
ff5f1a8
fix(native-chat): retries give up by time, and recovery stays with th…
brennanb2025 Oct 10, 2026
ceb7fd3
fix(native-chat): queued cards keep main's Steer; the relaunch bound …
brennanb2025 Oct 10, 2026
13b09e2
refactor(native-chat): one owed set, one wake, one budget for backgro…
brennanb2025 Oct 10, 2026
31c79fe
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-set…
brennanb2025 Oct 10, 2026
8c06757
fix(native-chat): an unreconciled owner reads unverifiable; retry bud…
brennanb2025 Oct 10, 2026
dda2bfc
fix(native-chat): a queue waits on an owner no adjudication concluded…
brennanb2025 Oct 10, 2026
cde3a53
fix(native-chat): every host reader reads one current work, with this…
brennanb2025 Oct 10, 2026
2947840
test(mobile): queued Steer pin uses main's card options
brennanb2025 Oct 10, 2026
c26186a
test(native-chat): status-feed journal fixtures answer runningTurn
brennanb2025 Oct 10, 2026
0015b14
Merge origin/main into brennanb2025/chat-settle-at-ownership-events
brennanb2025 Oct 12, 2026
c4bf684
Merge origin/main into brennanb2025/chat-settle-at-ownership-events
brennanb2025 Oct 12, 2026
3b19224
style(native-chat): keep the message list under its line cap with mai…
brennanb2025 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
20 changes: 20 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts',
Expand All @@ -112,6 +113,23 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement-scope.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-unadjudicated-owner-queue.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement-triggers.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-reconciliation-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-scan.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-scan-lifetime.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-recovery-scope.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-reconciliation-parked-debts.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-exit-delivery.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-background-writes.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-background-lock.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-dead-turn-frames.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restore-recovery-scope.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-retry-scope.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-roster-provenance.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts',
Expand Down Expand Up @@ -153,6 +171,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
Expand Down Expand Up @@ -200,6 +219,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/agent-session-conversation-clear-commit.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
'src/main/runtime/agent-session-death-evidence-persistence.test.ts',
'src/main/runtime/agent-session-generation-end.test.ts',
'src/main/runtime/agent-session-launch-directory.test.ts',
'src/main/runtime/agent-session-launch-env-admission.test.ts',
'src/main/runtime/agent-session-lease-renewal.test.ts',
Expand Down
22 changes: 21 additions & 1 deletion mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionQueuePause } from '../../../src/shared/agent-session-wire'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages'
import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards'
import {
mobileQueuedMessageCards,
type MobileQueuedMessageCard
} from './mobile-structured-queued-message-cards'

vi.mock('react-native', () => ({
Pressable: 'Pressable',
Expand Down Expand Up @@ -209,6 +212,23 @@ describe('MobileNativeChatQueuedMessages', () => {
expect(nodeTypes(rows[1]!)).toContain('CornerDownRight')
})

it('a waiting card still reads Steer while nothing runs, as when its agent died and its cleanup waits', async () => {
const [waiting] = mobileQueuedMessageCards(
[
{
messageId: 'waiting',
position: 1,
state: 'waiting',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] }
}
],
[],
{ agentWorking: false }
)
const mounted = await mount({ cards: [waiting!], onSend: vi.fn(async () => true) })
expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual(['Steer'])
})

it('a command card never steers: Send only while the agent is idle, and no menu', async () => {
const onSend = vi.fn(async () => true)
const mounted = await mount({
Expand Down
66 changes: 66 additions & 0 deletions mobile/src/session/mobile-structured-actionable-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// The phone answers only the prompts the host says still wait on the person: one an agent that
// has ended raised gets no card, and an older host, which names none, keeps today's rule.

import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import {
actionableStructuredApproval,
actionableStructuredQuestion
} from './mobile-structured-actionable-prompt'

const PENDING = {
state: 'pending' as const,
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}

function approval(itemId: string, sequence: number): AgentJournalRenderItem {
return {
itemId,
revision: 1,
sequence,
observedAt: sequence,
body: {
kind: 'approval',
title: 'Run command?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: PENDING
}
}
}

function question(itemId: string, sequence: number): AgentJournalRenderItem {
return {
itemId,
revision: 1,
sequence,
observedAt: sequence,
body: {
kind: 'question',
question: 'Which one?',
options: [{ id: 'a', label: 'A' }],
resolution: PENDING
}
}
}

const ITEMS = [approval('dead-approval', 1), question('dead-question', 2)]

describe('the prompt a phone answers', () => {
it('skips one the host does not name, and answers one it does', () => {
const items = [...ITEMS, approval('live-approval', 3)]
expect(
actionableStructuredApproval({ items, actionablePromptIds: ['live-approval'] })?.itemId
).toBe('live-approval')
expect(
actionableStructuredQuestion({ items, actionablePromptIds: ['live-approval'] })
).toBeNull()
})

it('is the first pending one when an older host names none', () => {
expect(actionableStructuredApproval({ items: ITEMS })?.itemId).toBe('dead-approval')
expect(actionableStructuredQuestion({ items: ITEMS })?.itemId).toBe('dead-question')
})
})
33 changes: 33 additions & 0 deletions mobile/src/session/mobile-structured-actionable-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { isActionableStructuredAgentSessionPrompt } from '../../../src/shared/structured-agent-session-live-turn'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import {
pendingStructuredApproval,
pendingStructuredQuestion,
type StructuredApprovalItem,
type StructuredQuestionItem
} from './mobile-structured-agent-prompts'

type PromptState = Pick<StructuredAgentSessionState, 'items' | 'actionablePromptIds'>

/** The approval a person can answer: pending, and named by the host as waiting when it names them
* (`isActionableStructuredAgentSessionPrompt`): one an agent that ended raised is not. */
export function actionableStructuredApproval(state: PromptState): StructuredApprovalItem | null {
return (
state.items.find(
(item): item is StructuredApprovalItem =>
pendingStructuredApproval(item) &&
isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds)
) ?? null
)
}

/** The question a person can answer, by the same rule. */
export function actionableStructuredQuestion(state: PromptState): StructuredQuestionItem | null {
return (
state.items.find(
(item): item is StructuredQuestionItem =>
pendingStructuredQuestion(item) &&
isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds)
) ?? null
)
}
14 changes: 9 additions & 5 deletions mobile/src/session/mobile-structured-agent-session-cancel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
import {
isActionableStructuredAgentSessionPrompt,
runningStructuredAgentSessionTurnId
} from '../../../src/shared/structured-agent-session-live-turn'
import type { RpcClient } from '../transport/rpc-client'
import {
requestStructuredAgentSessionMutation,
Expand All @@ -10,12 +12,14 @@ import {

type PromptIdentity = { itemId: string; expectedRevision: number }

/** The prompt a Stop cancels: one the host says still waits on the person, when it says. */
export function pendingStructuredPromptIdentity(
items: readonly AgentJournalRenderItem[]
state: Pick<StructuredAgentSessionState, 'items' | 'actionablePromptIds'>
): PromptIdentity | undefined {
const prompt = items.find((item) =>
const prompt = state.items.find((item) =>
item.body.kind === 'approval' || item.body.kind === 'question'
? item.body.resolution.state === 'pending'
? item.body.resolution.state === 'pending' &&
isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds)
: false
)
return prompt ? { itemId: prompt.itemId, expectedRevision: prompt.revision } : undefined
Expand Down
11 changes: 8 additions & 3 deletions mobile/src/session/mobile-structured-agent-session-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
fields: Record<string, unknown>
clientOperationId?: string
timeoutMs?: number
/** The chat's agent, which a refusal about it names. */
agentName?: string
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
const {
client,
Expand All @@ -136,8 +138,10 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
expectedRuntimeFence,
fields,
clientOperationId,
timeoutMs
timeoutMs,
agentName
} = args
const context = agentName ? { agentName } : {}
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
client,
Expand Down Expand Up @@ -171,7 +175,8 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
code: result.refusal.code,
message: agentSessionRefusalNotice(
result.refusal,
phoneWriteKind(fingerprintMethod, fields)
phoneWriteKind(fingerprintMethod, fields),
context
)
}
} catch (error) {
Expand All @@ -184,7 +189,7 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
return {
status: 'failed',
message: agentSessionWriteNoticeEnglish(
agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields))
agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields), context)
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ export function useMobileNativeChatTurnDisclosure({
rows,
turnJournal?.items,
stopping,
turnJournal?.submissions
turnJournal?.submissions,
turnJournal ?? {}
)
: null
if (!ids?.size) {
Expand Down
9 changes: 6 additions & 3 deletions mobile/src/session/use-mobile-structured-agent-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ export function useMobileStructuredAgentMutate(args: {
enabled: boolean
stateRef: { readonly current: StructuredAgentSessionState }
onSendError: (message: string) => void
/** The chat's agent, which a refusal about it names. */
agentName?: string
}): MobileStructuredAgentMutate {
const { client, enabled, onSendError, sessionId, stateRef } = args
const { agentName, client, enabled, onSendError, sessionId, stateRef } = args
return useCallback(
async <TValue>(
method: string,
Expand All @@ -41,7 +43,8 @@ export function useMobileStructuredAgentMutate(args: {
fingerprintMethod,
sessionId,
expectedRuntimeFence: targetFence,
fields
fields,
...(agentName ? { agentName } : {})
})
if (result.status === 'accepted') {
return {
Expand All @@ -56,6 +59,6 @@ export function useMobileStructuredAgentMutate(args: {
onSendError(result.message)
return { status: 'rejected' }
},
[client, enabled, onSendError, sessionId, stateRef]
[agentName, client, enabled, onSendError, sessionId, stateRef]
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,22 @@ describe('mobile structured prompt cancellation', () => {
expect(hook.turnId).toBeNull()
})

it("reads Working from the host's answer, and from its rows only when an older host gives none", () => {
// A turn row an agent that has since ended left running: the host says it is not working.
state = { ...state, items: [runningTurn()], working: false }
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
})
expect(hook.isWorking).toBe(false)
act(() => renderer?.unmount())
const { working: _answered, ...olderHost } = state
state = olderHost
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
})
expect(hook.isWorking).toBe(true)
})

it('uses the rendered prompt identity when the journal changes before tap', async () => {
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
Expand Down
Loading
Loading