Show "Pi isn't signed in" in native chat before the first send (#26743)

* Show 'Pi isn't signed in' before the first send

A Pi chat whose agent started with no model to use now says so in the
existing availability notice, and the next send replaces that agent so
a sign-in made since takes effect.

* Re-read the sign-in notice when the chat's agent stops; ignore an exited Pi

A Pi's start verdict ends with that Pi: the notice now re-reads when the
host starts or stops the chat's agent, and a Pi whose process already
exited reports nothing.

* Keep one sign-in line after a refused send; re-read on every agent start or stop

The notice steps aside while the newest send was refused for the same
reason, and the chat re-reads its verdict whenever its agent starts or
stops, dropping an answer read before.

* Step the notice aside only while the refused send's own line is on screen

Uses the host's send order and the chat's rendered delivery lines, so a
deleted returned card or a trimmed row brings the sign-in notice back.

* Keep the sign-in notice when the refused message is no longer loaded
This commit is contained in:
Brennan Benson
2026-10-08 23:24:00 -07:00
committed by GitHub
parent f9d0aa2f67
commit 4f126bc01a
17 changed files with 336 additions and 34 deletions
@@ -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
@@ -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
@@ -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
@@ -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
})
@@ -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']>>
}
@@ -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>) =>
@@ -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'
@@ -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 () => ({
@@ -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', () => {
@@ -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'
@@ -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()
@@ -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())
) {
+26
View File
@@ -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
View File
@@ -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 {
@@ -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)
@@ -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,
@@ -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: {} }
})
)
})
@@ -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: {} }
})
)
})
@@ -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() } })
})
})
@@ -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.
@@ -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({
@@ -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 })
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 })
}
if (worktree === undefined) {
return catalog.read(params)
}
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
}
})
]
@@ -84,6 +84,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 } : {})
@@ -225,7 +226,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,
@@ -36,10 +36,11 @@ import {
* When the host says its first listing for the account is running, one more
* read waits for it — one per chat, joined by every later run and remount.
* Reports that wait, and why the host's latest answer says no chat can start
* (kept until the next answer replaces it; a failed read is unknown). Only
* while it says so, the window gaining focus or a turn starting or ending
* reads again: the host pushes no change, the fix (signing in, installing)
* happens elsewhere, and a started chat makes the host re-check.
* (kept until the next answer replaces it; a failed read is unknown). The
* chat's agent starting or stopping reads again; only while a reason is said,
* the window gaining focus or a turn starting or ending does too: the host
* pushes no change, the fix (signing in, installing) happens elsewhere, and a
* started chat makes the host re-check.
*/
export function useHostModelCatalogUpgrade(args: {
agent: AgentType
@@ -55,6 +56,8 @@ export function useHostModelCatalogUpgrade(args: {
fence: number | null
/** The chat's running turn: one running proves its start, which the host re-checks against. */
turnId?: string | null
/** The host runs the chat's agent: a reason its start gave ends with it. */
providerRunning?: boolean
activeOptionRecordRef: MutableRefObject<NativeChatSessionOptionRecord>
updateOptionState: (
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
@@ -85,6 +88,9 @@ export function useHostModelCatalogUpgrade(args: {
const recheck = useCallback(() => setRereads((count) => count + 1), [])
const said = unavailable !== null
const turnWhileSaid = said ? (args.turnId ?? null) : null
// A reason the agent's own start gave ends with that agent: its start or stop reads again,
// dropping an answer read before it.
const running = args.providerRunning === true
useEffect(() => {
if (!said) {
return
@@ -163,6 +169,7 @@ export function useHostModelCatalogUpgrade(args: {
namesDefault,
optionCatalog,
rereads,
running,
sessionId,
turnWhileSaid,
target,
@@ -41,11 +41,11 @@ const mutate = vi.fn(async () => null) as unknown as StructuredAgentSessionMutat
let sessionId = ''
let sessionCount = 0
const NO_TURN: { turnId: string | null } = { turnId: null }
const NO_TURN: { turnId: string | null; providerRunning?: boolean } = { turnId: null }
function renderOptions() {
return renderHook(
({ turnId }: { turnId: string | null }) =>
({ turnId, providerRunning }: { turnId: string | null; providerRunning?: boolean }) =>
useStructuredAgentSessionOptions({
agent: 'codex',
sessionId,
@@ -55,6 +55,7 @@ function renderOptions() {
providerVisible: false,
fence: null,
turnId,
...(providerRunning ? { providerRunning } : {}),
unloadedTurnRevisions: undefined,
mutate,
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
@@ -140,6 +141,32 @@ describe("a chat's sign-in verdict", () => {
expect(reads.count()).toBe(3)
})
// A verdict the agent's own start gave ends with that agent: the idle sweep stopping it is read.
it("reads again when the chat's agent stops or starts", async () => {
const reads = catalogReads()
const { result, rerender } = renderOptions()
await act(async () => rerender({ turnId: null, providerRunning: true }))
expect(reads.count()).toBe(2)
await reads.answer(1, { origin: 'unknown', unavailable: SIGNED_OUT })
expect(result.current.unavailable).toEqual(SIGNED_OUT)
await act(async () => rerender({ turnId: null, providerRunning: false }))
expect(reads.count()).toBe(3)
await reads.answer(2, { origin: 'unknown' })
expect(result.current.unavailable).toBeNull()
})
it('drops a signed-out answer read before the agent stopped', async () => {
const reads = catalogReads()
const { result, rerender } = renderOptions()
await act(async () => rerender({ turnId: null, providerRunning: true }))
await act(async () => rerender({ turnId: null, providerRunning: false }))
expect(reads.count()).toBe(3)
await reads.answer(1, { origin: 'unknown', unavailable: SIGNED_OUT })
expect(result.current.unavailable).toBeNull()
await reads.answer(2, { origin: 'unknown' })
expect(result.current.unavailable).toBeNull()
})
it('a turn starting with no verdict reads nothing more', async () => {
const reads = catalogReads()
const { rerender } = renderOptions()
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice'
import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import {
structuredAgentSessionDeliveryNotices,
structuredAgentSessionStartFailureFacts
@@ -81,3 +82,59 @@ it('keeps one auth explanation while an unechoed message still reads as unsent',
})
expect([...notices.values()].map((notice) => notice.text)).toEqual(['Your message was not sent.'])
})
// A signed-out Pi turns the send away before any turn: that send's own line says it.
it("steps aside while the newest send's own line says the notice's reason", () => {
const sent = (
id: string,
order: { at: number; sequence?: number },
rejected?: 'notSignedIn' | 'providerRejected'
) => ({
clientMessageId: id,
submittedAt: order.at,
...(order.sequence === undefined ? {} : { submittedSequence: order.sequence }),
dispatchState: rejected ? ('rejected' as const) : ('accepted' as const),
...(rejected ? { rejection: { kind: rejected } } : {})
})
const lines = (...ids: string[]) =>
new Map(ids.map((id) => [agentJournalSubmissionKey(id), { text: 'line' }]))
const rows = (...ids: string[]): AgentJournalRenderItem[] =>
ids.map((id, index) => ({
itemId: agentJournalSubmissionKey(id),
revision: 1,
sequence: index + 1,
observedAt: index + 1,
body: { kind: 'message', role: 'user', text: id, blocks: [] }
}))
const notice = (
submissions: ReturnType<typeof sent>[],
deliveryNotices = lines('a', 'b'),
journalItems = rows('a', 'b')
) =>
renderHook(() =>
useNativeChatAvailabilityNotice({
agent: 'pi',
agentLabel: 'Pi',
unavailable: { reason: 'notSignedIn' },
launchFailure: null,
journalItems,
submissions,
deliveryNotices
})
).result.current
expect(notice([sent('a', { at: 1 }, 'notSignedIn')])).toBeNull()
expect(notice([sent('a', { at: 1 }, 'providerRejected')])?.text).toContain('/login')
expect(notice([sent('a', { at: 1 }, 'notSignedIn'), sent('b', { at: 2 })])?.text).toContain(
'/login'
)
// Its line gone (a returned card deleted, the row trimmed): the notice is the only guidance.
expect(notice([sent('a', { at: 1 }, 'notSignedIn')], lines())?.text).toContain('/login')
expect(notice([sent('a', { at: 1 }, 'notSignedIn')], lines('a'), rows())?.text).toContain(
'/login'
)
// The host's send order decides, not a clock that moved back.
expect(
notice([sent('a', { at: 5, sequence: 1 }, 'notSignedIn'), sent('b', { at: 2, sequence: 2 })])
?.text
).toContain('/login')
})
@@ -1,8 +1,13 @@
import { useMemo, useState } from 'react'
import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability'
import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { AgentType } from '../../../../shared/agent-status-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import { inSendOrder } from '../../../../shared/native-chat-send-order'
import { agentSessionRefusalReasonWords } from '../../../../shared/agent-session-refusal-reason-words'
import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure'
import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key'
@@ -26,15 +31,40 @@ function failedStartReason(items: readonly AgentJournalRenderItem[] | undefined)
return reason
}
type NoticeSubmission = Pick<
AgentJournalSubmission,
'clientMessageId' | 'dispatchState' | 'rejection' | 'submittedAt' | 'submittedSequence'
>
/** Why the newest send was turned away, while that send's own line on screen says so: its row is
* loaded and carries the line. */
function shownRejectionReason(
submissions: readonly NoticeSubmission[],
shownLines: ReadonlyMap<string, unknown>,
items: readonly AgentJournalRenderItem[]
): string | null {
const newest = inSendOrder(submissions, (submission) => submission).at(-1)
const key = newest && agentJournalSubmissionKey(newest.clientMessageId)
return newest?.dispatchState === 'rejected' &&
key !== undefined &&
shownLines.has(key) &&
items.some((item) => item.itemId === key)
? (readAgentSessionFailureFact(newest.rejection)?.kind ?? null)
: null
}
/** The host's verdict on why no chat can start here, as a notice that never holds Send: the
* verdict can be wrong while a send would work. Dismissed per verdict, so a changed or returning
* one shows again; left out while the chat's failed start already says the same reason. */
* one shows again; left out while the chat's failed start or newest send already says it. */
export function useNativeChatAvailabilityNotice(input: {
unavailable: AgentSessionUnavailable | null | undefined
agent: AgentType
agentLabel: string
launchFailure: AgentSessionWriteRefusal | null
journalItems: readonly AgentJournalRenderItem[] | undefined
submissions?: readonly NoticeSubmission[]
/** The lines under the chat's messages, by message key: a refused send's says why. */
deliveryNotices?: ReadonlyMap<string, unknown>
}): NativeChatComposerNotice | null {
const { unavailable, journalItems } = input
const key = !unavailable
@@ -47,12 +77,25 @@ export function useNativeChatAvailabilityNotice(input: {
setDismissal({ key, dismissed: false })
}
const rowReason = useMemo(() => failedStartReason(journalItems), [journalItems])
const sendReason = useMemo(
() =>
shownRejectionReason(
input.submissions ?? [],
input.deliveryNotices ?? new Map(),
journalItems ?? []
),
[input.submissions, input.deliveryNotices, journalItems]
)
if (!unavailable || (dismissal.key === key && dismissal.dismissed)) {
return null
}
const launchWords = input.launchFailure && agentSessionRefusalReasonWords(input.launchFailure)
const launchReason = launchWords && 'fact' in launchWords ? launchWords.fact : null
if (launchReason === unavailable.reason || rowReason === unavailable.reason) {
if (
launchReason === unavailable.reason ||
rowReason === unavailable.reason ||
sendReason === unavailable.reason
) {
return null
}
return {
@@ -43,6 +43,7 @@ export function useStructuredAgentSessionOptions(args: {
isVisible: boolean
providerVisible: boolean
providerStarting?: boolean
providerRunning?: boolean
fence: number | null
turnId: string | null
unloadedTurnRevisions: number | undefined
@@ -101,6 +102,7 @@ export function useStructuredAgentSessionOptions(args: {
...(launch?.worktree ? { worktree: launch.worktree } : {}),
fence,
turnId,
...(args.providerRunning ? { providerRunning: true } : {}),
activeOptionRecordRef,
updateOptionState
})
@@ -51,6 +51,8 @@ export function useStructuredAgentSession(args: {
transportEnabled?: boolean
/** The host has published the session but its provider has not answered startup yet. */
providerStarting?: boolean
/** The host runs the session's provider, started or not. */
providerRunning?: boolean
/** This view started the session; only then does the stored selection name what it runs. */
launch?: StructuredAgentSessionLaunchView
/** The composer Edit copies a card's text into. */
@@ -108,6 +110,7 @@ export function useStructuredAgentSession(args: {
isVisible,
providerVisible,
providerStarting,
...(args.providerRunning ? { providerRunning: true } : {}),
fence: state.fence,
turnId: transportState.turnId,
unloadedTurnRevisions: state.unloadedTurnRevisions,