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
Expand Up @@ -128,6 +128,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ??
false

startUnavailable: NonNullable<StructuredAgentSessionAdapter['startUnavailable']> = (sessionId) =>
this.liveOwnerOrNull(sessionId)?.startUnavailable?.(sessionId)

stopEndsSession = (sessionId: string): boolean =>
this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability'
import type {
AgentSessionAccountKind,
AgentSessionUnavailable
} from '../../../shared/agent-session-availability'
import type {
AgentSessionRewindReason,
AgentSessionRewindSupport
Expand Down Expand Up @@ -381,6 +384,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
/** The adapter's own child for this exact acquisition has a pid and its root exit has not been
* seen: first-hand proof of life for lease renewal. Absent or false falls back to a PID probe. */
holdsLiveProviderProcess?(sessionId: string, acquisitionGeneration: string): boolean
/** Why the session's running child can take no turn, as its own start found; undefined when
* unknown or no child runs. Lives and dies with that child. */
startUnavailable?(sessionId: string): AgentSessionUnavailable | undefined
/** The `/` surface the running provider reports for itself. Undefined when the
* provider never reports one, which is what keeps the client on its catalog. */
readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
},
providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true
},
startUnavailable: () => deps.adapter.startUnavailable?.(sessionId),
stopAgent: input.stopSignedOutAgent,
logger: deps.logger
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type RestTestAdapter = {
NonNullable<StructuredAgentSessionAdapter['acknowledgeSessionRelease']>
>
holdsDispatch: Mock<NonNullable<StructuredAgentSessionAdapter['holdsDispatch']>>
startUnavailable: Mock<NonNullable<StructuredAgentSessionAdapter['startUnavailable']>>
readOptions: Mock<NonNullable<StructuredAgentSessionAdapter['readOptions']>>
}

Expand Down Expand Up @@ -159,6 +160,7 @@ export async function createRestTestRig(
dispatch: vi.fn(async () => acceptedDispatch()),
acknowledgeSessionRelease: vi.fn(),
holdsDispatch: vi.fn(() => false),
startUnavailable: vi.fn(() => undefined),
readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } }))
}
const hostFor = (overrides: Partial<StructuredAgentSessionHostDeps>) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
AgentJournalTurnLifecycle
} from '../../../shared/agent-session-journal-types'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability'
import type { JournalItemLinkageVisitor } from '../agent-session-journal/journal-store-contracts'
import type { StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep'

Expand Down Expand Up @@ -63,6 +64,17 @@ describe('a send after the agent said it is not signed in', () => {
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
})

// A Pi that started with no model keeps a placeholder: a sign-in made since reaches a new Pi only.
it('goes to a new agent when the running one started signed out', async () => {
await foundRestTestChat(rig)
rig.adapter.startUnavailable.mockReturnValueOnce({ reason: 'notSignedIn' })
rig.adapter.closeSession.mockClear()
expect((await rig.host.send(CALLER, restTestSend('signed in since', fence()))).ok).toBe(true)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2))
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
})

it('keeps the agent after any other rejection', async () => {
await foundRestTestChat(rig)
rig.adapter.dispatch.mockImplementationOnce(async () => ({
Expand Down Expand Up @@ -163,6 +175,14 @@ describe('structuredAgentSessionChildReportedSignedOut', () => {
expect(reported({ items, child: childAt({ phase: 'starting' }) })).toBe(false)
expect(reported({ items, child: null })).toBe(false)
})

it("reads the running child's own start, and only a signed-out one", () => {
const fromStart = (startUnavailable: AgentSessionUnavailable, child = childAt({})) =>
structuredAgentSessionChildReportedSignedOut(conversation({ child }), startUnavailable)
expect(fromStart({ reason: 'notSignedIn' })).toBe(true)
expect(fromStart({ reason: 'cliMissing' })).toBe(false)
expect(fromStart({ reason: 'notSignedIn' }, childAt({ phase: 'starting' }))).toBe(false)
})
})

describe('retireSignedOutStructuredAgentSessionChild', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// A child that reported its agent is not signed in is replaced before the next send. Some agents
// read their saved login only when they start, so a sign-in made since reaches a new child only.

import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability'
import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure'
import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer'
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
Expand All @@ -26,13 +27,20 @@ type SignedOutReading = Pick<StructuredAgentSessionHostSession, 'child'> & {
const signedOut = (failure: unknown): boolean =>
readWholeAgentSessionFailureFact(failure)?.kind === 'notSignedIn'

/** The running child itself said it is not signed in: a send it rejected, or a row it wrote. Each
* child holds its own fence, so matching it scopes both to this child; a new one has nothing. */
export function structuredAgentSessionChildReportedSignedOut(session: SignedOutReading): boolean {
/** The running child itself said it is not signed in: its start, a send it rejected, or a row it
* wrote. Each child holds its own fence, so matching it scopes both to this child; a new one has
* nothing. `startUnavailable` is the adapter's read of that child's start. */
export function structuredAgentSessionChildReportedSignedOut(
session: SignedOutReading,
startUnavailable?: AgentSessionUnavailable
): boolean {
const { child, journal } = session
if (!child || child.phase === 'starting' || child.close) {
return false
}
if (startUnavailable?.reason === 'notSignedIn') {
return true
}
if (
journal
.submissions()
Expand Down Expand Up @@ -78,13 +86,14 @@ export async function retireSignedOutStructuredAgentSessionChild(
session: SignedOutReading | undefined,
deps: {
work: StructuredAgentSessionChildWorkReads
startUnavailable?: () => AgentSessionUnavailable | undefined
stopAgent: (sessionId: string) => Promise<void>
logger: StructuredAgentSessionLogger
}
): Promise<void> {
if (
!session ||
!structuredAgentSessionChildReportedSignedOut(session) ||
!structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) ||
structuredAgentSessionChildHasOpenWork(session.journal, deps.work) ||
owesWakeUp(session.journal, deps.work.childWork())
) {
Expand Down
26 changes: 26 additions & 0 deletions src/main/pi/rpc-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,3 +560,29 @@ describe('Pi RPC session ownership and delivery', () => {
expect(await other.adapter.closeSession(sessionId)).toBe(false)
})
})

describe('Pi RPC start without a model', () => {
it('reports a start that listed no model as signed out, for that child only', async () => {
const h = await setup()
expect(h.adapter.startUnavailable(sessionId)).toBeUndefined()
await h.adapter.closeSession(sessionId)
h.openConnection.mockImplementationOnce((_launch, handlers) => {
const connection = new FakeConnection(handlers)
// What a signed-out Pi lists (captured from Pi 1.0.4 in __fixtures__/signed-out.jsonl).
connection.requestOverride = (command) =>
command === 'get_available_models' ? Promise.resolve({ models: [] }) : undefined
h.connections.push(connection)
return connection
})
await h.adapter.acquire({ ...h.input, fence: 8 })
expect(h.adapter.startUnavailable(sessionId)).toEqual({ reason: 'notSignedIn' })
// Its root gone while its output still drains: that child says nothing more.
const child = h.connections.at(-1)!
child.rootVerdict = 'exited'
expect(child.closed).toBe(false)
expect(h.adapter.startUnavailable(sessionId)).toBeUndefined()
child.rootVerdict = 'live'
await h.adapter.closeSession(sessionId)
expect(h.adapter.startUnavailable(sessionId)).toBeUndefined()
})
})
12 changes: 12 additions & 0 deletions src/main/pi/rpc-session-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import type { AgentSessionUnavailable } from '../../shared/agent-session-availability'
import { agentSessionFailureFact } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import {
Expand Down Expand Up @@ -233,6 +234,17 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
holdsDispatch(id: string): boolean {
return this.sessions.get(id)?.turns.holdsDispatch ?? false
}
/** Started with no model listed: Pi keeps a placeholder, so every prompt this child takes fails
* as not signed in, even after a sign-in, which only a new Pi reads. */
startUnavailable(id: string): AgentSessionUnavailable | undefined {
const session = this.sessions.get(id)
return session &&
!session.connection.closed &&
session.connection.rootVerdict !== 'exited' &&
session.options?.models.length === 0
? { reason: 'notSignedIn' }
: undefined
}

async closeSession(id: string, requested = true): Promise<boolean> {
const session = this.sessions.get(id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability'
import type { AgentSessionRecord } from '../../../../shared/agent-session-record'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import {
call,
clearStructuredHostStub,
Expand All @@ -26,7 +32,7 @@ describe('agentSession.modelCatalog', () => {
record = null
setStructuredAgentSessionHost(
Object.assign(hostStub(), {
deps: { modelCatalog: { read }, store: { getRecord: () => record } }
deps: { modelCatalog: { read }, store: { getRecord: () => record }, adapter: {} }
})
)
})
Expand Down Expand Up @@ -108,7 +114,7 @@ describe('agentSession.modelCatalog before anything built the host', () => {
const installHost = vi.fn(async () => {
setStructuredAgentSessionHost(
Object.assign(hostStub(), {
deps: { modelCatalog: { read }, store: { getRecord: () => null } }
deps: { modelCatalog: { read }, store: { getRecord: () => null }, adapter: {} }
})
)
})
Expand Down Expand Up @@ -155,3 +161,68 @@ describe('agentSession.modelCatalog before anything built the host', () => {
expect(reply).toMatchObject({ ok: false })
})
})

const PI_CLIENT = {
...STRUCTURED_CLIENT,
clientCapabilities: [
...STRUCTURED_CLIENT.clientCapabilities,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY
]
}

describe("agentSession.modelCatalog with what the running agent's start said", () => {
const read = vi.fn(async (): Promise<AgentSessionModelCatalogResult> => ({ origin: 'unknown' }))
const startUnavailable = vi.fn((_sessionId: string): AgentSessionUnavailable | undefined => ({
reason: 'notSignedIn'
}))
const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' }
const readFor = (agent: string) =>
call('agentSession.modelCatalog', { agent, sessionId: record.sessionId }, PI_CLIENT)

beforeEach(() => {
read.mockReset()
read.mockResolvedValue({ origin: 'unknown' })
startUnavailable.mockClear()
setStructuredAgentSessionHost(
Object.assign(hostStub(), {
deps: {
modelCatalog: { read },
store: { getRecord: () => record },
adapter: { startUnavailable }
}
})
)
})

it("answers a Pi chat's catalog with its signed-out start", async () => {
const reply = await readFor('pi')
expect(startUnavailable).toHaveBeenCalledWith(record.sessionId)
expect(reply).toMatchObject({
ok: true,
result: { origin: 'unknown', unavailable: { reason: 'notSignedIn' } }
})
})

it('keeps the reason the catalog itself found', async () => {
read.mockResolvedValue({ origin: 'unknown', unavailable: { reason: 'cliMissing' } })
expect(await readFor('pi')).toMatchObject({
ok: true,
result: { unavailable: { reason: 'cliMissing' } }
})
})

it("never lends one agent's start to another agent's read", async () => {
const reply = await readFor('codex')
expect(startUnavailable).not.toHaveBeenCalled()
expect(reply).toMatchObject({ ok: true, result: { origin: 'unknown' } })
expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } })
})

it('stays quiet while the start said nothing', async () => {
startUnavailable.mockReturnValueOnce(undefined)
const reply = await readFor('pi')
expect(reply).toMatchObject({ ok: true })
expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//
// `agentSession.options` answers at rest from the record and the catalog, and
// asks the live provider only when one runs. `agentSession.modelCatalog` deliberately does
// neither — answering from the host store is what lets a picker render while
// an attach is still running. It is additive: an older host answers
// neither — answering from the host store (and what a running child's start already said) is
// what lets a picker render while an attach is still running. It is additive: an older host answers
// `method_not_found` (or `forbidden` through the mobile allowlist gate), and
// the client keeps its static seed.

Expand All @@ -20,6 +20,7 @@ import {
OptionsParams
} from './structured-agent-session-schemas'
import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'

export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
defineMethod({
Expand Down Expand Up @@ -55,24 +56,33 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
params.sessionId === undefined
? requireStructuredHost(ctx)
: await requireInstalledStructuredHost(ctx, params.sessionId)
const catalog = host.deps.modelCatalog
if (!catalog) {
return { origin: 'unknown' as const }
}
// A floating chat runs in the folder it was created in, not the floating setting's current one.
const record =
params.sessionId === undefined ? null : host.deps.store.getRecord(params.sessionId)
const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined
if (launchDirectory) {
return catalog.read({ ...params, workspacePath: launchDirectory })
}
if (worktree === undefined) {
return catalog.read(params)
const read = async (): Promise<AgentSessionModelCatalogResult> => {
const catalog = host.deps.modelCatalog
if (!catalog) {
return { origin: 'unknown' }
}
// A floating chat runs in the folder it was created in, not the floating setting's current one.
const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined
if (launchDirectory) {
return catalog.read({ ...params, workspacePath: launchDirectory })
}
if (worktree === undefined) {
return catalog.read(params)
}
const workspacePath = await ctx.runtime
.resolveStructuredAgentSessionLocalWorkspacePath(worktree)
.catch(() => null)
return catalog.read({ ...params, workspacePath })
}
const workspacePath = await ctx.runtime
.resolveStructuredAgentSessionLocalWorkspacePath(worktree)
.catch(() => null)
return catalog.read({ ...params, workspacePath })
const answer = await read()
// The chat's running agent can say what no listing does: a Pi that started with no model.
const started =
record?.provider === params.agent
? host.deps.adapter.startUnavailable?.(record.sessionId)
: undefined
return started && !answer.unavailable ? { ...answer, unavailable: started } : answer
}
})
]
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function NativeChatStructuredSession(
queueFollowUps,
hostStopping: hostExecution.stopping,
providerStarting: hostExecution.phase === 'starting',
providerRunning: hostExecution.phase !== null,
rewind: rewindHost,
transportEnabled: provisionalLaunch.transportEnabled,
...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {})
Expand Down Expand Up @@ -217,7 +218,9 @@ export function NativeChatStructuredSession(
agent: props.agent,
agentLabel,
launchFailure: provisionalLaunch.lifecycle === 'failed' ? provisionalLaunch.failure : null,
journalItems: controller.journalItems
journalItems: controller.journalItems,
submissions: controller.submissions,
deliveryNotices
})
const notices = structuredSessionNotices({
launch,
Expand Down
Loading
Loading