Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0979a80
feat(native-chat): one saved model catalog for every structured agent
brennanb2025 Oct 8, 2026
ea9e6e9
fix(native-chat): host names a listing's configured model; live listi…
brennanb2025 Oct 8, 2026
4f8b642
fix(native-chat): discovery and live listings kept apart; every agent…
brennanb2025 Oct 8, 2026
4d8c645
fix(native-chat): phone new-chat mark clears on the chat's first live…
brennanb2025 Oct 8, 2026
da39f52
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 8, 2026
3997b59
fix(native-chat): live-listing type lives beside the catalog entry so…
brennanb2025 Oct 8, 2026
7dc6d9c
fix(native-chat): the model picker never waits on a listing; a first …
brennanb2025 Oct 8, 2026
c07dae0
feat(native-chat): the host lists every agent's catalog ahead of use …
brennanb2025 Oct 8, 2026
2c92e61
feat(native-chat): a new Claude chat names the model and effort its c…
brennanb2025 Oct 8, 2026
eeca08f
test(native-chat): catalog service fakes gain prewarm
brennanb2025 Oct 8, 2026
a35551c
test(native-chat): prewarm span recorder without a type assertion
brennanb2025 Oct 8, 2026
41e09b8
fix(native-chat): project agent config overrides the account default …
brennanb2025 Oct 8, 2026
fb90594
test(native-chat): the Codex picker read and the launch it starts res…
brennanb2025 Oct 8, 2026
ca4faae
fix(native-chat): Claude's configured model and effort are saved toge…
brennanb2025 Oct 8, 2026
d7e536d
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 8, 2026
23fbdfb
test(native-chat): new catalog tests use typed hoisted state and type…
brennanb2025 Oct 8, 2026
58594df
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 8, 2026
12e6938
test(native-chat): a prewarm probe sets and clears the sign-in verdic…
brennanb2025 Oct 8, 2026
bfbeb87
fix(native-chat): an aged sign-in verdict re-probes only from probe p…
brennanb2025 Oct 8, 2026
a017b06
fix(native-chat): Claude project settings with an effort cap, a main-…
brennanb2025 Oct 8, 2026
07c02f1
refactor(native-chat): the catalog store's picker waiters live in the…
brennanb2025 Oct 8, 2026
2791217
fix(native-chat): changing an agent's command or environment re-lists…
brennanb2025 Oct 8, 2026
5c987a3
test(native-chat): the sign-in notice tests check the model picker is…
brennanb2025 Oct 8, 2026
57a47fb
fix(native-chat): a probe the host stops keeps the held notice reason…
brennanb2025 Oct 8, 2026
c77d0bd
test(codex): the catalog account-key test uses the runtime's real Cod…
brennanb2025 Oct 8, 2026
7df6b5e
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 8, 2026
979f56f
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 8, 2026
068daa3
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 8, 2026
313060d
feat(native-chat): keep each host's saved model lists in the renderer
brennanb2025 Oct 8, 2026
0947415
fix(native-chat): render the model pills once, from the host's list
brennanb2025 Oct 8, 2026
e658d86
feat(native-chat): answer a saved-only model catalog read
brennanb2025 Oct 8, 2026
c62b34d
fix(native-chat): preload every registered agent's saved model list
brennanb2025 Oct 8, 2026
3a38bf1
test(native-chat): drop a type assertion from the snapshot sync test
brennanb2025 Oct 8, 2026
353071c
fix(native-chat): learn an ACP agent's default from a chat started wi…
brennanb2025 Oct 9, 2026
c2c2d51
feat(native-chat): check OpenCode and OMP project config before namin…
brennanb2025 Oct 9, 2026
08b4f92
fix(native-chat): re-read the host's list once a no-pick chat reports…
brennanb2025 Oct 9, 2026
a027f97
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 9, 2026
f78a9f5
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 9, 2026
e0f8d6a
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-mo…
brennanb2025 Oct 9, 2026
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
2 changes: 2 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,8 @@ 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-host-live-listing.test.ts',
'src/main/acp/acp-structured-host-configured-default.test.ts',
'src/main/acp/acp-structured-omp-recordings.test.ts',
'src/main/acp/acp-text-stream-identity.test.ts',
'src/main/acp/acp-text-message-ownership.test.ts',
Expand Down
30 changes: 30 additions & 0 deletions mobile/src/session/mobile-created-structured-sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Structured chats this phone created and that have not yet answered live, as the desktop latches
// its own launches: only a new chat runs the account's configured model, so only its first frame may
// name it.

const CREATED_LIMIT = 32

const created = new Map<string, { worktree: string }>()

export function rememberMobileCreatedStructuredSession(sessionId: string, worktree: string): void {
created.delete(sessionId)
created.set(sessionId, { worktree })
for (const oldest of created.keys()) {
if (created.size <= CREATED_LIMIT) {
break
}
created.delete(oldest)
}
}

/** The chat's workspace selector when this phone created it, else undefined. */
export function mobileCreatedStructuredSession(
sessionId: string
): { worktree: string } | undefined {
return created.get(sessionId)
}

/** The chat answered live, so a reopened view reads its options rather than the listed default. */
export function forgetMobileCreatedStructuredSession(sessionId: string): void {
created.delete(sessionId)
}
5 changes: 5 additions & 0 deletions mobile/src/session/mobile-existing-agent-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
supportsMobileExistingAgentLaunch,
AGENT_LAUNCH_TAB_CLOSED_MESSAGE
} from './mobile-existing-agent-launch'
import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions'

// A connected client whose only behaviour is the scripted `sendRequest`.
function requestPortRpcClient(sendRequest: RpcClient['sendRequest']): RpcClient {
Expand Down Expand Up @@ -114,6 +115,10 @@ describe('reserveMobileAgentLaunch', () => {
paneKey: `${reservation.pane.tabId}:${reservation.pane.leafId}`,
sessionId: reservation.sessionId
})
// The chat it reserved is one this phone created, so its picker names the listed default.
expect(mobileCreatedStructuredSession(reservation.sessionId!)).toMatchObject({
worktree: expect.stringMatching(/^id:/)
})
})

it('asks for the new tab after the one the user is on', async () => {
Expand Down
4 changes: 4 additions & 0 deletions mobile/src/session/mobile-existing-agent-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
structuredSessionOperationId,
structuredSessionRandomUuid
} from './structured-session-operation-id'
import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions'

// Why: the host waits up to 60s for a terminal agent to become ready before pasting the prompt,
// so a prompted launch must outlive that wait or the phone reports a launch that is still running.
Expand Down Expand Up @@ -122,6 +123,9 @@ export async function launchAgentInExistingWorkspace(args: {
}
: {})
})
if (args.reservation?.sessionId) {
rememberMobileCreatedStructuredSession(args.reservation.sessionId, `id:${args.worktreeId}`)
}
let sent
try {
sent = await sendReplayingAmbiguousDelivery(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch'
import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions'

function clientReturning(
...responses: unknown[]
Expand Down Expand Up @@ -115,6 +116,10 @@ describe('mobile structured agent-session launch', () => {
expect(params.agent).toBe('claude')
expect(params.envelope.sessionId).toMatch(/^claude_[A-Za-z0-9_]{8,128}$/)
expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/)
// Its picker treats it as a new chat, as the desktop does one its own view launched.
expect(mobileCreatedStructuredSession(params.envelope.sessionId)).toEqual({
worktree: 'id:workspace-1'
})
})

it('names the refusing agent in the failure copy rather than always saying Codex', async () => {
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/session/mobile-structured-agent-session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
structuredAgentSupportProbe
} from './mobile-session-launch-operations'
import { structuredSessionRandomUuid } from './structured-session-operation-id'
import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions'

type StructuredCreateSupport = {
supported?: boolean
Expand Down Expand Up @@ -122,6 +123,7 @@ export async function createMobileStructuredAgentSession(
}

const params = createParamsFor(agent, worktree)
rememberMobileCreatedStructuredSession(params.envelope.sessionId, worktree)
let response
try {
response = await structuredAgentSessionCreate.request(client, params, {
Expand Down
113 changes: 113 additions & 0 deletions mobile/src/session/use-mobile-host-model-catalog-upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, type MutableRefObject } from 'react'
import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire'
import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import type { NativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state'
import {
applyStructuredAgentSessionModelCatalog,
settleStructuredAgentSessionBuiltinCatalog,
type StructuredAgentSessionOptionState
} from '../../../src/shared/structured-agent-session-options'
import type { RpcClient } from '../transport/rpc-client'
import { callAgentSession } from './mobile-structured-agent-session-rpc'

// The host answers a waiting read when its first listing lands, within its own 30s picker wait.
const LISTING_WAIT_TIMEOUT_MS = 45_000

/**
* The desktop's host-catalog upgrade on the phone: the picker lists the account's models from the
* host store while the session's own options read may still wait on its attach. Until the host
* answers it shows the quiet placeholder; it never waits on a listing: a first listing still
* running lands in place. An older host refuses the method, and the built-in list stands, naming
* nothing, until the options read lands.
*/
export function useMobileHostModelCatalogUpgrade(args: {
agent: string | null
client: RpcClient | null
sessionId: string | null
enabled: boolean
/** This phone created the chat, so it launches the listed default; a reopened one may not. */
newLaunch: boolean
/** Where a new chat runs: the host names no default its config could replace. */
worktree?: string
fence: number | null
optionCatalog: AgentSessionOptionCatalog | null
activeOptionRecordRef: MutableRefObject<NativeChatSessionOptionRecord>
updateOptionState: (
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
) => void
}): void {
const {
activeOptionRecordRef,
agent,
client,
enabled,
fence,
newLaunch,
optionCatalog,
sessionId,
updateOptionState,
worktree
} = args
useEffect(() => {
if (!client || !sessionId || !enabled || !agent || !optionCatalog) {
return
}
let stale = false
const params = { agent, sessionId, ...(newLaunch && worktree ? { worktree } : {}) }
const read = (waitForListing: boolean): Promise<AgentSessionModelCatalogResult> =>
waitForListing
? callAgentSession(
client,
'agentSession.modelCatalog',
{ ...params, waitForListing },
LISTING_WAIT_TIMEOUT_MS
)
: callAgentSession(client, 'agentSession.modelCatalog', params)
// A pick made meanwhile stays on the record; a listing only replaces the list it is picked from.
const apply = (catalog: AgentSessionModelCatalogResult): void => {
if (stale) {
return
}
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { newLaunch })
: current
)
}
// A refused or failed read is no list: the built-in one becomes usable, naming nothing.
const settleBuiltin = (): void => {
if (!stale) {
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? settleStructuredAgentSessionBuiltinCatalog(current)
: current
)
}
}
void read(false)
.then((catalog) => {
if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) {
// Usable on the built-in list while the listing runs; it lands in place.
apply(catalog)
return read(true).then(apply)
}
apply(catalog)
return undefined
})
.catch(settleBuiltin)
return () => {
stale = true
}
}, [
activeOptionRecordRef,
agent,
client,
enabled,
fence,
newLaunch,
optionCatalog,
sessionId,
updateOptionState,
worktree
])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'

const HOST_LIST: AgentSessionModelCatalogResult = {
origin: 'probe',
models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }],
fetchedAt: 1_000
}

/** Catalog reads take each scripted answer in turn; option reads never answer. */
function client(catalog: () => Promise<AgentSessionModelCatalogResult>): RpcClient {
return {
sendRequest: async (method: string) =>
method === 'agentSession.modelCatalog'
? { id: 'rpc-1', ok: true as const, result: await catalog(), _meta: { runtimeId: 'r' } }
: new Promise(() => {}),
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
}

function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] {
const model = snapshot.find((entry) => entry.id === 'model')
return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : []
}

type Props = { rpc: RpcClient; fence: number }

async function mount(initial: Props) {
const frames: string[][] = []
const mutate: StructuredAgentSessionMutate = vi.fn()
function Probe(props: Props): null {
const options = useMobileStructuredAgentOptions({
agent: 'codex',
client: props.rpc,
sessionId: 'session-1',
enabled: true,
fence: props.fence,
mutate
})
frames.push(modelChoices(options.optionSnapshot))
return null
}
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(createElement(Probe, initial))
})
return {
frames,
update: (next: Props) =>
act(async () => {
renderer?.update(createElement(Probe, next))
}),
unmount: () => act(async () => renderer?.unmount())
}
}

describe('the phone picker’s first frame', () => {
it('shows the quiet placeholder, never the built-in list, until the host answers', async () => {
let answer!: (value: AgentSessionModelCatalogResult) => void
const rpc = client(() => new Promise((resolve) => (answer = resolve)))
const harness = await mount({ rpc, fence: 1 })
expect(harness.frames.every((choices) => choices.length === 0)).toBe(true)
await act(async () => answer(HOST_LIST))
expect(harness.frames.at(-1)).toEqual(['gpt-host'])
// Only the host's list was ever offered.
expect(harness.frames.filter((choices) => choices.length > 0)).toEqual(
harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host'])
)
await harness.unmount()
})

it('keeps the host list through a new fence instead of falling back to the placeholder', async () => {
let reads = 0
const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {})))
const harness = await mount({ rpc, fence: 1 })
expect(harness.frames.at(-1)).toEqual(['gpt-host'])
const before = harness.frames.length
await harness.update({ rpc, fence: 2 })
expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true)
await harness.unmount()
})

it('makes the built-in list usable when the host refuses the read', async () => {
const rpc = client(() => Promise.reject(new Error('method_not_found')))
const harness = await mount({ rpc, fence: 1 })
expect(harness.frames.at(-1)?.length).toBeGreaterThan(0)
await harness.unmount()
})
})
Loading
Loading