mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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>) =>
|
||||
|
||||
+20
@@ -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', () => {
|
||||
|
||||
+13
-4
@@ -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())
|
||||
) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
+29
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user