diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json b/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json index c658d151a50a..5b4365add7c3 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json @@ -57,14 +57,6 @@ "sessionTabs": [], "terminals": [] }, - "776182f604c7": { - "name": "toast", - "ordinal": 4, - "value": { - "durationMs": 2400, - "message": "The agent started, but the notes weren't sent." - } - }, "8f06d46b0825": { "name": "agent.launchReplay#1", "ordinal": 1, @@ -129,6 +121,14 @@ "status": "pending", "startedAt": 0 }, + "ac15d5ff2168": { + "name": "toast", + "ordinal": 4, + "value": { + "durationMs": 2400, + "message": "The notes weren't sent to the agent." + } + }, "ad660413fab0": { "name": "agent.launchReplay#1", "ordinal": 1, @@ -207,7 +207,7 @@ "create": "eb79a9b3682a" }, "state": "73449f52ee06", - "effects": ["6248a3dd0710", "776182f604c7"] + "effects": ["6248a3dd0710", "ac15d5ff2168"] } }, { @@ -220,7 +220,7 @@ "create": "eb79a9b3682a" }, "state": "73449f52ee06", - "effects": ["6248a3dd0710", "776182f604c7"] + "effects": ["6248a3dd0710", "ac15d5ff2168"] } } ] diff --git a/mobile/src/components/AgentLaunchNotice.test.tsx b/mobile/src/components/AgentLaunchNotice.test.tsx index e1b7b07efce3..ca5e90c44b8c 100644 --- a/mobile/src/components/AgentLaunchNotice.test.tsx +++ b/mobile/src/components/AgentLaunchNotice.test.tsx @@ -63,10 +63,8 @@ describe('AgentLaunchNotice', () => { }) it('keeps a real failure in the error style beside a warning', () => { - expect( - lines({ error: "The agent started, but the prompt wasn't sent.", warning: 'w' }) - ).toEqual([ - { text: "The agent started, but the prompt wasn't sent.", isError: true }, + expect(lines({ error: "The prompt wasn't sent to the agent.", warning: 'w' })).toEqual([ + { text: "The prompt wasn't sent to the agent.", isError: true }, { text: 'w', isError: false } ]) }) @@ -78,14 +76,16 @@ describe('AgentLaunchNotice', () => { }) it('confirms a launch that went ahead, with any host note beneath it', () => { - expect(lines({ success: 'Agent started in feature-login', warning: 'w' })).toEqual([ - { text: 'Agent started in feature-login', isError: false }, + expect(lines({ success: 'Sent to an agent in feature-login', warning: 'w' })).toEqual([ + { text: 'Sent to an agent in feature-login', isError: false }, { text: 'w', isError: false } ]) }) it('never confirms beside an error', () => { - expect(lines({ success: 'Agent started', error: 'e' })).toEqual([{ text: 'e', isError: true }]) + expect(lines({ success: 'Sent to an agent', error: 'e' })).toEqual([ + { text: 'e', isError: true } + ]) }) it('renders nothing with nothing to say', () => { diff --git a/mobile/src/components/AgentLaunchNotice.tsx b/mobile/src/components/AgentLaunchNotice.tsx index 00d309477efa..9c9f0d4cce63 100644 --- a/mobile/src/components/AgentLaunchNotice.tsx +++ b/mobile/src/components/AgentLaunchNotice.tsx @@ -10,7 +10,7 @@ import { colors, spacing, typography } from '../theme/mobile-theme' type Props = { availability: MobileAgentLaunchAvailability - /** The confirmation that the agent started with its prompt. */ + /** The confirmation that the prompt was sent to an agent. */ success: string | null error: string | null /** The host's note on a launch that went ahead; secondary text, not an error. */ diff --git a/mobile/src/session/new-tab-agent-host-launch.ts b/mobile/src/session/new-tab-agent-host-launch.ts index 71326c48f78e..018d5270acae 100644 --- a/mobile/src/session/new-tab-agent-host-launch.ts +++ b/mobile/src/session/new-tab-agent-host-launch.ts @@ -16,11 +16,9 @@ import { type PendingSessionSelection } from './pending-session-selection' -const NOTES_NOT_SENT_MESSAGE = "The agent started, but the notes weren't sent." -export const PROMPT_UNCONFIRMED_MESSAGE = - "The agent started, but couldn't confirm the prompt was sent." -export const NOTES_UNCONFIRMED_MESSAGE = - "The agent started, but couldn't confirm the notes were sent." +const NOTES_NOT_SENT_MESSAGE = "The notes weren't sent to the agent." +export const PROMPT_UNCONFIRMED_MESSAGE = "Couldn't confirm the prompt was sent to the agent." +export const NOTES_UNCONFIRMED_MESSAGE = "Couldn't confirm the notes were sent to the agent." export type NewTabAgentLaunchOptions = MobileQuickCommandLaunch['options'] & { onPromptSent?: () => void @@ -80,7 +78,7 @@ export async function launchNewTabAgentThroughHost(args: { return true } if (launched.kind === 'unknown') { - // Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent. + // Why: a listed tab proves the launch went through, so only the prompt is in doubt; notes stay unsent. if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) { if (prompt?.trim()) { triggerError() diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index 16c8132836f3..a1f93610db1b 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -4,7 +4,7 @@ import type { RpcResponse } from '../transport/types' import { AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE } from './mobile-existing-agent-launch' import { AGENT_PROMPT_NOT_SENT_MESSAGE, - agentStartedMessage, + agentPromptSentMessage, launchAgentWithPrompt, promptedLaunchNotice } from './pr-ai-triage-launch' @@ -265,7 +265,7 @@ describe('promptedLaunchNotice', () => { }) expect(promptedLaunchNotice({ kind: 'sent' })).toEqual({ succeeded: true, - success: 'Agent started', + success: 'Sent to an agent', error: null, warning: null, undeliveredPrompt: null @@ -283,7 +283,7 @@ describe('promptedLaunchNotice', () => { it('never reports the host warning on a launch that went ahead as an error', () => { expect(promptedLaunchNotice({ kind: 'sent', warning: 'arguments were ignored' })).toEqual({ succeeded: true, - success: 'Agent started', + success: 'Sent to an agent', error: null, warning: 'arguments were ignored', undeliveredPrompt: null @@ -304,16 +304,16 @@ describe('promptedLaunchNotice', () => { }) }) -describe('agentStartedMessage', () => { +describe('agentPromptSentMessage', () => { // Issue #20543: a launch from the phone must say it happened, and where. - it('names the workspace the agent started in', () => { + it('names the workspace the prompt went to', () => { expect(promptedLaunchNotice({ kind: 'sent' }, 'feature-login').success).toBe( - 'Agent started in feature-login' + 'Sent to an agent in feature-login' ) }) it('still confirms the launch when the screen knows no workspace name', () => { - expect(agentStartedMessage(null)).toBe('Agent started') - expect(agentStartedMessage(' ')).toBe('Agent started') + expect(agentPromptSentMessage(null)).toBe('Sent to an agent') + expect(agentPromptSentMessage(' ')).toBe('Sent to an agent') }) }) diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 61c160b224ec..cd5d3fac3526 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -15,14 +15,15 @@ import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-l export type MobilePromptedAgentLaunch = | { kind: 'sent'; warning?: string } - /** The agent started but `prompt`, the text as sent, did not reach it; the caller offers it. */ + /** The launch went through but `prompt`, the text as sent, did not reach the agent; the caller + * offers it. */ | { kind: 'prompt-not-sent'; prompt: string; warning?: string } /** Nothing started; `message` says why. */ | { kind: 'not-started'; message: string } /** The agent may be running; do not launch again until the user has looked. */ | { kind: 'unconfirmed'; message: string } -export const AGENT_PROMPT_NOT_SENT_MESSAGE = "The agent started, but the prompt wasn't sent." +export const AGENT_PROMPT_NOT_SENT_MESSAGE = "The prompt wasn't sent to the agent." export async function launchAgentWithPrompt(args: { client: RpcClient @@ -91,10 +92,11 @@ export async function launchAgentWithPrompt(args: { } } -/** The confirmation under an AI button, naming the workspace when the screen knows it. */ -export function agentStartedMessage(workspaceLabel: string | null | undefined): string { +/** The confirmation under an AI button, naming the workspace when the screen knows it. Said once + * the host has the prompt: the agent may still be starting, and a failed start shows in its chat. */ +export function agentPromptSentMessage(workspaceLabel: string | null | undefined): string { const label = workspaceLabel?.trim() - return label ? `Agent started in ${label}` : 'Agent started' + return label ? `Sent to an agent in ${label}` : 'Sent to an agent' } /** What the button shows after a launch; one mapping so every AI button reads the same. @@ -113,7 +115,7 @@ export function promptedLaunchNotice( case 'sent': return { succeeded: true, - success: agentStartedMessage(workspaceLabel), + success: agentPromptSentMessage(workspaceLabel), error: null, warning: result.warning ?? null, undeliveredPrompt: null diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index 125e2e583504..c729bacd032b 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -353,7 +353,7 @@ describe('useMobileDiffReviewSendActions', () => { }) expect(saveCommentsAndReviewState).not.toHaveBeenCalled() expect(setActionError).toHaveBeenLastCalledWith( - "The agent started, but the notes weren't sent. Use Copy Notes to paste them." + "The notes weren't sent to the agent. Use Copy Notes to paste them." ) }) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index bea344cd3874..df23c584a502 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -156,9 +156,7 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { } if (result.kind === 'prompt-not-sent') { // Notes stay unsent so Copy Notes and a later send still carry them. - setActionError( - "The agent started, but the notes weren't sent. Use Copy Notes to paste them." - ) + setActionError("The notes weren't sent to the agent. Use Copy Notes to paste them.") return } await markNotesSent(comments) diff --git a/mobile/src/session/use-mobile-pr-ai-triage.test.ts b/mobile/src/session/use-mobile-pr-ai-triage.test.ts index d3e284fe882f..73c72371ccba 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.test.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.test.ts @@ -83,7 +83,7 @@ describe('useMobilePrAiTriage', () => { await triage?.launch('fix-checks', () => 'fix the failing checks') }) expect(triage?.noticeFor('fix-checks')).toMatchObject({ - error: "The agent started, but the prompt wasn't sent." + error: "The prompt wasn't sent to the agent." }) expect(triage?.noticeFor('fix-checks').undeliveredPrompt).toContain('fix the failing checks') expect(triage?.noticeFor('fix-checks').success).toBeNull() @@ -101,7 +101,7 @@ describe('useMobilePrAiTriage', () => { await triage?.launch('resolve-conflicts', () => 'resolve the conflicts') }) expect(triage?.noticeFor('resolve-conflicts')).toEqual({ - success: 'Agent started in feature-login', + success: 'Sent to an agent in feature-login', error: null, warning: null, undeliveredPrompt: null diff --git a/mobile/src/session/use-mobile-pr-ai-triage.ts b/mobile/src/session/use-mobile-pr-ai-triage.ts index 70215d9bbf02..f4893ee5d4cb 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.ts @@ -24,7 +24,7 @@ export type PrAiTriageLaunchNotice = { success: string | null error: string | null warning: string | null - /** The agent started without its prompt; kept so the user can paste it in themselves. */ + /** The launch went through without its prompt; kept so the user can paste it in themselves. */ undeliveredPrompt: string | null } diff --git a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts index 129bf1edf9b9..39035729e679 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts @@ -517,10 +517,7 @@ describe('launches that carry a prompt', () => { await create_(state, 'codex', { initialPrompt: 'the notes', onPromptSent }) expect(onPromptSent).not.toHaveBeenCalled() - expect(state.showToast).toHaveBeenCalledWith( - "The agent started, but the notes weren't sent.", - 2400 - ) + expect(state.showToast).toHaveBeenCalledWith("The notes weren't sent to the agent.", 2400) }) it('says a quick command prompt was not sent', async () => { diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts index 157a87b7448d..9fca23d4fabd 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts @@ -94,7 +94,7 @@ describe('useMobileCommitFailureRecovery', () => { expect(succeeded).toBe(true) expect(recovery?.launchError).toBeNull() expect(recovery?.launchWarning).toBe('the requested arguments were ignored.') - expect(recovery?.launchSuccess).toBe('Agent started in feature-login') + expect(recovery?.launchSuccess).toBe('Sent to an agent in feature-login') // A new failure is a new launch; the old one's note does not carry over. rerender({ ...FAILURE, error: 'another hook failed' }) expect(recovery?.launchWarning).toBeNull() diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts index fb19f7495ee2..57aa73b3e100 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts @@ -35,7 +35,7 @@ export function useMobileCommitFailureRecovery({ // would start a second agent. const inFlightRef = useRef(false) const [launchError, setLaunchError] = useState(null) - // The agent started without its prompt; kept so the user can paste it in themselves. Keyed by the + // The launch went through without its prompt; kept so the user can paste it in themselves. Keyed by the // failure it was built for, so a new failure never shows the previous one's prompt. const [undelivered, setUndelivered] = useState<{ failure: MobileCommitFailureRecovery diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index dcbd00776fda..eedf003d7f5c 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SWEEP_MS = 5 @@ -96,7 +97,9 @@ afterEach(async () => { describe('a Claude retrying a refused request', () => { it('keeps the conversation open past the idle window while retry frames arrive', async () => { - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) if (!sink) { diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 0228b1171683..313c8264196a 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -282,6 +282,32 @@ describe('Claude effort default at rest', () => { expect(pickerEffort(resting)).toBe('high') }) + // Every new chat sits at rest until its first message. Settings or env may run another model + // than the listing's default, so the picker names none (and offers no effort) until one runs. + it('names no model for a new chat at rest even with a listing, and the picker shows it unknown', async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + const resting = await readAtRest(store, restingRecord({})) + + expect(resting.current).toEqual({ model: '' }) + const seed = getAgentSessionOptionCatalog('claude')! + const snapshot = structuredAgentSessionOptionSnapshot( + applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + resting + ) + ) + const model = snapshot.find((row) => row.id === 'model') + expect(model).toMatchObject({ valueSource: 'unknown' }) + expect(model?.kind.type === 'select' ? model.kind.currentValue : 'named').toBeUndefined() + expect( + model?.kind.type === 'select' ? model.kind.choices.map((c) => c.value) : [] + ).not.toContain('') + expect(snapshot.find((row) => row.id === 'effort')).toBeUndefined() + }) + it('names no model at rest with no pick and no catalog, so the client keeps what the child ran', async () => { // The CLI's default model, reported live; the chat never picked one. const child = await startChild(new AgentModelCatalogStore(), undefined, [], { diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 2c319c86ee72..8ca595612977 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -88,7 +88,8 @@ describe('Claude effort reporting', () => { const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.effort).toBeUndefined() - expect(options.current.model).toBeTruthy() + // Neither the agent's report nor its own listing named a model, so none is named. + expect(options.current.model).toBe('') }) it('keeps the init fixture free of an effort the real frame never sends', async () => { diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index e5b7a7ea8a77..64265f556a7f 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -480,3 +480,26 @@ describe('Claude structured option restore under the request deadline', () => { ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) }) }) + +describe("a Claude chat's first start, before the agent names its model", () => { + function startingSession(): ClaudeSession { + return { ...sessionFor(vi.fn()), startup: createClaudeSessionStartup() } + } + + it("names no model, not its listing's default, and still lists the models", async () => { + const result = await readClaudeStructuredSessionOptions(startingSession(), undefined) + + expect(result.current.model).toBe('') + expect(result.models.length).toBeGreaterThan(0) + expect(result.models.some((model) => model.isDefault)).toBe(true) + }) + + it('names the pick a start replays', async () => { + const session = startingSession() + session.options.set('model', 'opus') + + const result = await readClaudeStructuredSessionOptions(session, undefined) + + expect(result.current.model).toBe('opus') + }) +}) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 082f45726fe5..4ddaa5e8b639 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -335,7 +335,10 @@ export function claudeStructuredSessionOptionsFrom( writeClaudeCatalogThrough(session, discovered) const listed = discovered.length > 0 ? discovered : seedModels() const current = readClaudeCurrentModel(session) - const model = currentModelId(listed, current.id) + // The static seed's default is not this agent's: Claude's settings or env may run another, so + // until a pick, the agent's report or its own listing names one (a start still under way), none. + const model = + current.id === undefined && discovered.length === 0 ? '' : currentModelId(listed, current.id) const models = structuredAgentSessionOptionModels(listed, model, (row) => ({ ...row, resolvedModel: null diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 0e3e8385685e..4fa75417635f 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const SWEEP_MS = 5 @@ -94,7 +95,9 @@ afterEach(async () => { describe('a Codex reconnecting a dropped stream', () => { it('keeps the conversation open past the idle window while retry frames arrive', async () => { - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) if (!sink) { diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 8938fbfd4e20..7a39a3beede5 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -36,6 +36,7 @@ import { structuredQuestionTranscript } from '../../renderer/src/components/native-chat/structured-agent-question-projection' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -103,7 +104,7 @@ beforeEach(async () => { // Every write lands on its own millisecond, as it does live. now: () => (clock += 1) }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) if (!page.ok) { throw new Error('no history page') diff --git a/src/main/github/client/fetch/review-reply-posts.test.ts b/src/main/github/client/fetch/review-reply-posts.test.ts new file mode 100644 index 000000000000..19316ab1a57e --- /dev/null +++ b/src/main/github/client/fetch/review-reply-posts.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + gh: vi.fn<(args: string[]) => Promise<{ stdout: string; stderr: string }>>() +})) + +vi.mock('../../gh-utils', () => ({ + ghExecFileAsync: mocks.gh, + acquire: async () => {}, + release: () => {} +})) +vi.mock('../../github-api-repository', () => ({ + resolveGitHubRepoExecution: async () => ({ + ownerRepo: { owner: 'acme', repo: 'app' }, + ghOptions: {} + }) +})) + +import { getReviewReplyPosts } from './review-reply-posts' + +const SINCE = '2026-10-03T10:00:00.000Z' + +function answer(graphql: unknown, rest: unknown = []) { + mocks.gh.mockImplementation(async (args) => ({ + stdout: JSON.stringify(args[1] === 'graphql' ? graphql : rest), + stderr: '' + })) +} + +function read(threadIds: string[] = ['PRRT_a', 'PRRT_b']) { + return getReviewReplyPosts('/repos/app', { prNumber: 42, threadIds, since: SINCE }) +} + +describe('what a review reply may already have posted', () => { + beforeEach(() => { + mocks.gh.mockReset() + }) + + it("reads each reply's thread newest first, the conversation since, and the account", async () => { + answer( + { + data: { + viewer: { login: 'me' }, + t0: { + comments: { nodes: [{ author: { login: 'me' }, body: 'Fixing', createdAt: SINCE }] } + }, + t1: { comments: { nodes: [] } } + } + }, + [{ user: { login: 'me' }, body: 'Fixing all', created_at: SINCE }] + ) + + await expect(read()).resolves.toEqual({ + viewerLogin: 'me', + threads: { + PRRT_a: [{ author: 'me', body: 'Fixing', createdAt: SINCE }], + PRRT_b: [] + }, + conversation: [{ author: 'me', body: 'Fixing all', createdAt: SINCE }] + }) + const [graphqlArgs, restArgs] = mocks.gh.mock.calls.map(([args]) => args) + expect(graphqlArgs?.join(' ')).toContain('t0: node(id: "PRRT_a")') + expect(graphqlArgs?.join(' ')).toContain('comments(last: 100)') + expect(restArgs?.[1]).toBe( + `repos/acme/app/issues/42/comments?since=${encodeURIComponent(SINCE)}&per_page=100` + ) + }) + + it('never puts something that is not a node id into the query', async () => { + answer({ data: { viewer: { login: 'me' } } }) + + await expect(read(['"){ evil }'])).resolves.toMatchObject({ threads: {} }) + expect(mocks.gh.mock.calls[0]?.[0].join(' ')).not.toContain('evil') + }) + + it('throws when GitHub refuses either read, instead of reading as nothing posted', async () => { + answer({ + data: { viewer: { login: 'me' } }, + errors: [{ message: 'Could not resolve to a node' }] + }) + await expect(read()).rejects.toThrow('GitHub refused the thread read') + + mocks.gh.mockRejectedValue(new Error('HTTP 502')) + await expect(read()).rejects.toThrow('HTTP 502') + + answer({ data: { viewer: { login: 'me' } } }, { message: 'Not Found' }) + await expect(read()).rejects.toThrow('conversation read') + }) +}) diff --git a/src/main/github/client/fetch/review-reply-posts.ts b/src/main/github/client/fetch/review-reply-posts.ts new file mode 100644 index 000000000000..2e8c6b34e324 --- /dev/null +++ b/src/main/github/client/fetch/review-reply-posts.ts @@ -0,0 +1,117 @@ +// What a review reply may already have posted on a PR: the newest comments on each reply's thread, +// the conversation comments since a moment, and who the repo's gh account is. Unlike the panel's +// comment read it throws on any failure, so a caller can tell "nothing posted" from "not read". + +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' + +export type ReviewReplyPost = { author: string; body: string; createdAt: string } + +export type ReviewReplyPosts = { + /** The account this repo's gh calls run as; null when GitHub did not say. */ + viewerLogin: string | null + /** The newest 100 comments on each asked thread, by thread id. */ + threads: Record + /** PR conversation comments updated since `since`, up to 100. */ + conversation: ReviewReplyPost[] +} + +// GitHub node ids are base64url-like; anything else is not one, and is never put into the query. +const NODE_ID = /^[A-Za-z0-9_=+/-]{1,512}$/ + +function threadsQuery(threadIds: readonly string[]): string { + const threads = threadIds.map( + (id, index) => + `t${index}: node(id: ${JSON.stringify(id)}) { ... on PullRequestReviewThread { comments(last: 100) { nodes { author { login } body createdAt } } } }` + ) + return `query { viewer { login } ${threads.join(' ')} }` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** One comment as either API returns it; null for anything that is not one. */ +function readPost( + value: unknown, + authorKey: 'author' | 'user', + createdKey: string +): ReviewReplyPost | null { + if (!isRecord(value) || typeof value[createdKey] !== 'string') { + return null + } + const author = value[authorKey] + return { + author: isRecord(author) && typeof author.login === 'string' ? author.login : 'ghost', + body: typeof value.body === 'string' ? value.body : '', + createdAt: String(value[createdKey]) + } +} + +function readPosts(values: unknown, authorKey: 'author' | 'user', createdKey: string) { + return (Array.isArray(values) ? values : []).flatMap((value) => { + const read = readPost(value, authorKey, createdKey) + return read ? [read] : [] + }) +} + +export async function getReviewReplyPosts( + repoPath: string, + args: { + prNumber: number + prRepo?: GitHubApiRepository | null + threadIds: readonly string[] + /** ISO time; the conversation read starts here. */ + since: string + }, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + args.prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + throw new Error('Not a GitHub repository.') + } + const threadIds = args.threadIds.filter((id) => NODE_ID.test(id)) + await acquire() + try { + const [graphql, rest] = await Promise.all([ + ghExecFileAsync(['api', 'graphql', '-f', `query=${threadsQuery(threadIds)}`], ghOptions), + ghExecFileAsync( + [ + 'api', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${args.prNumber}/comments?since=${encodeURIComponent(args.since)}&per_page=100` + ], + ghOptions + ) + ]) + const parsed: unknown = JSON.parse(graphql.stdout) + const data = isRecord(parsed) ? parsed.data : undefined + const errors = isRecord(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [] + if (!isRecord(data) || errors.length > 0) { + throw new Error(`GitHub refused the thread read: ${JSON.stringify(errors)}`) + } + const threads: Record = {} + threadIds.forEach((id, index) => { + const node = data[`t${index}`] + const comments = isRecord(node) && isRecord(node.comments) ? node.comments.nodes : [] + threads[id] = readPosts(comments, 'author', 'createdAt') + }) + const conversation: unknown = JSON.parse(rest.stdout) + if (!Array.isArray(conversation)) { + throw new Error('GitHub answered the conversation read with something else.') + } + const viewer = data.viewer + return { + viewerLogin: isRecord(viewer) && typeof viewer.login === 'string' ? viewer.login : null, + threads, + conversation: readPosts(conversation, 'user', 'created_at') + } + } finally { + release() + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..42aae15de38b 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -10,6 +10,7 @@ // likewise the creating write's: a revision naming no producer keeps it, one // naming any replaces it. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalAcceptanceReceipt, AgentJournalRenderItem, @@ -52,6 +53,8 @@ export type JournalReducerState = { /** Revision of a removed item, so a late lower revision cannot resurrect it. */ tombstones: Map submissions: Map + /** Host-only: what Orca does on the review once the agent takes a message, by message id. */ + reviewReplies: Map receipts: Map /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ @@ -78,6 +81,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou itemFences: new Map(), tombstones: new Map(), submissions: new Map(), + reviewReplies: new Map(), receipts: new Map(), aliases: new Map(), appliedSettlementIds: new Set(), diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 50c1edb4111f..f1d30d60b249 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -1,3 +1,4 @@ +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -72,6 +73,7 @@ export function journalSubmissionRowBuilder( handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + reviewReply?: AgentSessionReviewReply }, /** Present when the append hands off a queued draft: the row names that draft, stamped here * from the consume itself so no hand-off path can leave the link off. */ @@ -317,6 +319,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + reviewReply?: AgentSessionReviewReply }): JournalSubmissionRow { return { kind: 'submission', @@ -327,6 +330,7 @@ export function buildJournalSubmissionRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), - ...(input.origin !== undefined ? { origin: input.origin } : {}) + ...(input.origin !== undefined ? { origin: input.origin } : {}), + ...(input.reviewReply ? { reviewReply: input.reviewReply } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 07ec68cedd9e..45af6469ec7e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -30,6 +30,7 @@ import { } from '../../../shared/agent-session-journal-schemas' import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' /** Producer linkage rides the row BASE rather than the body: the two nested * prompt shapes are `.strict()`, so an unknown key on a body would make the @@ -135,6 +136,9 @@ export type JournalSubmissionRow = JournalRowBase & { * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it * was recorded. Older readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin + /** What Orca does on the review once the agent takes this message; host-only, read through + * `readAgentSessionReviewReply` as it folds. Older readers keep the key and ignore it. */ + reviewReply?: AgentSessionReviewReply } export type JournalSubmissionOrigin = 'client' | 'host' diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 36bd9549383a..2e1b378a2a0f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -1,4 +1,5 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalCursor, AgentJournalItemBody, @@ -91,6 +92,8 @@ export type JournalSubmissionInput = { queuedMessageId?: string /** Who asked for this turn (`JournalSubmissionRow.origin`). */ origin?: 'client' | 'host' + /** `JournalSubmissionRow.reviewReply`. */ + reviewReply?: AgentSessionReviewReply } /** A submission append that converts a queued draft, in one transaction. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index af9dbaf32adb..0ad07483a2f5 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,6 +1,7 @@ // Append-only journal store for one agent session. It owns the chat's fold and write queue, and no // connection: every statement goes through the host's one journal database. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -265,6 +266,16 @@ export class AgentSessionJournal { submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId) + /** Host-only: each message's review reply (`JournalSubmissionRow.reviewReply`). */ + reviewReplies = (): ReadonlyMap => this.state.reviewReplies + + /** The clock its rows are stamped by. */ + clock = (): number => this.now() + + /** Whether an item was ever written under this id, removed since or not. */ + itemWritten = (itemId: string): boolean => + this.state.items.has(itemId) || this.state.tombstones.has(itemId) + pendingSubmissions = (): AgentJournalSubmission[] => this.submissions().filter((entry) => entry.dispatchState === 'pending') diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 3f0f3cda689e..712baa95b01e 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -7,6 +7,7 @@ import { } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalRenderItem } from './journal-render-item' +import { readAgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' @@ -32,6 +33,11 @@ export function applyJournalSubmission( : {}), ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) }) + // A malformed stored spec is dropped, never the row; it then runs nothing. + const reviewReply = readAgentSessionReviewReply(row.reviewReply) + if (reviewReply) { + state.reviewReplies.set(row.clientMessageId, reviewReply) + } const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. const turnScope = row.handoverRecorded diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 7a533e5cd8af..db9b5156521e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -43,6 +43,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -112,7 +113,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) await startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index c98854292d5c..8c8ee639970d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -6,7 +6,10 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' @@ -37,7 +40,7 @@ afterEach(async () => { function attachParams( operationId: string, - expectedRuntimeFence: number | null, + expectedRuntimeFence: number, options?: Readonly> ): AgentSessionAttachParams { const params: AgentSessionAttachParams = { @@ -107,6 +110,25 @@ function adapter(input: { } } +/** The store with the record a create founds at rest (fence 1), which the first start reserves. */ +async function foundedStore( + directory: string, + options?: Readonly> +): Promise { + const store = await openTestAgentSessionRecordStore(directory) + const { location, provider, accountHome } = attachParams(CREATE_OPERATION, 1) + await foundTestAgentSessionRecord(store, { + sessionId: SESSION, + location, + provider, + accountHome, + ...(options ? { options } : {}), + claimKeyId: 'key-1', + now: NOW + }) + return store +} + function expectSettledAttachLease(record: AgentSessionRecord | null): void { expect(record).not.toBeNull() const lease = record!.lease @@ -118,7 +140,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { describe('structured session acquisition options', () => { it('samples provider history before acquiring a replacement child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(root) + const initialStore = await foundedStore(root) let childAcquired = false const historyWindow = (): ProviderHistoryWindow => ({ items: [], @@ -163,7 +185,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), + params: attachParams(CREATE_OPERATION, 1), now: () => NOW, onAttached: (attached) => { firstJournal = attached.journal @@ -178,7 +200,7 @@ describe('structured session acquisition options', () => { role: 'user', blocks: [{ type: 'text', text: 'deploy the thing' }] } satisfies AgentJournalMessageItem, - fence: 1 + fence: 2 }) await firstJournal!.close() const store = await openTestAgentSessionRecordStore(root) @@ -216,9 +238,9 @@ describe('structured session acquisition options', () => { it('persists create defaults before the first provider acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } + const store = await foundedStore(root, options) + const sessionAdapter = adapter({ origin: 'created' }) const recordPhase = vi.fn() const created = await performAttach({ @@ -233,7 +255,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), + params: attachParams(CREATE_OPERATION, 1, options), now: () => NOW, recordPhase, onAttached: () => {} @@ -246,9 +268,9 @@ describe('structured session acquisition options', () => { expect(store.getRecord(SESSION)?.options).toEqual(options) }) - it('replays a create retried after the host re-resolved different options', async () => { + it('replays a start retried after the host re-resolved different options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root, { model: 'gpt-5.6-sol', effort: 'medium' }) const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ @@ -263,13 +285,13 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), + params: attachParams(CREATE_OPERATION, 1, options), now: () => NOW, onAttached: () => {} }) const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a') - // Why: the user may reselect a model between an unknown-outcome create and the + // Why: the user may reselect a model between an unknown-outcome attempt and the // retry that reuses its operation id; the retry must replay, not conflict. const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b') @@ -280,7 +302,7 @@ describe('structured session acquisition options', () => { it('persists provider options before proving a resumed legacy record', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const created = await performAttach({ logger: createStructuredAgentSessionLogger(), @@ -294,7 +316,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), + params: attachParams(CREATE_OPERATION, 1), now: () => NOW, onAttached: () => {} }) @@ -349,7 +371,7 @@ describe('structured session acquisition options', () => { it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root, { model: 'gpt-standard', fastMode: 'true' }) const sessionAdapter = adapter({ origin: 'created', options: { current: { model: 'gpt-standard' }, models: [] }, @@ -368,7 +390,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { + params: attachParams(CREATE_OPERATION, 1, { model: 'gpt-standard', fastMode: 'true' }), @@ -382,7 +404,7 @@ describe('structured session acquisition options', () => { it('releases an acquisition when provider options cannot be read', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const releaseAcquisition = vi.fn(async () => true) const failingAdapter: StructuredAgentSessionAdapter = { ...adapter({ origin: 'created' }), @@ -405,7 +427,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), + params: attachParams(CREATE_OPERATION, 1), now: () => NOW, onAttached: () => {} }) @@ -434,7 +456,7 @@ describe('structured session acquisition options', () => { ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { const exitProven = cleanup === true root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const base = adapter({ origin: 'created', options: { current: { model: 'gpt-5.6-terra' }, models: [] } @@ -482,11 +504,7 @@ describe('structured session acquisition options', () => { ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) } : {}) } - const perform = ( - target: AgentSessionRecordStore, - operationId: string, - fence: number | null - ) => + const perform = (target: AgentSessionRecordStore, operationId: string, fence: number) => performAttach({ logger: createStructuredAgentSessionLogger(), store: target, @@ -509,7 +527,7 @@ describe('structured session acquisition options', () => { // A proven exit before the journal opens is answered once, as the refusal its replay gives; // no exit was observed, so it names no situation. - const failed = perform(store, CREATE_OPERATION, null) + const failed = perform(store, CREATE_OPERATION, 1) await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, @@ -541,26 +559,26 @@ describe('structured session acquisition options', () => { if (exitProven) { expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', handoffStage: null, handoffOperationId: null, ownerProcess: null, reservedSpawnToken: null }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) + await expect(perform(reopened, RESUME_OPERATION, 3)).resolves.toMatchObject({ ok: true }) expectSettledAttachLease(reopened.getRecord(SESSION)) } else if (failurePoint === 'proof' || failurePoint === 'journal') { // A recorded owner goes to recovery, which concludes about it before the next start. expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 1, + runtimeFence: 2, claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', handoffStage: 'recovering', // The settled operation must not stay named by the lease as an in-flight transfer. handoffOperationId: null, reservedSpawnToken: 'spawn-a' }) - await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ + await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) @@ -568,7 +586,7 @@ describe('structured session acquisition options', () => { // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, // with no death evidence, since nothing proved one. expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', handoffStage: null, handoffOperationId: null, @@ -576,54 +594,8 @@ describe('structured session acquisition options', () => { reservedSpawnToken: null, deathEvidence: null }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) + await expect(perform(reopened, RESUME_OPERATION, 3)).resolves.toMatchObject({ ok: true }) } }) }) }) - -describe('the tab a create reserves', () => { - async function openStore() { - root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(root) - } - - function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { - return performAttach({ - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which - // conversation this attaches to. - params: { - ...attachParams(CREATE_OPERATION, null), - ...(surfaceTabId ? { surfaceTabId } : {}) - }, - now: () => NOW, - onAttached: () => {} - }) - } - - it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { - const store = await openStore() - const created = await attachWith(store, 'chat-tab-1') - // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. - expect(created.ok && created.value.tabId).toBeUndefined() - expect(store.getSessionTabId(SESSION)).toBeNull() - - await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') - expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ - ok: true, - value: { tabId: 'chat-tab-1' } - }) - expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 76a276abd230..04c15a06c830 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,5 +1,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Source validation must finish before a new session claims the provider conversation. +// Adoption happens at the at-rest create: the source is validated before a record claims the provider +// conversation, and no agent starts. import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -13,8 +14,6 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -27,8 +26,11 @@ const THREAD = 'adopted-thread' const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` let root: string | null = null let store: AgentSessionRecordStore | null = null +let host: StructuredAgentSessionHost | null = null afterEach(async () => { + await host?.flushAllStreamedEvents() + host = null if (root) { await rm(root, { recursive: true, force: true }) } @@ -107,7 +109,6 @@ function adapter(): StructuredAgentSessionAdapter { observedAt: NOW } })), - // Proven released, so the failure rethrows its own cause rather than an unproven-exit wrapper. releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -116,28 +117,26 @@ function adapter(): StructuredAgentSessionAdapter { } } -async function attach( - transcriptPath: string | undefined, - sessionAdapter: StructuredAgentSessionAdapter, - onAttached: AttachFlowInput['onAttached'] = () => {} -) { +async function openHost(sessionAdapter: StructuredAgentSessionAdapter) { store ??= await openTestAgentSessionRecordStore(root!) - return performAttach({ + host ??= new StructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(transcriptPath), - now: () => NOW, - onAttached + journalDatabase: openTestJournalHostDatabase(root!), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW }) + return host +} + +async function create( + transcriptPath: string | undefined, + sessionAdapter: StructuredAgentSessionAdapter +) { + const opened = await openHost(sessionAdapter) + return opened.create({ callerKey: 'client-1' }, attachParams(transcriptPath)) } describe('adopting a provider conversation on create', () => { @@ -147,13 +146,19 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'token ORCA-ADOPT-1') const sessionAdapter = adapter() - const result = await attach(transcriptPath, sessionAdapter) + const result = await create(transcriptPath, sessionAdapter) expect(result).toMatchObject({ ok: true }) - // The adapter was asked to resume, not to start: the seeded chain is what tells it which - // conversation this session owns. + // The seeded chain is what tells the first start which conversation to resume. + expect(store?.getRecord(SESSION)?.providerHandleChain).toEqual([ + expect.objectContaining({ + origin: 'adopted', + handle: { provider: 'codex', threadId: THREAD } + }) + ]) const page = (result as { value: { page: { items: unknown[] } } }).value.page expect(JSON.stringify(page.items)).toContain('ORCA-ADOPT-1') + expect(sessionAdapter.acquire).not.toHaveBeenCalled() }) it('replays create without replacing journal-only messages or rereading the source', async () => { @@ -161,26 +166,26 @@ describe('adopting a provider conversation on create', () => { const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'original turn') const sessionAdapter = adapter() - const first = await attach(transcriptPath, sessionAdapter, async ({ journal }) => { - await journal.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - }) + const first = await create(transcriptPath, sessionAdapter) expect(first.ok).toBe(true) - await rm(transcriptPath) - const replay = await attach(transcriptPath, sessionAdapter, async ({ journal }) => - journal.close() + const journal = host?.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('no open journal') + } + await journal.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) + await rm(transcriptPath) + const replay = await create(transcriptPath, sessionAdapter) expect(replay).toMatchObject({ ok: true, replayed: true }) if (!first.ok || !replay.ok) { - throw new Error('attach failed') + throw new Error('create failed') } expect(replay.cursor.epoch).toBe(first.cursor.epoch) expect(JSON.stringify(replay.value.page.items)).toContain('not yet in rollout') - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() }) it.each(['missing', 'oversized', 'empty', 'invalid', 'source-less'] as const)( @@ -195,19 +200,15 @@ describe('adopting a provider conversation on create', () => { await writeFile(transcriptPath, kind === 'empty' ? '' : 'not json\n') } const sessionAdapter = adapter() - const onAttached = vi.fn() - const result = await attach( + const result = await create( kind === 'source-less' ? undefined : transcriptPath, - sessionAdapter, - onAttached + sessionAdapter ) expect(result).toMatchObject({ ok: false, refusal: { code: 'agent_session_identity_required' } }) expect(sessionAdapter.acquire).not.toHaveBeenCalled() - expect(sessionAdapter.releaseAcquisition).not.toHaveBeenCalled() - expect(onAttached).not.toHaveBeenCalled() expect(store?.getRecord(SESSION)).toBeNull() expect(store?.listOperationRows()).toEqual([]) if (kind === 'oversized') { @@ -216,83 +217,74 @@ describe('adopting a provider conversation on create', () => { } ) - it('still releases acquisition on an import write failure, and leaves the conversation open', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) - const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'valid source') - vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( - new Error('disk write failed') - ) - const sessionAdapter = adapter() - await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) - expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) - }) + // The import runs after the at-rest record commits. A create cut short there (a throw, a crash, a + // quit) leaves its operation pending, so its replay imports again rather than answering empty. + it.each([ + [ + 'import write', + () => + vi + .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') + .mockRejectedValueOnce(new Error('disk write failed')), + 'disk write failed' + ], + [ + 'journal open', + (sessionAdapter: StructuredAgentSessionAdapter) => { + sessionAdapter.historyFilePath = vi + .fn>() + .mockRejectedValueOnce(new Error('journal path unavailable')) + .mockResolvedValue(null) + }, + 'journal path unavailable' + ] + ] as const)( + 'imports the history on the replay of a create whose %s failed', + async (_case, interrupt, message) => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-interrupted-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'token ORCA-ADOPT-REPLAYED') + const sessionAdapter = adapter() + interrupt(sessionAdapter) - it('leaves the conversation writable when the import fails after acquiring', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) - const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'valid source') - store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( - new Error('disk write failed') - ) - const attached = await host - .attach({ callerKey: 'client-1' }, attachParams(transcriptPath)) - .catch(() => null) - expect(attached?.ok).not.toBe(true) + await expect(create(transcriptPath, sessionAdapter)).rejects.toThrow(message) + expect(store?.getOperationRow('client-1', OPERATION)?.outcome).toEqual({ status: 'pending' }) - const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } - const sent = await host.send( - { callerKey: 'client-1' }, - { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-${'2'.padStart(32, '0')}`, - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body + const replay = await create(transcriptPath, sessionAdapter) + expect(replay).toMatchObject({ ok: true, replayed: true }) + if (!replay.ok) { + throw new Error('create replay failed') } - ) - // The failed attach kept the conversation's own journal open, so the send is recorded. - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - await host.flushAllStreamedEvents() - }) + expect(JSON.stringify(replay.value.page.items)).toContain('ORCA-ADOPT-REPLAYED') + expect(store?.getOperationRow('client-1', OPERATION)?.outcome).toEqual({ + status: 'succeeded', + sessionId: SESSION + }) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + } + ) - it('prepares a valid source once before acquisition and imports those exact items', async () => { + it('prepares a valid source once before the record claims it and imports those exact items', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-')) const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'prepared before acquiring') + await writeCodexRollout(transcriptPath, 'prepared before claiming') const prepare = vi.spyOn(legacyImport, 'prepareLegacyTranscriptImport') const sessionAdapter = adapter() - const acquire = sessionAdapter.acquire - sessionAdapter.acquire = vi.fn(async (input) => { + const opened = await openHost(sessionAdapter) + const createAtRest = opened.deps.store.createAtRest + vi.spyOn(opened.deps.store, 'createAtRest').mockImplementation(async (request) => { expect(prepare).toHaveBeenCalledTimes(1) + // Gone once the record claims it: the import must use what was prepared. await rm(transcriptPath) - return acquire(input) + return createAtRest(request) }) - const result = await attach(transcriptPath, sessionAdapter, async ({ journal }) => - journal.close() - ) + const result = await create(transcriptPath, sessionAdapter) expect(result.ok).toBe(true) if (!result.ok) { - throw new Error('attach failed') + throw new Error('create failed') } - expect(JSON.stringify(result.value.page.items)).toContain('prepared before acquiring') + expect(JSON.stringify(result.value.page.items)).toContain('prepared before claiming') expect(prepare).toHaveBeenCalledTimes(1) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 3ee04bead04b..55f5056bca78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -145,7 +146,7 @@ beforeEach(async () => { mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW }) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 140a99201156..044cb450311d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -130,7 +131,8 @@ beforeEach(async () => { await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr') await writeCommand(join(account, 'commands'), 'deploy.md') await openHost() - const attached = await host.attach( + const attached = await attachForTests( + host, caller, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index d1b6cd280875..b2373f421c49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -34,10 +34,6 @@ import type { StructuredAgentSessionEventSink } from './structured-agent-session import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { acquireOwner } from './structured-agent-session-acquisition' -import { - importAdoptedTranscript, - prepareAdoptedTranscript -} from './structured-agent-session-adopted-import' import { withAgentSessionCreatePhase, type AgentSessionCreatePhaseRecorder @@ -93,11 +89,23 @@ export async function performAttach( ) }) const sessionId = params.envelope.sessionId + const expectedFence = params.envelope.expectedRuntimeFence + if (expectedFence === null) { + // A record is founded only by its create; a start names the fence of the record it read. + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + 'An agent start names the fence of the session record it starts.' + ) + } + } const admitted = admitAttachOrRefuse(params) if (!admitted.ok) { return admitted } - // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. + // An agent start bypasses create-intent, so recheck before reserving or spawning. if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { return unsupported() } @@ -110,18 +118,13 @@ export async function performAttach( let unsupportedReservationSettlementAttempted = false let replayed = false let providerHistoryWindow: ProviderHistoryWindow | null = null - const preparedTranscript = store.getRecord(sessionId) - ? { ok: true as const, items: null } - : await prepareAdoptedTranscript(params) - if (!preparedTranscript.ok) { - return preparedTranscript - } try { const reserved = await withAgentSessionCreatePhase('reserve_owner', input.recordPhase, () => store.reserveOwner( reserveRequestFor({ sessionId, params, + expectedFence, authority: input.authority, callerKey: input.callerKey, fingerprint: admitted.fingerprint, @@ -236,7 +239,6 @@ export async function performAttach( openConversation: input.openConversation, providerHistoryWindow }) - await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) await store.recordOperationOutcome({ callerKey: input.callerKey, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index d8f89065de4f..5d5748510893 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -35,6 +35,7 @@ import { } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { addAgentSessionCreatePhaseAttributes, withAgentSessionCreatePhase, @@ -50,12 +51,10 @@ export type StructuredAgentSessionAttachOptions = { } /** - * The attach itself, for a caller already inside the session's serialize. - * - * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, - * and an operation that needs the provider. They run their check and this attach in one serialized - * step, so "the session has no child" is still true when - * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. + * The attach itself, for a caller already inside the session's serialize: the delivery loop, and + * an operation that needs the provider. They run their check and this attach in one serialized + * step, so "the session has no child" is still true when the attach starts. It is the only way an + * agent starts; a create starts none (`structured-agent-session-create-at-rest`). */ export function attachStructuredAgentSessionUnderSerialize( context: StructuredAgentSessionAttachContext, @@ -63,29 +62,39 @@ export function attachStructuredAgentSessionUnderSerialize( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions = {} ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options)) + const record = context.deps.store.getRecord(params.envelope.sessionId) + // Tracked from the start: a quit drains it before it evicts, so no child is orphaned behind that. + return context.tasks.trackAttach( + record && isFirstAgentStart(record) + ? withFirstStartSpan(context, callerKey, params, options) + : runAttach(context, callerKey, params, options) + ) +} + +/** No agent has run this conversation in Orca yet: its chain holds nothing, or only what it adopted. */ +function isFirstAgentStart(record: AgentSessionRecord): boolean { + return record.providerHandleChain.every((link) => link.origin === 'adopted') } -export function attachStructuredAgentSession( +/** The `agentSession.create` span measures a chat's first agent start, which its create used to + * run; a later start (after idle, a crash, a retry of a later message) is not in it. */ +function withFirstStartSpan( context: StructuredAgentSessionAttachContext, callerKey: string, - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions ): Promise> { - const sessionId = params.envelope.sessionId - // Tracked from enqueue, not from its turn on the queue: a quit drains a queued attach before it - // evicts, so no child is spawned behind the eviction and orphaned. - const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => - context.tasks.trackAttach( - context.serialize(sessionId, () => runAttach(context, callerKey, params, { recordPhase })) - ) - if (params.envelope.expectedRuntimeFence !== null) { - return run() - } return withAgentSessionSpan(async (span) => { const startedAtMs = Date.now() const phases: Parameters[0][] = [] try { - return await run((timing) => phases.push(timing)) + return await runAttach(context, callerKey, params, { + ...options, + recordPhase: (timing) => { + phases.push(timing) + options.recordPhase?.(timing) + } + }) } finally { addAgentSessionCreatePhaseAttributes(span, { totalDurationMs: Math.max(0, Date.now() - startedAtMs), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts new file mode 100644 index 000000000000..4e861b0a6bd7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts @@ -0,0 +1,78 @@ +// For a test that needs a running agent now, the way production gives a chat one: a create at rest +// when the params name no record yet (a null fence), then the start its first message makes from +// the stored record, at the fence it reads there. Answers in the attach's shape for the fence and page. + +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import { readAgentSessionHydrationPage } from './agent-session-history-page' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' + +export async function attachForTests( + host: StructuredAgentSessionHost, + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams +): Promise> { + const sessionId = params.envelope.sessionId + if (params.envelope.expectedRuntimeFence === null) { + const created = await host.create(caller, params) + if (!created.ok) { + return created + } + } + return startAgentForTests(host, sessionId) +} + +/** The delivery loop's start, as its first message would make it. */ +export function startAgentForTests( + host: StructuredAgentSessionHost, + sessionId: string +): Promise> { + const { attachContext, serialize, sessions } = host.collaboratorsForTests() + return serialize(sessionId, async () => { + const started = await ensureStructuredAgentSessionAgent(attachContext(), sessionId) + if (!started.ok) { + return { ok: false, refusal: started.refusal } + } + const journal = sessions.get(sessionId)?.journal + const fence = host.deps.store.getRecord(sessionId)?.lease.runtimeFence + if (!journal || fence === undefined) { + throw new Error(`attachForTests: ${sessionId} started without an open conversation`) + } + const tabId = host.deps.store.getSessionTabId(sessionId) + return { + ok: true, + replayed: false, + fence, + cursor: journal.cursor(), + value: { + sessionId, + fence, + page: readAgentSessionHydrationPage(journal, fence), + unconfirmedClientMessageIds: [], + ...(tabId ? { tabId } : {}) + } + } + }) +} + +/** + * The attach itself at the fence the caller names, for tests of its own guards (a stale fence, a + * lease that still admits a writer, the drain before a replacement acquires). The production start + * never presents those: it reads the record under the same serialize and skips a running agent. + */ +export function attachExistingForTests( + host: StructuredAgentSessionHost, + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams +): Promise> { + const { attachContext, serialize } = host.collaboratorsForTests() + return serialize(params.envelope.sessionId, () => + attachStructuredAgentSessionUnderSerialize(attachContext(), caller.callerKey, params) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 85f99056c3a7..9320c2653c57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -1,9 +1,6 @@ -// Attach: reserve the session record, then open its journal. -// -// `create` and `ensure` are the same transition with a different starting -// point — a null expected fence means "no session exists yet". Both go through -// the record store's compare-and-swap, which also owns the idempotency row, so -// a retried attach replays instead of reserving a second owner. +// Attach: reserve an existing session record for an agent start, then open its journal. A record is +// founded only by its create (`structured-agent-session-create-at-rest`); the attach goes through the +// record store's compare-and-swap at the fence the start read. import type { AgentSessionJournalIdentity, @@ -72,8 +69,8 @@ export type AgentSessionAttachParams = { * than starting one: it seeds the handle chain so the adapter resumes instead of creating, and * names the transcript to import so the journal shows the conversation so far. * - * Deliberately separate from `providerHandle`, which `agentSession.ensure` already supplies - * without adopting — presence of a handle must never be what triggers a resume. + * Deliberately separate from `providerHandle`, which an agent start supplies without + * adopting — presence of a handle must never be what triggers a resume. */ adopt?: { providerHandle: Exclude @@ -264,7 +261,7 @@ async function reconcileAgainstProviderHistory(input: { */ const ADOPTED_HANDLE_FENCE = 1 -function adoptedProviderHandleLink( +export function adoptedProviderHandleLink( handle: Exclude, observedAt: number ): AgentSessionProviderHandleLink { @@ -289,6 +286,7 @@ function adoptedProviderHandleLink( export function reserveRequestFor(input: { sessionId: string params: AgentSessionAttachParams + expectedFence: number authority: AgentSessionAttachAuthority callerKey: string fingerprint: string @@ -301,19 +299,9 @@ export function reserveRequestFor(input: { provider: params.provider, accountHome: params.accountHome, ...(params.options ? { options: params.options } : {}), - ...(params.envelope.expectedRuntimeFence === null && params.surfaceTabId - ? { surfaceTabId: params.surfaceTabId } - : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), - ...(params.adopt - ? { - // Fence 1 is a new record's first, and the owner probe requires the head link to carry - // the record's current fence. - adoptedHandleLink: adoptedProviderHandleLink(params.adopt.providerHandle, input.now) - } - : {}), - expectedFence: params.envelope.expectedRuntimeFence, + expectedFence: input.expectedFence, spawnToken: authority.spawnToken, claimKeyId: authority.claimKeyId, handoffOperationId: authority.handoffOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index bbdc2b50b2fd..d28e312a5e44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -80,7 +81,8 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - const attached = await host.attach( + const attached = await attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index ac1111ac629e..1214750b349e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -81,7 +82,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 1bb3d36a5c43..eda43174eaa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -16,6 +16,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-claude' } @@ -63,6 +64,7 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { + supportsCreate: (_location, agent) => agent === 'claude', acquire, dispatch: vi.fn(), cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -93,7 +95,8 @@ beforeEach(async () => { now: () => NOW }) expect( - await host.attach( + await attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 5e4ac75b0662..8984f7ba4b4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -97,7 +98,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f09fe672b5ec..bee95b1e7482 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -8,7 +8,10 @@ import { fakeClaude, identityFor } from '../../claude/claude-structured-session-test-support' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { createTrackedJournalOpener, openTestJournalHostDatabase @@ -38,7 +41,7 @@ describe('Claude root-exit stop', () => { unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) const adapter = adapterFor(claude) - const reservation = await store.reserveOwner({ + const reservation = await foundAndReserveTestAgentSessionRecord(store, { sessionId: 'session-1', location: { executionHostId: 'local', @@ -48,7 +51,6 @@ describe('Claude root-exit stop', () => { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - expectedFence: null, spawnToken: 'spawn-1', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb65..0645f86b5e89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -41,6 +41,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -127,7 +128,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 9e10a753f07e..d603fa61acd2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -20,6 +20,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { @@ -84,7 +85,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 65dcfa420738..6ec47c3b0f44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -30,6 +30,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] @@ -88,7 +89,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 8195e7c18f9b..50b44f7144c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -10,6 +10,7 @@ import type { } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' +import { createStructuredAgentSessionHostReviewReplies } from './structured-agent-session-review-replies' import { createStructuredAgentSessionHostStatusFeed, type StructuredAgentSessionStatusSubscriber @@ -27,11 +28,14 @@ export class StructuredAgentSessionClientDelivery { private readonly statusFeed private readonly turnCompletionFeed private readonly sendSettlement + private readonly reviewReplies constructor( private readonly sessions: Map, now: () => number, private readonly deps: () => StructuredAgentSessionHostDeps, + /** The host's per-session lane, which a review reply's receipt is written in. */ + serialize: (sessionId: string, task: () => Promise) => Promise, private readonly onJournalActivity?: (sessionId: string) => void, onAgentStarted?: (sessionId: string) => void, /** A session's child records changed; the chat strip republishes from them. */ @@ -53,6 +57,11 @@ export class StructuredAgentSessionClientDelivery { this.requireJournal(sessionId) ) this.waitForSendSettlement = this.sendSettlement.wait + this.reviewReplies = createStructuredAgentSessionHostReviewReplies({ + deps, + journal: (sessionId) => sessions.get(sessionId)?.journal, + serialize + }) this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => this.readCommands(sessionId), readQueuePublication: (sessionId) => @@ -95,6 +104,9 @@ export class StructuredAgentSessionClientDelivery { } } + /** A journal opening re-derives what a run cut off by a restart still owes. */ + observeReviewReplies = (sessionId: string): void => this.reviewReplies.observe(sessionId) + publishRestored = (sessionId: string): void => this.statusFeed.publish(sessionId, undefined, { replay: true }) @@ -131,6 +143,8 @@ export class StructuredAgentSessionClientDelivery { // subscribed, which is the whole reason a backgrounded chat can complete at all. After the // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) + // Off the same edge: a review reply runs once its message is accepted, read or not. + this.reviewReplies.observe(sessionId) this.onJournalActivity?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts new file mode 100644 index 000000000000..70387ca035bb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts @@ -0,0 +1,110 @@ +// A launch prompt's caller reads the chat's first message to its final state. Closing the chat +// while that message waits out a retried start must end the read: the host withdraws the message +// and publishes that rejection, so the reader is never left waiting on a chat that is gone. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +let root: string +let host: StructuredAgentSessionHost + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-close-withdraws-first-message-')) + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store: await openTestAgentSessionRecordStore(root), + adapter: { + supportsCreate: () => true, + // A start refused before it ran, by a situation that clears on its own: retried later. + acquire: vi.fn(async () => { + throw new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + }), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +it('rejects the waiting first message, and its reader sees the rejection', async () => { + const created = await host.create({ callerKey: 'client-1' }, hostTestAttachParams(null)) + if (!created.ok) { + throw new Error('create refused') + } + const body = hostTestMessage('fix the checks') + const clientMessageId = hostTestOperationId() + await host.send( + { callerKey: 'client-1' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: clientMessageId, + expectedRuntimeFence: created.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions[0]?.startRetry?.nextAttemptAt + ).toBeDefined() + ) + const events: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ id: 'launch-prompt', sessionId: SESSION, emit: (e) => events.push(e) }) + + await host.close(SESSION, 'user-close') + + const seen = events.flatMap((event) => + event.type === 'batch' + ? event.batch.submissions + : event.type === 'snapshot' || event.type === 'reset' + ? event.page.submissions + : [] + ) + const ended = events.some((event) => event.type === 'end') + // Withdrawn with the start failure it was waiting out, so it reads as that failure. + const rejected = seen.some( + (entry) => + entry.clientMessageId === clientMessageId && + entry.dispatchState === 'rejected' && + entry.rejection?.kind === 'accountSwitchInProgress' + ) + expect(ended || rejected).toBe(true) + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + ).toMatchObject({ dispatchState: 'rejected' }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7f828a484688..40993d508071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -27,6 +27,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -78,7 +79,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 0ccab3257334..693af8a824c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -21,6 +21,7 @@ import { } from './structured-agent-session-rest-test-rig' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' let rig: RestTestRig @@ -254,7 +255,7 @@ describe('a start that never finishes (P2-15)', () => { return resolve.call(this, input) }) const reader = collectSubscriber() - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + const attached = await attachForTests(rig.host, CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index aab219c779d6..f52bb4dc1efc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const CALLER = { callerKey: 'client-1' } @@ -99,7 +100,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { @@ -184,13 +185,13 @@ describe('a Stop that names no turn', () => { expect(stopped).toEqual({ ok: true, replayed: false, - fence: 1, + fence: 2, cursor: expect.anything(), value: { cancelled: true } }) expect(cancelTurn).toHaveBeenCalledTimes(1) expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') - expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) + expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 2 }) expect(await statusRows()).toEqual(['Cancellation requested.']) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 059567386a6d..26fb836bb429 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -108,7 +109,7 @@ describe('a host that dies while its Codex child is starting', () => { it('leaves the spawned child recorded, so the next host stops it by identity and starts over', async () => { const first = await openStore('dying') const dying = host('dying', first, adapterThatNeverFinishesStarting()) - void dying.attach(CALLER, hostTestAttachParams(null)).catch(() => {}) + void attachForTests(dying, CALLER, hostTestAttachParams(null)).catch(() => {}) await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease.ownerProcess).toBeTruthy()) // Durable before the handshake returned: the only record the next host will have. expect(first.getRecord(SESSION)?.lease).toMatchObject({ @@ -148,8 +149,9 @@ describe('a host that dies while its Codex child is starting', () => { deathEvidence: { kind: 'pid-absent' } }) // What the next send's delivery does: start at the record's current fence. - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await relaunched.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ + ok: true + }) expect(restarted.connections).toHaveLength(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -158,22 +160,23 @@ describe('a host that dies while its Codex child is starting', () => { }) }) -// The client keeps a create it never heard back from and retries it under the same operation id, so -// that replay, not a fresh start, is what the user's Retry and first send go through. -describe('a create replayed after the host that ran it died', () => { +// The create committed at rest before its first start ran. The client's retry of a create it never +// heard back from answers that chat at rest, even once its operation row has aged out, and the +// next message's start is what starts an agent. +describe('a first start cut short by the host dying', () => { const PAST_OPERATION_EXPIRY = AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 it.each([ ['its child was recorded', adapterThatNeverFinishesStarting, 0], [ - 'its child was recorded, and its operation row has since expired', + 'its child was recorded, and its operation rows have since expired', adapterThatNeverFinishesStarting, PAST_OPERATION_EXPIRY ], ['nothing was recorded beyond the reservation', adapterThatNeverSpawns, 0], [ - 'nothing was recorded, and its operation row has since expired', + 'nothing was recorded, and its operation rows have since expired', adapterThatNeverSpawns, PAST_OPERATION_EXPIRY ] @@ -181,7 +184,7 @@ describe('a create replayed after the host that ran it died', () => { const params = hostTestAttachParams(null) const first = await openStore('dying') const dying = host('dying', first, dyingAdapter()) - void dying.attach(CALLER, params).catch(() => {}) + void attachForTests(dying, CALLER, params).catch(() => {}) await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease).toMatchObject( dyingAdapter === adapterThatNeverSpawns @@ -211,28 +214,90 @@ describe('a create replayed after the host that ran it died', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2 + runtimeFence: 3 + }) + // Before, `_operation_expired` once the row aged out, and the client's relaunch under a new id + // was then refused as `sessionExists`. + expect(await relaunched.create(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { sessionId: SESSION } }) + expect( + store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome + ).toEqual({ status: 'succeeded', sessionId: SESSION }) + expect(restarted.connections).toHaveLength(0) - const replayed = await relaunched.attach(CALLER, params) - // Before, `agent_session_ownership_unknown` while the row was pending, then `_operation_expired`. - expect(replayed.ok ? null : replayed.refusal.code).toBeNull() - expect(replayed).toMatchObject({ ok: true, value: { sessionId: SESSION, fence: 3 } }) + expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ + ok: true, + value: { sessionId: SESSION, fence: 4 } + }) expect(restarted.connections).toHaveLength(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', - runtimeFence: 3, + runtimeFence: 4, ownerProcess: { spawnToken: 'spawn-b' } }) - expect( - store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome - ).toEqual({ status: 'succeeded', sessionId: SESSION }) - // Settled now: the same id replays that answer and never starts a second agent. - await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true + // The next message finds the agent running and never starts a second one. + await expect(startAgentForTests(relaunched, SESSION)).resolves.toMatchObject({ ok: true }) + expect(restarted.connections).toHaveLength(1) + }) +}) + +// An older host ran the agent inside its create and died there, leaving the create's row pending. +describe('a create an older host left pending when it died', () => { + it('answers the chat at rest on its retry, settles the row, and starts on the next message', async () => { + const params = hostTestAttachParams(null) + const operation = { + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId + } + const left = await openStore('dying') + const identity = { + sessionId: SESSION, + location: params.location, + provider: params.provider, + accountHome: params.accountHome + } + await left.createAtRest({ + ...identity, + claimKeyId: 'key-1', + operation: { ...operation, fingerprint: params.envelope.payloadFingerprint }, + now: NOW + }) + await left.reserveOwner({ + ...identity, + expectedFence: 1, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: operation.operationId, + probe: { outcome: 'reservation-unused' }, + operation: { ...operation, fingerprint: params.envelope.payloadFingerprint }, + now: NOW + }) + await crash(left) + + const restarted = fakeCodex() + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no token scan' }) + } + ) + await relaunched.restoreReadableSessions() + + expect(await relaunched.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(store.getOperationRow(operation.callerKey, operation.operationId)?.outcome).toEqual({ + status: 'succeeded', + sessionId: SESSION }) + expect(restarted.connections).toHaveLength(0) + expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ ok: true }) expect(restarted.connections).toHaveLength(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e2376150891..f86173748392 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -45,6 +45,7 @@ import { HOST_TEST_LOCATION as LOCATION, HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' @@ -252,7 +253,8 @@ function turnStatesSent(events: readonly AgentSessionSubscribeEvent[]) { } function attach(fence: number) { - return host.attach( + return attachForTests( + host, { callerKey: 'client-1' }, hostTestAttachParams(fence, { provider: 'claude', @@ -536,13 +538,13 @@ describe('a turn a newer start could not settle before it failed', () => { lastRenewedAt: RELAUNCHED_AT }) advance(60_000) - // Recovery records that child's death, then the client retries at the fence it was told. - await expect(attach(15)).resolves.toMatchObject({ refusal: { currentFence: 16 } }) - expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ - ownerFence: 15, - lastProvenAliveAt: RELAUNCHED_AT + // Recovery records that child's death; the next start then reserves past it. + await host.collaboratorsForTests().runtimeState.resolveRecovery(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 16, + deathEvidence: { ownerFence: 15, lastProvenAliveAt: RELAUNCHED_AT } }) - await expect(attach(16)).resolves.toMatchObject({ ok: true }) + await expect(attach(16)).resolves.toMatchObject({ ok: true, fence: 17 }) expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts new file mode 100644 index 000000000000..82edb04821ec --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts @@ -0,0 +1,156 @@ +// Creating a chat: its record and its journal, at rest. No agent starts here. The chat's first +// message starts one (`structured-agent-session-agent-start`), so a start that fails is reported +// on that message and never fails the create. + +import { refuse } from '../../../shared/agent-session-wire-refusals' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + adoptedProviderHandleLink, + admitAttachOrRefuse, + classifyStoreFailure, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { pinnedAgentSessionLaunchArgs } from './structured-agent-session-launch-env' +import { + importAdoptedTranscript, + prepareAdoptedTranscript +} from './structured-agent-session-adopted-import' + +type CreateResult = AgentSessionMutationResult + +export function createStructuredAgentSessionAtRest( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise { + // Tracked from enqueue: a quit drains a create before it closes conversations, so none it opens + // is left open behind the teardown. + return context.tasks.trackAttach( + context.serialize(params.envelope.sessionId, () => createAtRest(context, callerKey, params)) + ) +} + +async function createAtRest( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise { + const { store, adapter } = context.deps + const sessionId = params.envelope.sessionId + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + if (!adapterSupportsCreateIfDeclared(adapter, params.location, params.agent)) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) + } + } + // A record this host has not adjudicated since load is adjudicated here, as a start does: the + // create's transaction refuses one still unreconciled, and nothing else on this path would clear it. + if (store.getRecord(sessionId)?.lease.unreconciled) { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return { ok: false, refusal: unreconciled } + } + } + // Read and checked before the record claims the provider conversation it adopts. + const transcript = store.getRecord(sessionId) + ? { ok: true as const, items: null } + : await prepareAdoptedTranscript(params) + if (!transcript.ok) { + return transcript + } + let created: Awaited> + try { + const now = context.now() + created = await store.createAtRest({ + sessionId, + location: params.location, + provider: params.provider, + accountHome: params.accountHome, + ...(params.options ? { options: params.options } : {}), + ...(params.surfaceTabId ? { surfaceTabId: params.surfaceTabId } : {}), + ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), + ...(params.adopt + ? { adoptedHandleLink: adoptedProviderHandleLink(params.adopt.providerHandle, now) } + : {}), + claimKeyId: context.deps.claimKeyId, + operation: { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint: admitted.fingerprint + }, + now + }) + } catch (error) { + return { ok: false, refusal: classifyStoreFailure(error, null, store.getRecord(sessionId)) } + } + // Settled only once the journal is open and any adopted history is in it. A create that stopped + // short (a throw, a crash, a quit) leaves its row pending, and its replay does that work again: + // the import is a no-op on a journal that already holds rows. + const settled = created.operationRow.outcome.status === 'succeeded' + if (created.replayed && !settled) { + // `failed` or `unknown` rows are a create from before chats were created at rest. + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: created.operationRow.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return { ok: false, refusal: replay.refusal } + } + } + const conversation = await context.openConversation(created.record.sessionId) + if (!conversation) { + return { + ok: false, + refusal: refuse( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) + } + } + const { journal } = conversation + if (!settled) { + await importAdoptedTranscript( + params, + { journal, unconfirmedClientMessageIds: [] }, + created.record, + transcript.items + ) + await store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: created.record.sessionId } + }) + } + const fence = created.record.lease.runtimeFence + const tabId = store.getSessionTabId(created.record.sessionId) + return { + ok: true, + replayed: created.replayed, + fence, + cursor: journal.cursor(), + value: { + sessionId: created.record.sessionId, + fence, + page: readAgentSessionHydrationPage(journal, fence), + unconfirmedClientMessageIds: [], + ...(tabId ? { tabId } : {}) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts new file mode 100644 index 000000000000..378726e18f74 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts @@ -0,0 +1,66 @@ +// A quit drains a create still in flight before it closes conversations, as it drains a start. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +it('closes the conversation a create opened while the quit waited for it', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-create-drained-at-quit-')) + const journalOpening = Promise.withResolvers() + const releaseJournalOpen = Promise.withResolvers() + const adapter: StructuredAgentSessionAdapter = { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + historyFilePath: () => { + journalOpening.resolve() + return releaseJournalOpen.promise + } + } + const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store: await openTestAgentSessionRecordStore(root), + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW + }) + + const created = host.create({ callerKey: 'client-1' }, hostTestAttachParams(null)) + // Past the record's commit, opening the chat's journal. + await journalOpening.promise + let quitFinished = false + const quit = host.flushAllStreamedEvents().then(() => { + quitFinished = true + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(quitFinished).toBe(false) + + releaseJournalOpen.resolve(null) + expect(await created).toMatchObject({ ok: true }) + await quit + expect(host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + expect(adapter.acquire).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts new file mode 100644 index 000000000000..c8d66c34622b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts @@ -0,0 +1,102 @@ +// Every lease loads unreconciled. A create over the record an older host's failed start left must +// adjudicate that lease itself, as a start does: the create refuses one still unreconciled, and +// nothing else on the create path would clear it, so the chat's relaunch would be refused forever. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +let root: string +let host: StructuredAgentSessionHost | null = null + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-create-reconciles-first-')) +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + host = null + await rm(root, { recursive: true, force: true }) +}) + +/** What an older host left: its create reserved the record, the start failed and was proven gone. */ +async function leaveFailedCreate(): Promise { + const store = await openTestAgentSessionRecordStore(root) + const params = hostTestAttachParams(null) + const identity = { + sessionId: SESSION, + location: params.location, + provider: params.provider, + accountHome: params.accountHome + } + const operation = { callerKey: 'client-1', operationId: hostTestOperationId() } + await store.createAtRest({ + ...identity, + claimKeyId: 'key-1', + operation: { ...operation, fingerprint: 'old-create' }, + now: NOW + }) + const reserved = await store.reserveOwner({ + ...identity, + expectedFence: 1, + spawnToken: 'spawn-old', + claimKeyId: 'key-1', + handoffOperationId: operation.operationId, + probe: { outcome: 'reservation-unused' }, + operation: { ...operation, fingerprint: 'old-create' }, + now: NOW + }) + await store.settleFailedAcquisition({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + spawnToken: 'spawn-old', + ...operation, + outcome: { status: 'failed', code: 'agent_session_operation_invalid', message: 'signed out' }, + exitProof: 'exit-proven', + now: NOW + }) + return store.getRecord(SESSION)!.lease.runtimeFence +} + +it('adjudicates an unreconciled lease, then founds the chat again at the next fence', async () => { + const leftFence = await leaveFailedCreate() + // A fresh process: every lease it loads is unreconciled until adjudicated. + const store = await openTestAgentSessionRecordStore(root) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + supportsCreate: () => true, + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW, + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + + const created = await host.create({ callerKey: 'client-1' }, hostTestAttachParams(null)) + + expect(created).toMatchObject({ ok: true, replayed: false, fence: leftFence + 1 }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'released', + deathEvidence: null + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index fca04e1028ac..4f8f1e0a569a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -1,5 +1,5 @@ -// A durably failed create tells a client what the host proved about the provider process, so a -// client can tell "retry under a new operation" (exited) from "the session may exist" (anything else). +// A failed first start says what the host proved about the provider process, so a reader can tell +// "the next start goes ahead" (exited) from "the process may still exist" (anything else). import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -23,12 +23,13 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' // The refusal says what the chat's start failure says; the error text stays in the log. -const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." +const COULD_NOT_START = "Codex couldn't start. Send your message to try again." const PROVIDER_STOPPED = 'Codex stopped before it finished starting. Send your message to try again.' @@ -74,10 +75,10 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('failed create owner verdict', () => { +describe('failed first start owner verdict', () => { it.each([ // The cleanup's release proves the whole tree gone, which says nothing about why it failed. - ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_START], // Only an exit the adapter saw says the provider stopped. [ 'an exit the adapter observed', @@ -86,12 +87,10 @@ describe('failed create owner verdict', () => { PROVIDER_STOPPED ] ])( - 'answers %s as exited on the first call and its replay, and a new operation starts fresh', + 'answers %s as exited, and the next start goes ahead', async (_case, failure, situation, message) => { acquire.mockRejectedValueOnce(failure()) - const first = hostTestAttachParams(null) - // The replay names the same details as the first answer: the ledger kept them beside the code, - // and the verdict reaches released clients at the top level exactly as before. + // The verdict reaches released clients at the top level exactly as before. const refusal = { code: 'agent_session_operation_invalid', details: { ...situation, ownerVerdict: 'exited' }, @@ -99,13 +98,13 @@ describe('failed create owner verdict', () => { ownerVerdict: 'exited' } - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toEqual({ + ok: false, + refusal + }) expect(acquire).toHaveBeenCalledOnce() - const retry = hostTestAttachParams(null) - expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } @@ -113,7 +112,7 @@ describe('failed create owner verdict', () => { it.each([ // A cleanup that saw the root go may have stopped it itself. - ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_START], [ 'a root exit the adapter observed', () => withObservedProviderExit(new Error(EXIT_REASON)), @@ -121,12 +120,9 @@ describe('failed create owner verdict', () => { PROVIDER_STOPPED ] ])( - 'answers %s as exited on the first call, in the shape its replay takes', + 'answers %s as exited, and the next start goes ahead', async (_case, cause, situation, message) => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) - const first = hostTestAttachParams(null) - // The replay names the same details as the first answer: the ledger kept them beside the code, - // and the verdict reaches released clients at the top level exactly as before. const refusal = { code: 'agent_session_operation_invalid', details: { ...situation, ownerVerdict: 'exited' }, @@ -134,13 +130,13 @@ describe('failed create owner verdict', () => { ownerVerdict: 'exited' } - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toEqual({ + ok: false, + refusal + }) expect(acquire).toHaveBeenCalledOnce() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) } ) @@ -148,7 +144,7 @@ describe('failed create owner verdict', () => { it('answers an acquisition refusal with its verdict directly', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toEqual({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toEqual({ ok: false, refusal: { code: 'agent_session_operation_invalid', @@ -161,12 +157,9 @@ describe('failed create owner verdict', () => { it('never claims exited when the failed attempt could not prove its process gone', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionExitUnprovenError(new Error('hung'))) - const first = hostTestAttachParams(null) - - await expect(host.attach(CALLER, first)).rejects.toThrow() - const replay = await host.attach(CALLER, first) + const answer = await attachForTests(host, CALLER, hostTestAttachParams(null)) - expect(replay).toMatchObject({ + expect(answer).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown', ownerVerdict: 'unverifiable' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 38b85ca12d1f..f8b3ef167c5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -69,14 +70,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a create that fails after its child wrote through the unbound sink', () => { +describe('a first start that fails after its child wrote through the unbound sink', () => { it.each([ // The common failed start: answered as a refusal. ['refused', new Error(EXIT_REASON)], - // A failure the attach cannot classify still throws, and must release the sink too. + // A failure the attach throws, settled before it does, which the start answers from. ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] ])( - 'releases the sink when %s, so a new create and shutdown both proceed', + 'releases the sink when %s, so the next start and shutdown both proceed', async (_how, cause) => { acquire.mockImplementationOnce(async ({ events }) => { // The published child's exit reached the translator before any journal was attached. @@ -84,17 +85,12 @@ describe('a create that fails after its child wrote through the unbound sink', ( throw cause }) - const failed = host.attach(CALLER, hostTestAttachParams(null)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ - ok: false, - refusal: { message: "Codex couldn't restart. Send your message to try again." } - })) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: false, + refusal: { message: "Codex couldn't start. Send your message to try again." } }) + + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() expect(acquire).toHaveBeenCalledTimes(2) } @@ -104,7 +100,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( ['refused', new Error(EXIT_REASON)], ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] ])('releases the sink when a resume of a still-indexed session is %s', async (_how, cause) => { - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -116,20 +112,17 @@ describe('a create that fails after its child wrote through the unbound sink', ( fence: exitedFence, acquisitionGeneration: `generation-${exitedFence}` }) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 acquire.mockImplementationOnce(async ({ events }) => { events?.setActivity?.(null) throw cause }) // The session stays indexed across this failure: it is a resume, not a create. - const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ ok: false })) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: false + }) - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(3) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts new file mode 100644 index 000000000000..96ec77175a4c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts @@ -0,0 +1,81 @@ +// The `agentSession.create` span keeps its meaning now that a create starts nothing: it measures a +// chat's first agent start, and no later one. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' +import * as instrumentation from '../../observability/agent-session-instrumentation' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams +} from './structured-agent-session-host-test-data' +import { startAgentForTests } from './structured-agent-session-attach-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +let root: string +let host: StructuredAgentSessionHost +let acquire: Mock + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-first-start-span-')) + const store = await openTestAgentSessionRecordStore(root) + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +it('wraps the first start, and its retry after a failure, but no start once an agent has run', async () => { + const span = vi.spyOn(instrumentation, 'withAgentSessionSpan') + acquire.mockRejectedValueOnce(new Error('signed out')) + + expect(await host.create({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + expect(span).not.toHaveBeenCalled() + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false }) + // Still the chat's first start: no agent has run it. + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) + expect(span).toHaveBeenCalledTimes(2) + + // Put to rest and started again, as the next message after the idle sweep does. + await host.collaboratorsForTests().lifetime.stopAgent(SESSION, { cause: 'evict', resting: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(3) + expect(span).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index c0e78b5a0c52..81d569ba58fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,7 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' @@ -200,9 +200,10 @@ function attachContext( } as unknown as StructuredAgentSessionAttachContext } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach fails before reading anything past the envelope. const attachParams = { envelope: { sessionId: SESSION, clientOperationId: 'op-1' } -} as unknown as Parameters[2] +} as unknown as Parameters[2] describe('a session that leaves the host without an explicit close', () => { it('forgets the retained exact subject after the record and live session are deleted first', async () => { @@ -243,7 +244,11 @@ describe('a session that leaves the host without an explicit close', () => { const drop = vi.spyOn(server, 'dropStructuredStatus') await expect( - attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) + attachStructuredAgentSessionUnderSerialize( + attachContext(sessions, feed), + 'caller-1', + attachParams + ) ).rejects.toThrow('attach failed after acquisition') expect(sessions.has(SESSION)).toBe(true) @@ -257,7 +262,11 @@ describe('a session that leaves the host without an explicit close', () => { const { feed, sessions } = await workingSession() await expect( - attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) + attachStructuredAgentSessionUnderSerialize( + attachContext(sessions, feed), + 'caller-1', + attachParams + ) ).rejects.toThrow('attach failed after acquisition') const events: unknown[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index b82499c28301..f41b52414315 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -151,7 +152,7 @@ afterEach(async () => { describe('grouped question admission', () => { it('reads the packed answer an older client sends into structured answers', async () => { - const attached = await host.attach(CALLER, attachParams()) + const attached = await attachForTests(host, CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() const answers = [ @@ -175,7 +176,7 @@ describe('grouped question admission', () => { }) it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { - const attached = await host.attach(CALLER, attachParams()) + const attached = await attachForTests(host, CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) @@ -207,7 +208,7 @@ describe('grouped question admission', () => { }) it('refuses answers that do not match the questions without reaching the provider', async () => { - const attached = await host.attach(CALLER, attachParams()) + const attached = await attachForTests(host, CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] @@ -245,7 +246,7 @@ describe('a grouped question answered again', () => { } it('answers a re-click with the same answers from the resolution it holds', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) + expect((await attachForTests(host, CALLER, attachParams())).ok).toBe(true) const prompt = await seedGroupedQuestion() const first = await answer(prompt, answers) @@ -258,7 +259,7 @@ describe('a grouped question answered again', () => { }) it('refuses different answers to a resolved question as moved on', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) + expect((await attachForTests(host, CALLER, attachParams())).ok).toBe(true) const prompt = await seedGroupedQuestion() await answer(prompt, answers) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 72160505bc7c..cbbf7d8cd8d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,6 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -55,6 +56,7 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + reviewReply?: AgentSessionReviewReply /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` * origin, whose started turn ends a Stop's or a restart's queue pause. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index ee0bb2bee929..2532c09ec1a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -19,6 +19,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,9 @@ describe('abandoning a structured agent-session host', () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) // The conversation stays and its provider child does not, so the next send makes the delivery // loop start one — the shape the refusal-oracle spec ends on. await host.close(SESSION, 'evict') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index c7667531110f..0ad3bdfc0a5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const journals = createTrackedJournalOpener() @@ -95,7 +96,7 @@ function adapter(): StructuredAgentSessionAdapter { } async function attach(): Promise { - const result = await host.attach(CALLER, attachParams()) + const result = await attachForTests(host, CALLER, attachParams()) expect(result.ok).toBe(true) return store.getRecord(SESSION) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index aaaa595c8598..29a94c621f62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionReviewRuntime } from './structured-agent-session-review-reply-runner' import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' @@ -150,4 +151,6 @@ export type StructuredAgentSessionHostDeps = { statusSink?: StructuredAgentSessionStatusSink /** Host model catalog surface; absent means every catalog read answers `unknown`. */ modelCatalog?: AgentModelCatalogService + /** The review writes a launch prompt's review reply asks for; absent, none runs. */ + reviewRuntime?: StructuredAgentSessionReviewRuntime } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index f66d2506804d..c26d0f033f63 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -28,6 +28,11 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + attachExistingForTests, + attachForTests, + startAgentForTests +} from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' let root: string @@ -56,7 +61,7 @@ beforeEach(() => { describe('attach', () => { it('reserves the lease, spawns through the adapter, and opens the journal', async () => { - const result = await host.attach(CALLER, attachParams()) + const result = await attachForTests(host, CALLER, attachParams()) expect(result).toMatchObject({ ok: true, replayed: false }) const record = store.getRecord(SESSION) expect(record?.lease.ownerProcess?.pid).toBe(4242) @@ -65,7 +70,7 @@ describe('attach', () => { it('refuses a payload the client fingerprinted wrong', async () => { const params = attachParams() - const result = await host.attach(CALLER, { + const result = await host.create(CALLER, { ...params, envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } }) @@ -80,7 +85,7 @@ describe('attach', () => { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ + expect(await host.create(CALLER, params)).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) @@ -89,14 +94,18 @@ describe('attach', () => { it('refuses a second create against a live session', async () => { await attach() - expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) + expect(await host.create(CALLER, attachParams())).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict', details: { reason: 'sessionExists' } } + }) }) - it('replays a retried attach instead of reserving a second owner', async () => { + it('replays a retried create instead of founding a second record', async () => { const params = attachParams() - await host.attach(CALLER, params) - const retry = await host.attach(CALLER, params) - expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(await host.create(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + const retry = await host.create(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true, fence: 1 }) + expect(acquire).not.toHaveBeenCalled() }) it('retires a failed proved acquisition before admitting a fresh operation', async () => { @@ -149,14 +158,14 @@ describe('attach', () => { refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' }, - message: "Codex couldn't restart. Send your message to try again.", + message: "Codex couldn't start. Send your message to try again.", ownerVerdict: 'exited' } } - expect(await host.attach(CALLER, params)).toEqual(refused) - expect(await host.attach(CALLER, params)).toEqual(refused) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await host.attach(CALLER, ensureParams(releasedFence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toEqual(refused) + // The failure is the start's: the create it followed still replays as the chat it made. + expect(await host.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) @@ -165,10 +174,10 @@ describe('attach', () => { it('reaps an acquisition when process identity commit fails', async () => { vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed')) - await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, attachParams())).resolves.toMatchObject({ ok: false, refusal: { - message: "Codex couldn't restart. Send your message to try again.", + message: "Codex couldn't start. Send your message to try again.", ownerVerdict: 'exited' } }) @@ -201,7 +210,11 @@ describe('attach', () => { now: NOW }) - const replacement = host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + const replacement = attachExistingForTests( + host, + CALLER, + ensureParams(released.lease.runtimeFence) + ) await new Promise((resolve) => setImmediate(resolve)) expect(acquire).toHaveBeenCalledTimes(1) @@ -556,26 +569,14 @@ describe('restart', () => { replaceHostTestState({ store, host }) } - /** The refusal a restarted host owes a client holding the dead generation's - * fence: stale, with the live fence attached so the retry can succeed. */ - async function staleFenceFrom(held: number): Promise { - const refused = await host.attach(CALLER, ensureParams(held)) - if (refused.ok) { - throw new Error('a fence from the previous host generation was accepted') - } - expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') - const current = refused.refusal.currentFence - expect(current).toBeGreaterThan(held) - return current ?? 0 - } - it('adjudicates the leases it loaded before deciding who may write', async () => { const before = await attach() const held = before?.lease.runtimeFence ?? 0 await reboot(async () => ({ outcome: 'pid-absent' })) - const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) - expect(reattached).toMatchObject({ ok: true }) + // The start reads the fence the restart's eviction moved to, never the dead generation's. + const reattached = await startAgentForTests(host, SESSION) + expect(reattached).toMatchObject({ ok: true, fence: held + 2 }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) }) @@ -619,7 +620,7 @@ describe('restart', () => { // The recovery stage clears on evidence at startup; the child comes back only once work // starts it — here the explicit attach a send's delivery would make. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, ensureParams(fence))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -651,7 +652,8 @@ describe('restart', () => { return settled(input) }) - const start = host.attach( + const start = attachForTests( + host, CALLER, ensureParams(store.getRecord(SESSION)?.lease.runtimeFence ?? 0) ) @@ -684,9 +686,7 @@ describe('restart', () => { ) acquire.mockClear() - expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ - ok: true - }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true, fence: held + 2 }) expect(acquire).toHaveBeenCalledOnce() // An unverifiable pid may already belong to an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() @@ -701,9 +701,9 @@ describe('restart', () => { .mockResolvedValue({ outcome: 'pid-absent' }) await reboot(probe) - await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') - const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) - expect(reattached).toMatchObject({ ok: true }) + await expect(startAgentForTests(host, SESSION)).rejects.toThrow('probe exploded') + const reattached = await startAgentForTests(host, SESSION) + expect(reattached).toMatchObject({ ok: true, fence: held + 2 }) expect(probe).toHaveBeenCalledTimes(2) }) }) @@ -806,7 +806,8 @@ describe('subscribe', () => { emit: (event) => events.push(event), cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } }) - expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) + // The create founded the record at fence 1; its first start moved it to 2. + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 2 }) }) it('publishes the replacement fence when the owner generation changes', async () => { @@ -824,7 +825,11 @@ describe('subscribe', () => { now: NOW }) - const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + const replacement = await attachExistingForTests( + host, + CALLER, + ensureParams(released.lease.runtimeFence) + ) if (!replacement.ok) { throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 3a6037478cce..418734cb6821 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -20,7 +20,7 @@ import { } from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { createStructuredAgentSessionAtRest } from './structured-agent-session-create-at-rest' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' import { @@ -67,7 +67,10 @@ export class StructuredAgentSessionHost { this.conversationDelivery.afterCommit(sessionId, journal) }, logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), - onOpened: (sessionId) => this.queued.drain.schedule(sessionId), + onOpened: (sessionId) => { + this.queued.drain.schedule(sessionId) + this.clientDelivery.observeReviewReplies(sessionId) + }, now: () => this.now() }) private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () => @@ -78,6 +81,7 @@ export class StructuredAgentSessionHost { this.sessions, () => this.now(), () => this.deps, + (sessionId, task) => this.serialize(sessionId, task), (sessionId) => this.queued.onJournalActivity(sessionId), (sessionId) => this.restartResume.onAgentStarted(sessionId), (sessionId) => this.backgroundTasks.publish(sessionId) @@ -242,12 +246,9 @@ export class StructuredAgentSessionHost { private serialize = this.tasks.serialize.bind(this.tasks) - attach( - caller: StructuredAgentSessionCaller, - params: AgentSessionAttachParams - ): Promise> { - return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) - } + /** A new chat at rest; its first message starts the agent. */ + create = (caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams) => + createStructuredAgentSessionAtRest(this.attachContext(), caller.callerKey, params) /** Test barrier: every write has landed by its call's return, so no production path needs it. */ flushStreamedEvents = (sessionId: string): Promise => @@ -347,6 +348,7 @@ export class StructuredAgentSessionHost { /** Test rigs only: the collaborators the host builds itself, typed, for tests that drive them. */ collaboratorsForTests = () => ({ + attachContext: () => this.attachContext(), sessions: this.sessions, subscribers: this.subscribers, runtimeState: this.runtimeState, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index f91fc57c0558..cfe6061b1d6a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -29,6 +29,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -126,7 +127,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index cd26b96e5cd1..a017c81251a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -1,4 +1,6 @@ -// Where a launch prompt sent the instant `attach` resolves reaches the adapter. +// Where a launch prompt sent the instant the create resolves reaches the adapter. The create +// starts nothing: that send, at the create's own fence, is what starts the agent, and the delivery +// loop dispatches it only after the start's `acquire` returns. // // Both shipped adapters look the session up in a live map and throw when it is absent // (`claude-structured-session-adapter.ts:331-337`, `codex-structured-session-state.ts`'s @@ -15,7 +17,7 @@ import { CALLER, hostTestState } from './structured-agent-session-host-test-harness' -import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' +import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data' let host: StructuredAgentSessionHost @@ -45,7 +47,7 @@ async function launchAndDeliver(): Promise<{ dispatchState: string }> { const send = vi.spyOn(host, 'send') - const created = await host.attach(CALLER, attachParams()) + const created = await host.create(CALLER, attachParams()) if (!created.ok) { throw new Error(`expected a create, got ${created.refusal.code}`) } @@ -53,6 +55,7 @@ async function launchAndDeliver(): Promise<{ host, caller: CALLER, sessionId: created.value.sessionId, + // The create's own fence, as `agentLaunchSurfaceFactory` sends it. fence: created.value.fence, text: 'fix the failing test' }) @@ -69,6 +72,7 @@ async function launchAndDeliver(): Promise<{ )?.dispatchState ?? 'missing' expect(dispatchState).not.toBe('pending') }) + expect(created.value.fence).toBe(1) return { messageId, dispatchState } } @@ -80,7 +84,7 @@ beforeEach(() => { }) describe('a launch prompt sent the instant the create resolves', () => { - it('reaches a live provider, because attach returns only after acquire published it', async () => { + it("reaches a live provider, because the first send's start publishes the session before it dispatches", async () => { modelAdapterLiveness(true) await expect(launchAndDeliver()).resolves.toEqual({ @@ -88,6 +92,8 @@ describe('a launch prompt sent the instant the create resolves', () => { dispatchState: 'accepted' }) expect(hostTestState().dispatch).toHaveBeenCalledTimes(1) + // Sent at the create's fence 1; its start moved the record to 2. + expect(hostTestState().store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence).toBe(2) }) // Positive control: the assertion above is only evidence if this arm can fail, and it names diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index c3eb1e324e9f..bac4a639d769 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -7,7 +7,10 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' @@ -18,7 +21,7 @@ async function liveStore(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) roots.push(root) const store = await openTestAgentSessionRecordStore(root) - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId: 'session-renewal', location: { executionHostId: 'local', @@ -28,7 +31,6 @@ async function liveStore(): Promise { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: root }, - expectedFence: null, spawnToken: 'spawn-renewal', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 641551d6f157..eab32a5ee5c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -71,7 +72,7 @@ type OlderBuildLease = Partial & { } async function persistFromOlderBuild(lease: OlderBuildLease): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() // Over the attached owner: the older build's stage or terminal owner kept it from releasing. @@ -264,8 +265,7 @@ describe('a record an older build left mid terminal handoff', () => { "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." } }) - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict', message: quitTerminal } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 9ab31743cf4b..8adccd1061a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -37,6 +37,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -99,7 +100,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) host.subscribe({ id: 'chat-1', sessionId: SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c88..40f852a9329e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -5,6 +5,7 @@ // operation happened, so the durable answer usually comes back out of the // journal. Send is fail-closed: admission alone cannot prove non-delivery. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' @@ -64,6 +65,7 @@ export function sendPlan(params: { body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + reviewReply?: AgentSessionReviewReply userSend?: true beforeRun?: () => void }): MutationPlan { @@ -75,9 +77,13 @@ export function sendPlan(params: { operationIdScope: 'global', conversationWrite: true, markUnknownBeforeRun: true, - // `delivery` joins the OPERATION fingerprint only; the submission row keeps + // `delivery` and `reviewReply` join the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. - fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, + fields: { + body: params.body, + ...(params.delivery ? { delivery: params.delivery } : {}), + ...(params.reviewReply ? { reviewReply: params.reviewReply } : {}) + }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. @@ -88,7 +94,8 @@ export function sendPlan(params: { origin: params.userSend ? 'client' : 'host', clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), - body: params.body + body: params.body, + ...(params.reviewReply ? { reviewReply: params.reviewReply } : {}) }) }, replay: (ctx, outcome) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 8a9d93c35e73..47ba3f3f994e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -133,7 +134,8 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-native', now: () => NOW }) - const attached = await host.attach( + const attached = await attachForTests( + host, CALLER, hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index e17d5ddf074a..7187db37c259 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -53,11 +53,14 @@ async function readStructuredAgentSessionOptionsAtRest( saved.fastMode === undefined ? null : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) - // An unknown model is one the client already treats as unconfirmed. Only a real listing names the - // account's default; a built-in list's default is a guess, so with none the client keeps its own. + // An unnamed model is one the client shows as unknown. Only a real Codex listing names the model a + // start will run; Claude's settings or env may pick another than its listing's default, so a Claude + // chat no agent has run names none, as its live child and the client's own upgrade never do. const model = saved.model ?? - (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ?? + (catalog.origin === 'unknown' || record.provider !== 'codex' + ? undefined + : models.find((entry) => entry.isDefault)?.id) ?? '' // As a live child answers: the pick, else what Claude runs for this model when none is sent. // A live Codex child answers only the effort its thread reported, never the model's default. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 8b2586a9ccba..ce2e340ef8e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -32,6 +32,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -122,7 +123,8 @@ afterEach(async () => { }) async function attachStarting(): Promise { - const created = await host.attach( + const created = await attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index f82a47296902..b53f12edaec7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' import { AgentSessionPreSpawnError, @@ -32,12 +35,13 @@ afterEach(async () => { root = null }) -function createParams(): AgentSessionAttachParams { +/** The first start of a chat its create founded at fence 1. */ +function startParams(): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { sessionId: SESSION, clientOperationId: OPERATION, - expectedRuntimeFence: null, + expectedRuntimeFence: 1, payloadFingerprint: '' }, location: { @@ -68,6 +72,15 @@ function createParams(): AgentSessionAttachParams { async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) const store = await openTestAgentSessionRecordStore(root) + const { location, provider, accountHome } = startParams() + await foundTestAgentSessionRecord(store, { + sessionId: SESSION, + location, + provider, + accountHome, + claimKeyId: 'key-1', + now: NOW + }) const unused = async (): Promise => { throw new Error('not reached before a spawn') } @@ -93,7 +106,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - params: createParams(), + params: startParams(), now: () => NOW, onAttached: () => {} } @@ -118,7 +131,7 @@ function thrownWith( return { reason, needsUser: true } } -describe('a create that fails before any process spawns', () => { +describe('a first start that fails before any process spawns', () => { it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ [ 'the managed account env override', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index c45ceac238c8..5618336923ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionPreSpawnError, type StructuredAgentSessionAdapter @@ -30,10 +33,8 @@ afterEach(async () => { root = null }) -function attachParams( - operationId = OPERATION, - expectedRuntimeFence: number | null = null -): AgentSessionAttachParams { +/** A start of the record `foundedStore` founds at fence 1. */ +function attachParams(operationId = OPERATION, expectedRuntimeFence = 1): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { sessionId: SESSION, @@ -66,10 +67,30 @@ function attachParams( } } +/** The store with the record a create founds at rest, which every start here reserves. */ +async function foundedStore(directory: string) { + const store = await openTestAgentSessionRecordStore(directory) + const { location, provider, accountHome } = attachParams() + await foundTestAgentSessionRecord(store, { + sessionId: SESSION, + location, + provider, + accountHome, + claimKeyId: 'key-1', + now: NOW + }) + return store +} + +/** The rows this suite's starts wrote; the create writes one of its own. */ +function startRows(store: Awaited>) { + return store.listOperationRows().filter((row) => row.callerKey === 'client-1') +} + describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { @@ -108,7 +129,7 @@ describe('processless structured session reservation', () => { it('refuses a replay when adapter support drifts after durable reservation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -156,7 +177,7 @@ describe('processless structured session reservation', () => { it('releases a new reservation when support drifts before acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -193,10 +214,10 @@ describe('processless structured session reservation', () => { claimStatus: 'released', handoffStage: null, reservedSpawnToken: null, - runtimeFence: 2, + runtimeFence: 3, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) - expect(store.listOperationRows()[0]?.outcome).toMatchObject({ + expect(startRows(store)[0]?.outcome).toMatchObject({ status: 'failed', code: 'structured_agent_session_unsupported' }) @@ -209,7 +230,7 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const adapter = { acquire: vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) @@ -242,11 +263,11 @@ describe('processless structured session reservation', () => { claimStatus: 'released', handoffStage: null, handoffOperationId: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) - expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) + expect(startRows(store)[0]?.outcome).toMatchObject({ status: 'failed' }) const reopened = await openTestAgentSessionRecordStore(root) await reopened.reconcileOnRestart({ @@ -255,14 +276,14 @@ describe('processless structured session reservation', () => { }) expect(reopened.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null }) }) it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const adapter = { acquire: vi .fn() @@ -319,13 +340,13 @@ describe('processless structured session reservation', () => { spawnToken: 'spawn-b', handoffOperationId: NEXT_OPERATION }, - params: attachParams(NEXT_OPERATION, 2) + params: attachParams(NEXT_OPERATION, 3) }) ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 3, + runtimeFence: 4, ownerProcess: { spawnToken: 'spawn-b' } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 80035726d3cb..ea58dc3f3413 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -40,6 +40,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -124,7 +125,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) await host.close(SESSION, 'evict') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index de2951a6292a..7e9da12640e0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -16,6 +16,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CLAUDE_SESSION = 'claude-session' @@ -82,7 +83,8 @@ describe('structured session provider restore', () => { resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) - const attached = await host.attach( + const attached = await attachForTests( + host, { callerKey: 'client-1' }, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 2922c3fdb2ec..5b61e432494f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -110,7 +111,7 @@ function lastPhase(): string | undefined { describe('a publish-first Claude create whose init is slow', () => { it('never persists the catalog default, and persists the reported model once started', async () => { - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) + await expect(attachForTests(host, CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) // Published, not yet answering: the record holds no model rather than a guessed one. expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() @@ -126,7 +127,7 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the saved model as intent while starting, then confirms what the child runs', async () => { const params = claudeParams() await expect( - host.attach(CALLER, { ...params, options: { model: 'opus' } }) + attachForTests(host, CALLER, { ...params, options: { model: 'opus' } }) ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -139,14 +140,14 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() - await host.attach(CALLER, { ...params, options: { model: 'opus' } }) + await attachForTests(host, CALLER, { ...params, options: { model: 'opus' } }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 // Starting the chat again resumes the session under a new fence. - await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index 26eaa8b8ab59..e6c23f512157 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -49,9 +49,10 @@ describe('the one queue gate', () => { } const draftId = queued.value.queued.messageId // An unfinished /clear changed nothing the chat reads, so it refuses no send. - await store.setConversationCommand(SESSION, 1, { + const fence = store.getRecord(SESSION)!.lease.runtimeFence + await store.setConversationCommand(SESSION, fence, { command: 'clear', - runtimeFence: 1, + runtimeFence: fence, operationId: hostTestOperationId(), callerKey: CALLER.callerKey, phase: 'prepared', @@ -69,6 +70,7 @@ describe('the one queue gate', () => { } const draftId = queued.value.queued.messageId const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const fence = store.getRecord(SESSION)!.lease.runtimeFence await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, { @@ -78,7 +80,7 @@ describe('the one queue gate', () => { options: [], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await sendNow(draftId)).toMatchObject({ ok: false, @@ -86,9 +88,7 @@ describe('the one queue gate', () => { }) // Read in place: the gate runs on every admission and drain step. const snapshot = vi.spyOn(journal, 'snapshot') - expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe( - 'prompt' - ) + expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence })).toBe('prompt') expect(snapshot).not.toHaveBeenCalled() snapshot.mockRestore() await journal.appendItem( @@ -105,7 +105,7 @@ describe('the one queue gate', () => { resolvedAt: 1 } }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // The turn still runs (`working`), which Send-now alone may override. expect(await submission(working)).toMatchObject({ dispatchState: 'pending' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f94d091a5f97..3e3bd7f61bcb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -116,7 +117,7 @@ export async function createQueuedMessageTestRig( ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}) }) let host = makeHost() - expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ + expect(await attachForTests(host, QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 8a7c5c4c3d36..de7724286deb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -717,9 +717,10 @@ describe('/clear', () => { throw new Error('expected a queued receipt') } const draftId = queued.value.queued.messageId - await store.setConversationCommand(SESSION, 1, { + const fence = store.getRecord(SESSION)!.lease.runtimeFence + await store.setConversationCommand(SESSION, fence, { command: 'clear', - runtimeFence: 1, + runtimeFence: fence, operationId: hostTestOperationId(), callerKey: CALLER.callerKey, phase: 'prepared', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 10799cd451f4..65f0eea24cfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -194,6 +194,7 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + reviewReply?: unknown } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -201,7 +202,12 @@ export async function maybeQueueStructuredAgentSessionSend( | null > { const clientMessageId = params.envelope.clientOperationId - if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + // A draft carries no review reply, so a send with one is recorded as a message now. + if ( + params.delivery !== 'queue-if-active' || + params.reviewReply !== undefined || + !queuedMessageBodyIsTextOnly(params.body) + ) { return null } // Asked again with no ledger answer: a send this host queued answers as its replay would — diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index d411efc42395..1c57b00b7312 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -15,6 +15,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + reviewReply?: unknown userSend?: true }, immediate: () => Promise> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 228fa21fbc24..e04dbbda4597 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -22,6 +22,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -63,10 +64,9 @@ async function stopOwner(child: ReturnType): Promise spawnedOwners.delete(child) } -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +/** What a send's delivery does: the start at the record's current fence. */ async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect((await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) + expect((await startAgentForTests(host, SESSION)).ok).toBe(true) } function adapter(): StructuredAgentSessionAdapter { @@ -131,16 +131,20 @@ afterEach(async () => { describe('recovery exits', () => { it('releases an ownerless unproven acquisition, so the next start goes ahead', async () => { acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) - await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( - 'agent_session_acquisition_exit_unproven' - ) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'agent_session_acquisition_exit_unproven' + } + }) // No owner was recorded, and the adapter closed the stdio of anything it spawned. expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, handoffOperationId: null, ownerProcess: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, deathEvidence: null }) @@ -148,14 +152,14 @@ describe('recovery exits', () => { await reopenStore() openHost({ mintSpawnToken: () => 'spawn-b' }) - expect(await host.attach(CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) }) it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { // A real session first, so restart restore has a journal to read and runs the // handoff restorer over the residue instead of skipping the record. - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() // The resume fails at owner proof and cleanup cannot prove exit: the settlement @@ -165,15 +169,19 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await expect(host.attach(CALLER, hostTestAttachParams(2))).rejects.toThrow( - 'agent_session_acquisition_exit_unproven' - ) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'agent_session_acquisition_exit_unproven' + } + }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'reserved', handoffStage: 'recovering', handoffOperationId: null, ownerProcess: { spawnToken: 'spawn-b' }, - runtimeFence: 3, + runtimeFence: 4, reservedSpawnToken: 'spawn-b' }) @@ -194,7 +202,7 @@ describe('recovery exits', () => { handoffOperationId: null, ownerProcess: null, reservedSpawnToken: null, - runtimeFence: 4 + runtimeFence: 5 }) // The ordinary native recovery path remains: the next start resumes it. @@ -203,13 +211,13 @@ describe('recovery exits', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 5, + runtimeFence: 6, ownerProcess: { spawnToken: 'spawn-c' } }) }) it('stops a surviving native child after restart instead of readopting its dead transport', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() let orphanAlive = true @@ -225,25 +233,21 @@ describe('recovery exits', () => { stopOwnerProcess }) - const stale = await host.attach(CALLER, hostTestAttachParams(1)) - expect(stale).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale', currentFence: 2 } - }) - expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') - const retried = await host.attach(CALLER, hostTestAttachParams(2)) + // The start reconciles the lease first: the orphan is stopped, then a fresh child is spawned. + const retried = await startAgentForTests(host, SESSION) expect(retried).toMatchObject({ ok: true }) + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') // A fresh child was spawned; the orphan pid's lease did not survive as the owner. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 4, claimStatus: 'live', handoffStage: null }) }) it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() let orphanAlive = true @@ -275,7 +279,7 @@ describe('recovery exits', () => { expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 4, claimStatus: 'live', handoffStage: null, ownerProcess: { spawnToken: 'spawn-b' } @@ -290,11 +294,11 @@ describe('recovery exits', () => { linkId: 'link-outgoing', handle: { provider: 'codex', threadId: THREAD }, origin: 'created', - mintedAtFence: 1, + mintedAtFence: 2, observedAt: NOW } }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) const outgoingHost = host const outgoingStore = store @@ -310,7 +314,7 @@ describe('recovery exits', () => { overlapDriven = true await outgoingStore.renewLease({ sessionId: SESSION, - fence: 1, + fence: 2, childProbe: probe, now: NOW + 1 }) @@ -322,7 +326,7 @@ describe('recovery exits', () => { await host.restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', ownerProcess: null }) @@ -335,7 +339,7 @@ describe('recovery exits', () => { linkId: 'link-replacement', handle: { provider: 'codex', threadId: THREAD }, origin: 'resumed', - mintedAtFence: 3, + mintedAtFence: 4, observedAt: NOW + 2 } }) @@ -343,7 +347,7 @@ describe('recovery exits', () => { await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 4, claimStatus: 'live', handoffStage: null, ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 8e3be9d1f3d7..66a2c367db64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -9,7 +9,10 @@ import { writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { supervisedPosixLaunch } from '../../codex/codex-app-server-posix-supervisor' import { resolveStructuredSessionRecovery, @@ -38,7 +41,7 @@ async function openStore(directory?: string): Promise { async function reserve(store: AgentSessionRecordStore) { operations += 1 - return store.reserveOwner({ + return foundAndReserveTestAgentSessionRecord(store, { sessionId: SESSION, location: { executionHostId: 'local', @@ -48,7 +51,6 @@ async function reserve(store: AgentSessionRecordStore) { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, - expectedFence: null, spawnToken: 'spawn-recovery', claimKeyId: 'key-1', handoffOperationId: null, @@ -131,7 +133,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, deathEvidence: null }) @@ -153,7 +155,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, ownerProcess: null }) }) @@ -185,7 +187,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2 + runtimeFence: 3 }) }) @@ -211,7 +213,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, ownerProcess: null, deathEvidence: null }) @@ -236,7 +238,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, ownerProcess: null, deathEvidence: null }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index d5899c794e3c..85a1aeaced10 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -29,6 +29,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -100,7 +101,9 @@ async function createHarness(options: { attached?: boolean } = {}) { const harness = { root, store, host, setOption } harnesses.push(harness) if (options.attached !== false) { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) } return harness } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index a16ef1101a2f..ccffd21e2996 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -52,7 +53,9 @@ beforeEach(async () => { link: { linkId: `link-${fence}`, handle: { provider: 'codex' as const, threadId: THREAD }, - origin: fence > 1 ? ('resumed' as const) : ('created' as const), + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), mintedAtFence: fence, observedAt: NOW } @@ -72,7 +75,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { @@ -200,8 +203,9 @@ describe('a press sent again', () => { expect(refused.ok ? null : agentSessionRefusalOperationState(refused.refusal.code)).toBe( 'pending-admission' ) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: true + }) expect(await stop(store.getRecord(SESSION)!.lease.runtimeFence)).toMatchObject({ ok: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index c0cc13125ca4..481dc81e8073 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -123,7 +123,7 @@ describe('a Stop pressed again', () => { }) replaceHostTestState({ store: relaunchedStore, host: relaunched }) host = relaunched - await startAgent({ host, store: relaunchedStore }) + await startAgent({ host }) cancelTurn.mockResolvedValueOnce({ cancelled: false }) expect(await stopTurn()).toMatchObject({ ok: true, value: { cancelled: false } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0045684ccb37..3ad56c667f27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' export const REST_TEST_CALLER = { callerKey: 'client-1' } @@ -207,7 +208,7 @@ export async function createRestTestRig( /** Creates the chat, lists its tab, and sends one message so its journal is on disk. */ export async function foundRestTestChat(rig: RestTestRig): Promise { - const attached = await rig.host.attach(REST_TEST_CALLER, hostTestAttachParams(null)) + const attached = await attachForTests(rig.host, REST_TEST_CALLER, hostTestAttachParams(null)) if (!attached.ok) { throw new Error(`attach refused: ${attached.refusal.code}`) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 402a5fc91605..eff4585b2e7a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -10,7 +10,6 @@ import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE } from '../../runtime/agent-session-recovery-capsule' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -31,22 +30,16 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, - hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { startAgentForTests } from './structured-agent-session-attach-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -/** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a - * running child before its next step. Nothing else starts one ahead of a send. */ -export async function startAgent(state: { - host: StructuredAgentSessionHost - store: AgentSessionRecordStore -}): Promise { - const result = await state.host.attach( - CALLER, - hostTestAttachParams(state.store.getRecord(SESSION)?.lease.runtimeFence ?? null) - ) +/** Starts the agent as a queued message's delivery does, for a test that needs a running child + * before its next step. Nothing else starts one ahead of a send. */ +export async function startAgent(state: { host: StructuredAgentSessionHost }): Promise { + const result = await startAgentForTests(state.host, SESSION) expect(result.ok).toBe(true) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 6cde8d87b842..0d262023318d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -96,7 +97,7 @@ async function restartWithPersistedTurn(): Promise { resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) if (!sent.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-retry-in-place.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-retry-in-place.test.ts index 53f43c351ebc..a87c3ef52fd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-retry-in-place.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-retry-in-place.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const PHONE = { callerKey: 'phone-1' } @@ -102,7 +103,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) startHost() await expect( - host.attach(CALLER, hostTestAttachParams(null, { providerHandle: undefined })) + attachForTests(host, CALLER, hostTestAttachParams(null, { providerHandle: undefined })) ).resolves.toMatchObject({ ok: true }) // The chat is put to rest, so each message starts the agent. await host.close(SESSION, 'evict') @@ -241,9 +242,7 @@ describe('a Retry of a message whose start failed for good', () => { // By then the chat was put to rest, Orca restarted, or the chat was closed while it waited. describe('a Retry after the chat it failed in was closed or reopened', () => { async function reopen(): Promise { - await expect( - host.attach(CALLER, hostTestAttachParams(fence(), { providerHandle: undefined })) - ).resolves.toMatchObject({ ok: true }) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) } it('delivers it after the idle sweep put the chat to rest', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts new file mode 100644 index 000000000000..53d4a30f0f92 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -0,0 +1,619 @@ +// A launch prompt's review reply (resolve the threads, post Orca's "fixing" replies) runs once the +// agent takes the message: never before, never for a message no agent took, and once across a +// restart, derived from the chat itself. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' +import type { PRComment } from '../../../shared/github/comment-types' +import type { ReviewReplyPosts } from '../../github/client/fetch/review-reply-posts' +import { + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from '../../../shared/protocol-version' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' +import { + REVIEW_REPLY_WINDOW_MS, + reviewReplyReceiptIdentity +} from './structured-agent-session-review-replies' +import { + REVIEW_REPLY_CLOCK_MARGIN_MS, + type StructuredAgentSessionReviewRuntime +} from './structured-agent-session-review-reply-runner' + +const CALLER = { callerKey: 'client-1' } +const FIXING = 'Fixing. Will be in the next commit' +const GITHUB: AgentSessionReviewReply = { + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + prRepo: { owner: 'acme', repo: 'app' }, + resolve: ['thread-resolvable'], + replies: [{ commentId: 7, threadId: 'thread-7', path: 'src/a.ts', line: 3 }], + replyBody: FIXING, + conversationReply: 'Fixing the review summary.' +} + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let beforeSpawn = vi.fn<() => Promise>() +let dispatch = vi.fn() +let warnings: string[] = [] +let onPR: ReviewReplyPosts = { viewerLogin: 'me', threads: {}, conversation: [] } +let review: { + [Method in keyof StructuredAgentSessionReviewRuntime]: ReturnType< + typeof vi.fn + > +} + +function reviewRuntime() { + return { + resolveRepoReviewThread: vi.fn( + async () => true + ), + resolveGitLabRepoMRDiscussion: vi.fn< + StructuredAgentSessionReviewRuntime['resolveGitLabRepoMRDiscussion'] + >(async () => ({ ok: true as const })), + addRepoPRReviewCommentReply: vi.fn< + StructuredAgentSessionReviewRuntime['addRepoPRReviewCommentReply'] + >(async () => ({ ok: true as const, comment: postedComment(FIXING) })), + addRepoIssueComment: vi.fn( + async () => ({ ok: true as const, comment: postedComment('summary') }) + ), + getRepoReviewReplyPosts: vi.fn( + async () => onPR + ), + reviewWritten: vi.fn(async () => {}) + } +} + +function postedComment(body: string, extra: Partial = {}): PRComment { + return { + id: 900, + author: 'me', + authorAvatarUrl: '', + body, + createdAt: new Date().toISOString(), + url: 'https://github.com/acme/app/pull/42#discussion_r900', + ...extra + } +} + +function startHost(): void { + host = new StructuredAgentSessionHost({ + logger: { warn: (message) => warnings.push(message), error: vi.fn() }, + store, + adapter: { + acquire: vi.fn(async ({ fence, spawnToken }) => { + await beforeSpawn() + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${fence}` + } + }), + releaseAcquisition: vi.fn(async () => true), + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${beforeSpawn.mock.calls.length + 1}`, + now: () => NOW, + setStartRetryTimer: () => () => {}, + reviewRuntime: review + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-review-replies-')) + resetHostTestOperationIds() + onPR = { viewerLogin: 'me', threads: {}, conversation: [] } + warnings = [] + review = reviewRuntime() + beforeSpawn = vi.fn(async () => undefined) + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + store = await openTestAgentSessionRecordStore(root) + startHost() + await expect( + attachForTests(host, CALLER, hostTestAttachParams(null, { providerHandle: undefined })) + ).resolves.toMatchObject({ ok: true }) + // At rest, so each message starts the agent, as a new chat's first one does. + await host.close(SESSION, 'evict') + beforeSpawn.mockClear() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** Lets whatever the last commits set off run, short of a quit. */ +async function settled(): Promise { + await host.flushStreamedEvents(SESSION) + await new Promise((resolve) => setTimeout(resolve, 20)) +} + +function fence(): number { + return store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +function sendParams(text: string, reviewReply: AgentSessionReviewReply, clientOperationId: string) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body, reviewReply } + }) + }, + body, + reviewReply, + userSend: true as const + } +} + +async function launchPrompt(reviewReply: AgentSessionReviewReply = GITHUB): Promise { + const sent = await host.send( + CALLER, + sendParams('resolve these', reviewReply, hostTestOperationId()) + ) + expect(sent).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function handedOver(clientMessageId: string): Promise { + await eventually(async () => + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toContain(clientMessageId) + ) +} + +function accept(clientMessageId: string) { + return host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) +} + +function writes(): number { + return ( + review.resolveRepoReviewThread.mock.calls.length + + review.resolveGitLabRepoMRDiscussion.mock.calls.length + + review.addRepoPRReviewCommentReply.mock.calls.length + + review.addRepoIssueComment.mock.calls.length + ) +} + +/** Whether the chat holds the message's receipt, and the line it shows if any. */ +async function receipt(clientMessageId: string) { + const itemId = agentJournalItemKey(reviewReplyReceiptIdentity(clientMessageId)) + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + const line = (await host.journalSnapshot(SESSION)).items.find((item) => item.itemId === itemId) + return { written: journal?.itemWritten(itemId) ?? false, line: line?.body } +} + +async function receiptItem(clientMessageId: string) { + const itemId = agentJournalItemKey(reviewReplyReceiptIdentity(clientMessageId)) + return (await host.journalSnapshot(SESSION)).items.find((item) => item.itemId === itemId) +} + +async function settledReceipt(clientMessageId: string) { + await eventually(async () => expect((await receipt(clientMessageId)).written).toBe(true)) + return receipt(clientMessageId) +} + +/** The next start waits for the test. */ +function heldStart(): { release: () => void } { + const held = Promise.withResolvers() + beforeSpawn.mockImplementationOnce(() => held.promise) + return { release: () => held.resolve() } +} + +function notInstalled(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error('codex not installed'), { + reason: 'providerMissing', + needsUser: true + }) +} + +function retry(clientMessageId: string) { + return host.retryMessage(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.retryMessage', + sessionId: SESSION, + fields: { clientMessageId } + }) + }, + clientMessageId + }) +} + +describe('a launch prompt with a review reply', () => { + it('writes nothing while the agent has not taken it, then everything once when it does', async () => { + const id = await launchPrompt() + await handedOver(id) + await settled() + expect(writes()).toBe(0) + + await accept(id) + + await settledReceipt(id) + expect(review.resolveRepoReviewThread).toHaveBeenCalledExactlyOnceWith( + 'id:repo-1', + 'thread-resolvable', + true, + GITHUB.prRepo + ) + expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledExactlyOnceWith('id:repo-1', { + prNumber: 42, + commentId: 7, + body: FIXING, + threadId: 'thread-7', + path: 'src/a.ts', + line: 3, + prRepo: GITHUB.prRepo + }) + expect(review.addRepoIssueComment).toHaveBeenCalledExactlyOnceWith( + 'id:repo-1', + 42, + 'Fixing the review summary.', + GITHUB.prRepo + ) + // A live accept needs no read of the PR, and all going through shows nothing in the chat. + expect(review.getRepoReviewReplyPosts).not.toHaveBeenCalled() + expect((await receipt(id)).line).toBeUndefined() + // The checks panel refetches the PR, as after its own writes. + expect(review.reviewWritten).toHaveBeenCalledExactlyOnceWith('id:repo-1', 42) + + // Later commits re-derive nothing. + await launchPrompt({ provider: 'gitlab', repoId: 'repo-1', iid: 1, resolve: [] }) + await settled() + expect(writes()).toBe(3) + }) + + it('writes nothing for a message whose start failed for good, and runs once its Retry is taken', async () => { + beforeSpawn.mockRejectedValueOnce(notInstalled()) + const id = await launchPrompt() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + await settled() + expect(writes()).toBe(0) + + await expect(retry(id)).resolves.toMatchObject({ ok: true }) + await handedOver(id) + expect(writes()).toBe(0) + await accept(id) + + await settledReceipt(id) + expect(writes()).toBe(3) + }) + + it('writes nothing for a message a Stop withdrew, or one the chat closed on', async () => { + const start = heldStart() + const stopped = await launchPrompt() + const stop = host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: {} + }) + } + }) + start.release() + await stop + await eventually(async () => + expect((await submission(stopped))?.dispatchState).toBe('rejected') + ) + + await host.close(SESSION, 'evict') + const closing = heldStart() + const closed = await launchPrompt() + const close = host.close(SESSION, 'user-close') + closing.release() + await close + await host.journalSnapshot(SESSION) + await eventually(async () => expect((await submission(closed))?.dispatchState).toBe('rejected')) + await settled() + expect(writes()).toBe(0) + }) + + it('says why on its own line, once, when a write fails, and a reopen changes nothing', async () => { + review.addRepoPRReviewCommentReply.mockResolvedValueOnce({ ok: false, error: 'HTTP 401' }) + const id = await launchPrompt() + await handedOver(id) + // The agent's turn is running when the writes fail, as it is once the agent took the message. + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + await journal?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1 }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await accept(id) + + const { line } = await settledReceipt(id) + expect(line).toMatchObject({ + kind: 'status', + text: "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself.", + failure: { kind: 'reviewReplyFailed', detail: { text: 'HTTP 401', audience: 'log' } }, + // Shown as a failure, and outside the agent's turn, so the turn folding never hides it. + tone: 'error' + }) + expect((await receiptItem(id))?.turnScope).toEqual({ kind: 'thread' }) + // The rest still went through. + expect(writes()).toBe(3) + + await host.flushAllStreamedEvents() + startHost() + await host.journalSnapshot(SESSION) + await settled() + expect(writes()).toBe(3) + }) + + it('resolves GitLab discussions, and posts nothing', async () => { + const id = await launchPrompt({ + provider: 'gitlab', + repoId: 'repo-2', + iid: 8, + resolve: ['discussion-1', 'discussion-2'] + }) + await handedOver(id) + await accept(id) + + await settledReceipt(id) + expect(review.resolveGitLabRepoMRDiscussion.mock.calls).toEqual([ + ['id:repo-2', 8, 'discussion-1', true], + ['id:repo-2', 8, 'discussion-2', true] + ]) + expect(writes()).toBe(2) + expect(review.reviewWritten).not.toHaveBeenCalled() + }) + + it('tells no PR view to refetch when nothing was written', async () => { + review.resolveRepoReviewThread.mockResolvedValue(false) + review.addRepoPRReviewCommentReply.mockResolvedValue({ ok: false, error: 'HTTP 401' }) + review.addRepoIssueComment.mockResolvedValue({ ok: false, error: 'HTTP 401' }) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + + await settledReceipt(id) + expect(writes()).toBe(3) + expect(review.reviewWritten).not.toHaveBeenCalled() + }) +}) + +describe("a review reply's receipt", () => { + it("is written in the chat's own lane, after the work already in it", async () => { + const writesDone = Promise.withResolvers() + review.addRepoIssueComment.mockImplementationOnce(async () => { + await writesDone.promise + return { ok: true, comment: postedComment('summary') } + }) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalled()) + const lane = Promise.withResolvers() + const occupied = host.collaboratorsForTests().serialize(SESSION, () => lane.promise) + + writesDone.resolve() + await settled() + expect((await receipt(id)).written).toBe(false) + + lane.resolve() + await occupied + await settledReceipt(id) + }) +}) + +describe('a review reply cut off by a restart', () => { + /** Accepted, with Orca dying while the writes run: no receipt reaches the chat. */ + let acceptedAt = 0 + + async function acceptedThenCrashed(): Promise { + review.addRepoIssueComment.mockImplementationOnce(() => new Promise(() => {})) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalled()) + acceptedAt = (await submission(id))?.resolvedAt ?? 0 + await host.flushAllStreamedEvents() + startHost() + review.resolveRepoReviewThread.mockClear() + review.addRepoPRReviewCommentReply.mockClear() + review.addRepoIssueComment.mockClear() + return id + } + + /** A post on thread-7 by `author`, `secondsAfter` the agent took the message. */ + function onThread(author: string, secondsAfter: number): ReviewReplyPosts['threads'] { + const at = new Date(acceptedAt + secondsAfter * 1000).toISOString() + return { 'thread-7': [{ author, body: FIXING, createdAt: at }] } + } + + it('posts only what the PR does not show yet, once the chat opens again', async () => { + const id = await acceptedThenCrashed() + onPR = { viewerLogin: 'me', threads: onThread('me', 1), conversation: [] } + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.getRepoReviewReplyPosts).toHaveBeenCalledExactlyOnceWith('id:repo-1', { + prNumber: 42, + prRepo: GITHUB.prRepo, + threadIds: ['thread-7'], + since: new Date(acceptedAt - REVIEW_REPLY_CLOCK_MARGIN_MS).toISOString() + }) + expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() + expect(review.addRepoIssueComment).toHaveBeenCalledOnce() + expect(review.resolveRepoReviewThread).toHaveBeenCalledOnce() + }) + + it("counts this account's post from a host clock running ahead of GitHub's", async () => { + const id = await acceptedThenCrashed() + onPR = { viewerLogin: 'me', threads: onThread('me', -30), conversation: [] } + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() + }) + + it('replies where only another account posted the same words since', async () => { + const id = await acceptedThenCrashed() + onPR = { viewerLogin: 'me', threads: onThread('teammate', 1), conversation: [] } + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledOnce() + }) + + it("matches the words alone when GitHub doesn't say which account it is", async () => { + const id = await acceptedThenCrashed() + onPR = { viewerLogin: null, threads: onThread('teammate', 1), conversation: [] } + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() + expect(review.addRepoIssueComment).toHaveBeenCalledOnce() + }) + + it('posts everything once more when the read fails, and says so', async () => { + const id = await acceptedThenCrashed() + review.getRepoReviewReplyPosts.mockRejectedValueOnce(new Error('HTTP 502')) + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledOnce() + expect(review.addRepoIssueComment).toHaveBeenCalledOnce() + expect(warnings).toContain( + 'review reply: could not read the PR before replying; a reply may repeat' + ) + }) + + it('posts nothing once the window since the agent took it has passed', async () => { + await acceptedThenCrashed() + // The journal stamps its rows, and reads the window, on the wall clock. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(Date.now() + REVIEW_REPLY_WINDOW_MS + 1) + try { + await host.journalSnapshot(SESSION) + await settled() + + expect(writes()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('what a send may carry', () => { + it('is recorded as a message, never held as a draft, while the agent works', async () => { + const working = await launchPrompt({ + provider: 'gitlab', + repoId: 'repo-1', + iid: 1, + resolve: [] + }) + await handedOver(working) + const params = sendParams('resolve these too', GITHUB, hostTestOperationId()) + const fields = { body: params.body, delivery: 'queue-if-active', reviewReply: GITHUB } + const sent = await host.send(CALLER, { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + }, + delivery: 'queue-if-active' + }) + + expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + }) + + it('is part of the send: the same id with another review reply is refused', async () => { + const operation = hostTestOperationId() + await expect(host.send(CALLER, sendParams('x', GITHUB, operation))).resolves.toMatchObject({ + ok: true + }) + const other = { ...GITHUB, resolve: ['another-thread'] } + await expect(host.send(CALLER, sendParams('x', other, operation))).resolves.toMatchObject({ + ok: false + }) + }) +}) + +describe('who learns a host runs review replies', () => { + it('is every client: the host says so, so a client attaches one only where it runs', () => { + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts new file mode 100644 index 000000000000..4e9cedbd34d6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -0,0 +1,178 @@ +// A launch prompt's review reply runs once the agent takes the message. What is owed is derived, +// never stored: a message carrying a review reply, accepted within the window, whose chat has no +// receipt for it. The receipt is an item in the chat under the message's own id: a status line when +// a write failed, a tombstone (nothing shown) when all went through. + +import { + AGENT_SESSION_REVIEW_REPLY_WINDOW_MS, + agentSessionReviewReplyReceiptMessageId, + type AgentSessionReviewReply +} from '../../../shared/agent-session-review-reply' +import { agentSessionFailureFact, providerDiagnostic } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { runStructuredAgentSessionReviewReply } from './structured-agent-session-review-reply-runner' + +export const REVIEW_REPLY_WINDOW_MS = AGENT_SESSION_REVIEW_REPLY_WINDOW_MS + +export function reviewReplyReceiptIdentity(clientMessageId: string): AgentJournalItemIdentity { + return { + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId(clientMessageId) + } +} + +/** The receipt: the failure said once on its own line, or a tombstone that shows nothing. */ +export async function writeStructuredAgentSessionReviewReplyReceipt( + journal: AgentSessionJournal, + fence: number, + clientMessageId: string, + failure: string | null +): Promise { + const identity = reviewReplyReceiptIdentity(clientMessageId) + if (failure === null) { + await journal.appendTombstone(identity, { fence }) + return + } + const detail = providerDiagnostic(failure, 'log') + await journal.appendItem( + identity, + { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('reviewReplyFailed', detail ? { detail } : {}), + { surface: 'row' } + ), + tone: 'error' + }, + // About the review, not the agent's turn: a turn's rows fold once it ends, and the agent's + // answer would fold this line with them. + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +export type StructuredAgentSessionReviewRepliesDeps = { + /** The writes; answers the first failure's words, or null when all went through. */ + run: ( + spec: AgentSessionReviewReply, + options: { acceptedAt: number; reread: boolean } + ) => Promise + /** Records the receipt in the chat; null `failure` hides it. */ + writeReceipt: ( + sessionId: string, + clientMessageId: string, + failure: string | null + ) => Promise + log: (message: string, error: unknown) => void +} + +export class StructuredAgentSessionReviewReplies { + /** Runs in flight or done in this process: a cache over the derived rule, never its truth. */ + private readonly claimed = new Set() + /** Messages seen waiting for the agent in this process: their run follows a live accept. */ + private readonly seenWaiting = new Set() + + constructor(private readonly deps: StructuredAgentSessionReviewRepliesDeps) {} + + /** Called on every journal publish and once as a journal opens. */ + observe(sessionId: string, journal: AgentSessionJournal): void { + for (const [clientMessageId, spec] of journal.reviewReplies()) { + const key = `${sessionId}:${clientMessageId}` + const submission = journal.submission(clientMessageId) + if (!submission || this.claimed.has(key)) { + continue + } + if (submission.dispatchState !== 'accepted') { + if (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') { + this.seenWaiting.add(key) + } + continue + } + const acceptedAt = submission.resolvedAt + if ( + acceptedAt === null || + journal.clock() - acceptedAt > REVIEW_REPLY_WINDOW_MS || + journal.itemWritten(agentJournalItemKey(reviewReplyReceiptIdentity(clientMessageId))) + ) { + continue + } + this.claimed.add(key) + // Accepted before this process saw it wait: an earlier run may have posted before its receipt. + const reread = !this.seenWaiting.delete(key) + void this.settle(sessionId, clientMessageId, spec, { acceptedAt, reread }) + } + } + + private async settle( + sessionId: string, + clientMessageId: string, + spec: AgentSessionReviewReply, + options: { acceptedAt: number; reread: boolean } + ): Promise { + let failure: string | null + try { + failure = await this.deps.run(spec, options) + } catch (error) { + this.deps.log('review reply: the run failed', error) + failure = error instanceof Error ? error.message : String(error) + } + // A receipt the chat refuses (closed, read-only) is logged. This process keeps its claim, so the + // reply is derived again only after Orca restarts, inside the window, re-reading the PR first. + await this.deps + .writeReceipt(sessionId, clientMessageId, failure) + .catch((error: unknown) => this.deps.log('review reply: the receipt was not written', error)) + } +} + +/** The host's review replies: writes through its runtime; each receipt in the chat's own lane, at + * the fence it holds then, so no start or close moves the fence under the append. */ +export function createStructuredAgentSessionHostReviewReplies(host: { + deps: () => Pick + journal: (sessionId: string) => AgentSessionJournal | undefined + serialize: (sessionId: string, task: () => Promise) => Promise +}): { observe: (sessionId: string) => void } { + const log = (message: string, error: unknown): void => + host.deps().logger.warn(message, { scope: 'review-reply', error }) + const replies = new StructuredAgentSessionReviewReplies({ + run: async (spec, options) => { + const runtime = host.deps().reviewRuntime + if (!runtime) { + throw new Error('This host cannot write to the review.') + } + return runStructuredAgentSessionReviewReply(runtime, spec, { ...options, log }) + }, + // Queued behind the lane's work, never awaited from inside it: the run was started off a + // publish, so a task in the lane never waits on this. + writeReceipt: (sessionId, clientMessageId, failure) => + host.serialize(sessionId, async () => { + const journal = host.journal(sessionId) + if (!journal) { + throw new Error(`chat ${sessionId} is closed`) + } + const fence = structuredAgentSessionConversationFence(host.deps().store, sessionId) + await writeStructuredAgentSessionReviewReplyReceipt( + journal, + fence, + clientMessageId, + failure + ) + }), + log + }) + return { + // A host with no review surface owes nothing, so it never writes a receipt either. + observe: (sessionId) => { + const journal = host.journal(sessionId) + if (journal && host.deps().reviewRuntime) { + replies.observe(sessionId, journal) + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts new file mode 100644 index 000000000000..2697c4accef7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts @@ -0,0 +1,195 @@ +// The review writes a message's review reply asks for, through the runtime's repo-scoped GitHub and +// GitLab methods: the ones the review RPCs call. Resolves are idempotent; a reply is not, so a run +// that may repeat one already posted (`reread`) first reads the reply threads, newest first, and the +// conversation since, and skips what this account already posted there. + +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' +import type { + ReviewReplyPost, + ReviewReplyPosts +} from '../../github/client/fetch/review-reply-posts' +import type { RuntimeReviewCommandSurface } from '../../runtime/runtime-review-command-surface' +import type { RuntimeGitHubReviewQueryCommands } from '../../runtime/runtime-github-review-query-commands' + +export type StructuredAgentSessionReviewRuntime = Pick< + RuntimeReviewCommandSurface, + | 'resolveRepoReviewThread' + | 'resolveGitLabRepoMRDiscussion' + | 'addRepoPRReviewCommentReply' + | 'addRepoIssueComment' +> & + Pick & { + /** Tells this machine's views of the PR to refetch, as the desktop's own writes do. */ + reviewWritten: (repoSelector: string, prNumber: number) => Promise + } + +/** How far before acceptance a post still counts as this message's: the host's clock and + * GitHub's can disagree by seconds. */ +export const REVIEW_REPLY_CLOCK_MARGIN_MS = 60_000 + +/** The checks panel's ceiling: the shared GitHub client keeps four calls in flight. */ +const REVIEW_REPLY_CONCURRENCY = 4 + +type ReviewWrite = () => Promise + +/** Runs every write; answers the first failure's words, or null when all went through. */ +export async function runStructuredAgentSessionReviewReply( + runtime: StructuredAgentSessionReviewRuntime, + spec: AgentSessionReviewReply, + options: { + /** When the agent took the message: a reply posted since is this message's. */ + acceptedAt: number + /** A run that may follow an earlier one cut off before its receipt. */ + reread: boolean + log: (message: string, error?: unknown) => void + } +): Promise { + const repo = `id:${spec.repoId}` + const writes: ReviewWrite[] = + spec.provider === 'gitlab' + ? spec.resolve.map((discussionId) => async () => { + const resolved = await runtime.resolveGitLabRepoMRDiscussion( + repo, + spec.iid, + discussionId, + true + ) + return resolved.ok ? null : resolved.error + }) + : await gitHubWrites(runtime, repo, spec, options) + const failures = await runBounded(writes, options.log) + // GitLab views already poll; a GitHub PR view refetches only when told. + if (spec.provider === 'github' && failures.length < writes.length) { + await runtime + .reviewWritten(repo, spec.prNumber) + .catch((error: unknown) => options.log('review reply: the PR view was not told', error)) + } + return failures[0] ?? null +} + +async function gitHubWrites( + runtime: StructuredAgentSessionReviewRuntime, + repo: string, + spec: Extract, + options: { + acceptedAt: number + reread: boolean + log: (message: string, error?: unknown) => void + } +): Promise { + const posted = options.reread ? await postedSince(runtime, repo, spec, options) : null + // A reply with no thread to read, or with no read at all, is posted: at worst once more. Only a + // failed read is logged; a reply with no thread id is rare (path-only review comments). + const alreadyPosted = (body: string, at: { threadId?: string } | null): boolean => { + if (!posted) { + return false + } + const comments = at === null ? posted.conversation : at.threadId && posted.threads[at.threadId] + return (comments || []).some((comment) => comment.body.trim() === body.trim()) + } + const prRepo = spec.prRepo ?? null + const conversationReply = spec.conversationReply + return [ + ...spec.resolve.map( + (threadId) => async () => + (await runtime.resolveRepoReviewThread(repo, threadId, true, prRepo)) + ? null + : 'Could not resolve the review thread.' + ), + ...spec.replies + .filter((reply) => !alreadyPosted(spec.replyBody, reply)) + .map((reply) => async () => { + const result = await runtime.addRepoPRReviewCommentReply(repo, { + prNumber: spec.prNumber, + commentId: reply.commentId, + body: spec.replyBody, + ...(reply.threadId ? { threadId: reply.threadId } : {}), + ...(reply.path ? { path: reply.path } : {}), + ...(reply.line !== undefined ? { line: reply.line } : {}), + prRepo + }) + return result.ok ? null : result.error + }), + ...(conversationReply && !alreadyPosted(conversationReply, null) + ? [ + async () => { + const result = await runtime.addRepoIssueComment( + repo, + spec.prNumber, + conversationReply, + prRepo + ) + return result.ok ? null : result.error + } + ] + : []) + ] +} + +/** What this account posted since shortly before the agent took the message, read from GitHub. A + * failed read answers null; an unknown account matches any author. Both are reported. */ +async function postedSince( + runtime: StructuredAgentSessionReviewRuntime, + repo: string, + spec: Extract, + options: { acceptedAt: number; log: (message: string, error?: unknown) => void } +): Promise { + if (spec.replies.length === 0 && !spec.conversationReply) { + return null + } + const since = options.acceptedAt - REVIEW_REPLY_CLOCK_MARGIN_MS + let read: ReviewReplyPosts + try { + read = await runtime.getRepoReviewReplyPosts(repo, { + prNumber: spec.prNumber, + prRepo: spec.prRepo ?? null, + threadIds: spec.replies.flatMap((reply) => (reply.threadId ? [reply.threadId] : [])), + since: new Date(since).toISOString() + }) + } catch (error) { + options.log('review reply: could not read the PR before replying; a reply may repeat', error) + return null + } + const login = read.viewerLogin + if (login === null) { + options.log('review reply: the account is unknown, so a reply is matched by its text', null) + } + const mine = (comments: readonly ReviewReplyPost[]) => + comments.filter( + (comment) => + Date.parse(comment.createdAt) >= since && (login === null || comment.author === login) + ) + return { + viewerLogin: login, + threads: Object.fromEntries( + Object.entries(read.threads).map(([threadId, comments]) => [threadId, mine(comments)]) + ), + conversation: mine(read.conversation) + } +} + +async function runBounded( + writes: readonly ReviewWrite[], + log: (message: string, error?: unknown) => void +): Promise { + const failures: string[] = [] + let next = 0 + const worker = async (): Promise => { + while (next < writes.length) { + const write = writes[next++]! + try { + const failure = await write() + if (failure !== null) { + failures.push(failure) + } + } catch (error) { + log('review reply: a review write failed', error) + failures.push(error instanceof Error ? error.message : String(error)) + } + } + } + await Promise.all( + Array.from({ length: Math.min(REVIEW_REPLY_CONCURRENCY, writes.length) }, worker) + ) + return failures +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index c7240eccd833..f95ad2699261 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } @@ -158,7 +159,7 @@ function sendParams(text: string) { /** A chat whose rewind the provider applied, whose Orca side never finished, reopened at rest. */ async function interruptedRewindAtRest(): Promise { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const drop = { ...KEPT, turnId: 'drop' } sink.appendItem(KEPT, hostTestMessage('verified history'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE @@ -227,7 +228,9 @@ describe('an interrupted Codex rewind on a chat at rest (R16)', () => { describe('a rewind asked of a chat at rest (P2-23)', () => { it('starts the agent first and answers with what the provider says', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) sink.appendItem(KEPT, hostTestMessage('kept'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index dc8eb1117005..c45081856966 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -29,6 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } @@ -107,8 +108,16 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +/** An interrupted rewind recovers when the agent next starts: here, after the host puts it to rest. */ +async function restartAgent() { + await host + .collaboratorsForTests() + .lifetime.stopAgent(HOST_TEST_SESSION, { cause: 'evict', resting: true }) + return startAgentForTests(host, HOST_TEST_SESSION) +} + async function seed(acceptedSubmissions = false) { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const keys = ['kept', 'drop', 'tip'].map((turnId) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -196,8 +205,9 @@ describe('host rewind', () => { await expect(host.rewind(caller, request)).rejects.toThrow('disk failed') expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') replace.mockRestore() - const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ + ok: true + }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) @@ -214,19 +224,12 @@ describe('host rewind', () => { expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) - await expect( - host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('history still unavailable') + expect(await restartAgent()).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('history still unavailable') } + }) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( hostTestMessage('verified history') @@ -333,12 +336,7 @@ describe('host rewind', () => { refusal: { rewindReason: 'outcome-unknown' } }) expect(adapter.dispatch).not.toHaveBeenCalled() - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) @@ -350,12 +348,7 @@ describe('host rewind', () => { rewind.mockRejectedValueOnce(new Error('read failed before revert')) await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) @@ -371,12 +364,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') const body = hostTestMessage('after the refused rewind') @@ -398,7 +386,9 @@ describe('host rewind', () => { }) it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) const message = (turnId: string) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -479,7 +469,9 @@ describe('host rewind', () => { }) it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) const message = (turnId: string, ordinal = 0) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -570,7 +562,9 @@ describe('host rewind', () => { }) it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) const message = (turnId: string) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -614,12 +608,7 @@ describe('host rewind', () => { ok: true, items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] }) - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect( (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ @@ -645,12 +634,7 @@ describe('host rewind', () => { }) await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) @@ -679,12 +663,10 @@ describe('host rewind', () => { items: missing === 'turn' ? [] : items.slice(0, 1) }) const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') - await expect( - host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('proof-mismatch') + expect(await restartAgent()).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('proof-mismatch') } + }) expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch) @@ -712,12 +694,7 @@ describe('host rewind', () => { expect(committed.cursor.epoch).not.toBe(request.expectedEpoch) checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') - expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() replace.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 52a1284abf6a..ce2bdd845293 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -93,7 +94,7 @@ beforeEach(async () => { mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index e1fb3d1e1d58..f6b331e88489 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -161,7 +162,7 @@ beforeEach(async () => { mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW }) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f0163b22791d..d643f2be5252 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -82,7 +82,8 @@ describe('send', () => { expect(dispatch).toHaveBeenCalledTimes(1) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) - expect(page.ok && page.page.fence).toBe(1) + // The harness creates the chat at rest (fence 1), then starts its agent at 2. + expect(page.ok && page.page.fence).toBe(2) expect(page.page.hostNow).toBe(NOW) expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index f14393df8740..a1ea20db3844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -1,6 +1,6 @@ -// What a client's retry sees around a failed attach settlement: a crash between -// reserve and settlement replays into the original reservation, and a settled -// failure refuses sends without ever re-dispatching on the user's behalf. +// What the next start sees around a failed start's settlement: a crash between reserve and +// settlement is released at restart, a replay of the same operation continues its reservation, and +// a settled failure refuses sends without ever re-dispatching on the user's behalf. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -26,6 +26,11 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + attachExistingForTests, + attachForTests, + startAgentForTests +} from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -133,7 +138,6 @@ describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const historyFilePath = vi .fn>() - .mockRejectedValueOnce(new Error('journal path unavailable')) .mockResolvedValue(null) host = new StructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), @@ -144,28 +148,34 @@ describe('settled attach retry', () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - const first = hostTestAttachParams(null) + expect(await host.create(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + // Closed, so the start opens the journal again after it acquires. + await host.close(SESSION, 'evict') + historyFilePath.mockRejectedValueOnce(new Error('journal path unavailable')) - await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: false, + refusal: { message: 'journal path unavailable' } + }) expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - runtimeFence: 2 + runtimeFence: 3 }) expect( - store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) + store.listOperationRows().find((row) => row.callerKey === 'trusted-local:agent-start') ?.outcome ).toMatchObject({ status: 'failed' }) - await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 3 + runtimeFence: 4 }) }) @@ -202,13 +212,17 @@ describe('settled attach retry', () => { mintSpawnToken, now: () => NOW }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 + expect(await host.create(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const params = hostTestAttachParams(1) + publishFault.failOnPublish = publishFault.publishCount + 2 - await expect(host.attach(CALLER, params)).rejects.toThrow( + await expect(attachExistingForTests(host, CALLER, params)).rejects.toThrow( 'agent session acquisition failure settlement failed' ) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await attachExistingForTests(host, CALLER, params)).toMatchObject({ + ok: true, + replayed: true + }) expect(mintSpawnToken).toHaveBeenCalledOnce() expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) @@ -252,17 +266,16 @@ describe('settled attach retry', () => { }), now: () => NOW }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 + expect(await host.create(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + publishFault.failOnPublish = publishFault.publishCount + 2 - await expect(host.attach(CALLER, params)).rejects.toThrow( + await expect(startAgentForTests(host, SESSION)).rejects.toThrow( 'agent session acquisition failure settlement failed' ) - expect(publishFault.publishCount).toBe(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'reserved', handoffStage: 'new-owner-proving', - runtimeFence: 1, + runtimeFence: 2, reservedSpawnToken: 'spawn-1', ownerProcess: null }) @@ -290,20 +303,20 @@ describe('settled attach retry', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, ownerProcess: null, deathEvidence: null }) - // The interrupted operation's own retry continues it as a fresh reservation. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) + // The next start, the chat's next message's, goes ahead as a fresh reservation. + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(mintSpawnToken).toHaveBeenCalledTimes(2) expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeKind: 'native', - runtimeFence: 3, + runtimeFence: 4, handoffStage: null, handoffOperationId: null, ownerProcess: { spawnToken: 'spawn-2' } @@ -311,7 +324,7 @@ describe('settled attach retry', () => { }) it('settles a submission the host restart left pending, and never redelivers it', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) // Admitted: written to the child, acknowledgement still outstanding. The // restart below is the process fact that ends the wait, not a stopwatch. dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) @@ -342,7 +355,7 @@ describe('settled attach retry', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 3 + runtimeFence: 4 }) expect(dispatch).toHaveBeenCalledTimes(1) @@ -385,10 +398,10 @@ describe('settled attach retry', () => { it('records proven acquisition cleanup as durable death evidence', async () => { acquire.mockRejectedValueOnce(new Error('resume rejected')) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, refusal: { - message: "Codex couldn't restart. Send your message to try again.", + message: "Codex couldn't start. Send your message to try again.", ownerVerdict: 'exited' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index aa50336fb1ce..4e99ccc02a88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -42,6 +42,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -293,7 +294,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) startHost() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) // The first child (generation-1) is lost at setup; the first send starts generation-2. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2654b20ac730..b65132450417 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -262,7 +262,7 @@ describe('a rewind that restates a turnless Stop', () => { expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') await rig.settleAccepted(stopped, 'stopped') - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } const stoppedTurn = { ...LATER_TURN, turnId: 'turn-stopped', ordinal: 998 } const ended = { kind: 'turn' as const, @@ -277,7 +277,7 @@ describe('a rewind that restates a turnless Stop', () => { ) await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) - await journal().replaceEpochItems('handle_forked', 1, [ + await journal().replaceEpochItems('handle_forked', fence(), [ { identity: stoppedTurn, body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index dd0acf82b371..7dcb1d8d4fab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -22,6 +22,11 @@ afterEach(() => rig.dispose()) /** Swept only when a test ticks it. */ const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } +/** The started chat's fence: a create at rest holds the first, and its first start takes the next. */ +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + function journal() { const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!open) { @@ -56,7 +61,7 @@ async function runningTurn(turnId = 'turn-1'): Promise { await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, { kind: 'turn', turnId, state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return working } @@ -211,7 +216,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the turnId: 'turn-1', ordinal: 999 } - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } await journal().appendItem( identity, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, @@ -271,7 +276,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(journal().activeTurnId()).toBeNull() const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) @@ -368,7 +373,7 @@ describe("a person's Stop pause and the Stop events after it", () => { 'starting' ) // A person's Stop of an earlier turn still pauses the queue. - await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, fence()) expect(journal().queuedMessages.userStopInForce()).not.toBeNull() const atClose = stopEventsAtClose() @@ -390,7 +395,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) const atClose = stopEventsAtClose() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015a..7e2039a24fc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -47,6 +47,11 @@ let rig: QueuedMessageTestRig afterEach(() => rig.dispose()) +/** The started chat's fence: a create at rest holds the first, and its first start takes the next. */ +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + function journal() { const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!open) { @@ -65,7 +70,7 @@ async function runningTurn( await journal().appendItem( identity, { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -74,6 +79,7 @@ async function runningTurn( async function restartAndSettle( proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent' ): Promise { + const ownerFence = fence() rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) const now = Date.now() @@ -84,13 +90,13 @@ async function restartAndSettle( kind: proof, detail: 'the relaunch proved the old child gone', observedAt: now + 60_000, - ownerFence: 1, + ownerFence, lastProvenAliveAt: now - 20_000 } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, - fence: 2, + fence: ownerFence + 1, acquisitionGeneration: 'generation-2', deathEvidence }) @@ -218,12 +224,12 @@ describe('a restart between a Stop and its turn end', () => { outcome: 'success', completedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal().appendItem( CODEX_NEXT_TURN, { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() - 10_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // Its exit is proven after the Stop, so only the turn the Stop named decides. @@ -262,7 +268,7 @@ describe('a restart between a Stop and its turn end', () => { await journal().appendItem( CODEX_TURN, { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // Its exit is proven after the Stop, so only whose turn it is decides. @@ -279,7 +285,7 @@ describe('a restart between a Stop and its turn end', () => { expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } // Codex names the send that opened a turn on its row. const opener = agentJournalSubmissionKey(stopped) await journal().appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 1ceff1b63a44..3d63aa12d87a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -51,7 +51,11 @@ async function turnRow(turnId: string, state: 'running' | 'interrupted') { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, { kind: 'turn', turnId, state, startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + // The rig's chat was created at rest (fence 1) and its agent started at the next. + { + fence: rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts index 37ee57e79eda..28c6ab5c727a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts @@ -8,6 +8,7 @@ import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { RpcDispatcher } from '../../runtime/rpc/dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from '../../runtime/rpc/methods/structured-agent-session' import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { attachForTests } from './structured-agent-session-attach-test-support' import { hostTestAttachParams } from './structured-agent-session-host-test-data' import { createRestTestRig, @@ -57,7 +58,8 @@ async function collect(): Promise { } async function seedFold() { - const attached = await rig.host.attach( + const attached = await attachForTests( + rig.host, CALLER, hostTestAttachParams(null, { location: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1f8fc80c863d..5b7d0acd5931 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -41,6 +41,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -102,13 +103,12 @@ async function reboot(): Promise { } async function attach(): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) } -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +/** What a send's delivery does: the start at the record's current fence. */ async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) } function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 8c50d95126cf..5a1d8995c3a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,6 +6,7 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, @@ -122,6 +123,7 @@ export async function performSend( body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' + reviewReply?: AgentSessionReviewReply } ): Promise> { const existing = ctx.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 95ec8d35206b..aaff58dfddad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ @@ -163,7 +164,8 @@ describe('a fresh chat whose Claude start fails', () => { initGate = createGate claude = fakeClaude({ exitBeforeInit: LAUNCH_FAILURE }) await expect( - host.attach( + attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 08532f63c1fd..ab3b5b7cc0db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -50,6 +50,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const CALLER = { callerKey: 'client-1' } @@ -370,12 +371,9 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - // The attach adjudicates the dead owner first, which moves the fence; like a client's ensure, - // it is retried at the fence the refusal names. - const stale = await host.attach(CALLER, hostTestAttachParams(13)) - const fence = stale.ok ? 13 : (stale.refusal.currentFence ?? 13) - expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) - expect(stale.ok || (await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) + // The start adjudicates the dead owner first, which moves the fence, then reserves at the + // fence it reads from the record. + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -413,7 +411,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -506,7 +504,7 @@ describe('already-wedged profiles become usable on load', () => { ownerProcess: null, deathEvidence: null }) - expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() }) @@ -530,7 +528,9 @@ describe('already-wedged profiles become usable on load', () => { runtimeFence: 14, deathEvidence: null }) - expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: true + }) expect(acquire).toHaveBeenCalledOnce() } ) @@ -542,7 +542,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(turnLifecycle('turn-1')).toEqual({ @@ -560,14 +560,14 @@ describe('already-wedged profiles become usable on load', () => { ).toBe(false) }) - it("leaves the live generation's running turn alone on a re-attach", async () => { + it("leaves the live generation's running turn alone on a second start", async () => { await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: null })) // The child this host spawns stays provably alive across the second attach. openHost({ probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) }) const params = hostTestAttachParams(13) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) const fence = store.getRecord(SESSION)!.lease.runtimeFence await restoredJournal().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, @@ -575,8 +575,8 @@ describe('already-wedged profiles become usable on load', () => { { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - // A reconnecting client replays its attach; the same operation admits the live owner. - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + // The next start, a second message's, finds the agent running and leaves it alone. + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(turnLifecycle('turn-2')).toEqual({ turnId: 'turn-2', state: 'running', startedAt: NOW }) @@ -694,8 +694,9 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: true + }) expect(order).toEqual(['acquire']) }) @@ -714,8 +715,7 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() // A terminal agent keeps its transport across a restart, so an unanswered probe is not a way in. - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 61f2f6a69e96..6eab1dd65299 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' const caller = { callerKey: 'desktop' } @@ -141,7 +142,7 @@ beforeEach(async () => { } await openHost() expect( - await host.attach(caller, hostTestAttachParams(null, { options: { effort: 'low' } })) + await attachForTests(host, caller, hostTestAttachParams(null, { options: { effort: 'low' } })) ).toMatchObject({ ok: true }) await host.setSessionTabVisibility(HOST_TEST_SESSION, true) }) @@ -298,7 +299,8 @@ describe('/clear starts nothing', () => { it('copies the launch arguments the source was pinned to', async () => { const pinned = 'session-pinned' expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(null, { envelope: { @@ -688,7 +690,7 @@ describe('what an older build left', () => { now: clock }) ).toMatchObject({ admission: { decision: 'admit' } }) - expect(await host.attach(caller, orphanStart)).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, orphanStart)).toMatchObject({ ok: true }) await restartHost() await host.restoreReadableSessions(store.listVisibleSessionIds()) diff --git a/src/main/runtime/agent-session-at-rest-create.test.ts b/src/main/runtime/agent-session-at-rest-create.test.ts new file mode 100644 index 000000000000..5e596d843aba --- /dev/null +++ b/src/main/runtime/agent-session-at-rest-create.test.ts @@ -0,0 +1,400 @@ +// A chat's create founds its record at rest, and is the only thing that does: adoption, the tab +// it reserves, and every refusal of a record that cannot be founded are decided here. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../shared/agent-session-host-authority' +import { + commitAgentSessionAtRestCreate, + type AgentSessionAtRestCreateRequest +} from './agent-session-at-rest-create' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-creating' +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const ACCOUNT_HOME = { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/home/dev/.claude' } + +let operations = 0 +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function createRequest( + overrides: Partial = {} +): AgentSessionAtRestCreateRequest { + return { + sessionId: SESSION, + location: LOCATION, + provider: 'claude', + accountHome: ACCOUNT_HOME, + claimKeyId: 'key-1', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-create' }, + now: NOW, + ...overrides + } +} + +function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState { + return { + schemaVersion: 2, + hostId: 'local', + records: new Map(records.map((record) => [record.sessionId, record])), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map(), + sessionTabs: null + } +} + +function adoptedLink( + overrides: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId: 'claude-1-provider-session-alpha-1-empty', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, + origin: 'adopted', + mintedAtFence: 1, + observedAt: NOW, + ...overrides + } +} + +/** Another chat whose agent already ran the conversation `adoptedLink` names, on another leaf. */ +function holderOfAdoptedConversation(): AgentSessionRecord { + return { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-holder' })), + providerHandleChain: [ + adoptedLink({ + linkId: 'claude-1-provider-session-alpha-1-leaf', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'leaf-1' }, + origin: 'created' + }) + ] + } +} + +describe('a create at rest', () => { + it('founds a released record at fence 1 with an empty chain, so its first start is fresh', () => { + const state = storeState() + const { record, replayed } = commitAgentSessionAtRestCreate(state, createRequest()) + + expect(replayed).toBe(false) + expect(record.providerHandleChain).toEqual([]) + expect(record.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: 'released', + ownerProcess: null, + deathEvidence: null + }) + expect(state.records.get(SESSION)).toEqual(record) + // Settled by the create once its journal is open; see structured-agent-session-create-at-rest. + expect([...state.operations.values()].map((row) => row.outcome)).toEqual([ + { status: 'pending' } + ]) + }) + + it('replays the record its own operation founded, and writes nothing more', () => { + const state = storeState() + const request = createRequest() + const first = commitAgentSessionAtRestCreate(state, request) + + const replay = commitAgentSessionAtRestCreate(state, { ...request, now: NOW + 1 }) + + expect(replay).toMatchObject({ replayed: true, record: first.record }) + expect(state.operations.size).toBe(1) + }) + + it('refuses a second create of a session that exists', () => { + const state = storeState() + commitAgentSessionAtRestCreate(state, createRequest()) + + expect(() => commitAgentSessionAtRestCreate(state, createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'agent_session_conflict', + details: { reason: 'sessionExists' } + }) + }) + ) + }) + + it('refuses a session whose record this build cannot read, as reconciling', () => { + const state = storeState() + state.unreadableRecords.set(SESSION, { reason: 'invalid', raw: {} }) + + expect(() => commitAgentSessionAtRestCreate(state, createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' } + }) + }) + ) + expect(state.records.size).toBe(0) + }) + + it('refuses launch args or options it cannot store, before founding anything', () => { + const state = storeState() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed payload the store must refuse. + const launchArgs = [42] as unknown as string[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed payload the store must refuse. + const options = { model: 7 } as unknown as Record + + expect(() => commitAgentSessionAtRestCreate(state, createRequest({ launchArgs }))).toThrow( + 'agent_session_launch_args_invalid' + ) + expect(() => commitAgentSessionAtRestCreate(state, createRequest({ options }))).toThrow( + 'agent_session_options_invalid' + ) + expect(state.records.size).toBe(0) + }) +}) + +/** What a create on an older host left when the start it ran at create failed: released at the + * fence that start reserved, its exit proven, no conversation bound. */ +function failedCreateFromOlderHost( + overrides: Partial = {} +): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION, + runtimeKind: 'native', + runtimeFence: 2, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'the create failed', observedAt: 1 } + }) + ), + location: LOCATION, + accountHome: ACCOUNT_HOME, + providerHandleChain: [], + conversationName: 'Fix the flaky test', + ...overrides + } +} + +describe('a create over a record an older host left after its start failed', () => { + it('founds it again at rest, at the next fence, keeping its name', () => { + const existing = failedCreateFromOlderHost() + const state = storeState([existing]) + + const { record, replayed } = commitAgentSessionAtRestCreate(state, createRequest()) + + expect(replayed).toBe(false) + expect(record).toMatchObject({ + providerHandleChain: [], + conversationName: 'Fix the flaky test', + createdAt: existing.createdAt + }) + expect(record.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'released', + handoffStage: null, + deathEvidence: null + }) + }) + + it('keeps the floor a recovered copy set, and mints the fence past it', () => { + const exited = failedCreateFromOlderHost().lease + const state = storeState([ + failedCreateFromOlderHost({ lease: { ...exited, minimumNextFence: 9 } }) + ]) + + const { record } = commitAgentSessionAtRestCreate(state, createRequest()) + + expect(record.lease).toMatchObject({ runtimeFence: 9, minimumNextFence: 9 }) + }) + + it('refuses while this host has not yet adjudicated the lease', () => { + const exited = failedCreateFromOlderHost().lease + const state = storeState([ + failedCreateFromOlderHost({ lease: { ...exited, unreconciled: true } }) + ]) + + expect(() => commitAgentSessionAtRestCreate(state, createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } + }) + }) + ) + }) + + it('refuses one that bound a conversation, may still run, or is another identity', () => { + const exited = failedCreateFromOlderHost().lease + const records = [ + failedCreateFromOlderHost({ providerHandleChain: [adoptedLink({ mintedAtFence: 2 })] }), + failedCreateFromOlderHost({ + lease: { ...exited, claimStatus: 'reserved', handoffStage: 'recovering' } + }), + // Released so a send can start over, but nothing proved the attempt gone. + failedCreateFromOlderHost({ lease: { ...exited, deathEvidence: null } }), + failedCreateFromOlderHost({ + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' } + }) + ] + for (const record of records) { + expect(() => commitAgentSessionAtRestCreate(storeState([record]), createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'sessionExists' } }) + }) + ) + } + // An adoption never takes over a record: it names a conversation of its own. + expect(() => + commitAgentSessionAtRestCreate( + storeState([failedCreateFromOlderHost()]), + createRequest({ adoptedHandleLink: adoptedLink() }) + ) + ).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'sessionExists' } }) + }) + ) + }) +}) + +describe('a create retried after its operation row aged out', () => { + const LATER = + NOW + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1 + + it('answers the record it founded, at rest, under a fresh row the create settles', () => { + const state = storeState() + const request = createRequest() + const { record } = commitAgentSessionAtRestCreate(state, request) + + const retried = commitAgentSessionAtRestCreate(state, { ...request, now: LATER }) + + expect(retried).toMatchObject({ replayed: true, record }) + expect(retried.operationRow).toMatchObject({ + operationId: request.operation.operationId, + outcome: { status: 'pending' } + }) + expect(retried.operationRow.expiresAt).toBeGreaterThan(LATER) + }) + + it('stays expired for a record that is not the one this create founded', () => { + const request = createRequest() + const otherHome = { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/home/dev/.claude-work' } + for (const founded of [ + createRequest({ ...request, accountHome: otherHome }), + createRequest({ ...request, adoptedHandleLink: adoptedLink() }) + ]) { + const state = storeState() + commitAgentSessionAtRestCreate(state, founded) + expect(() => commitAgentSessionAtRestCreate(state, { ...request, now: LATER })).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ code: 'agent_session_operation_expired' }) + }) + ) + } + }) +}) + +describe('an adopting create at rest', () => { + it('seeds the chain with the adopted link alone, at the record fence its first start moves', () => { + const link = adoptedLink() + const { record } = commitAgentSessionAtRestCreate( + storeState(), + createRequest({ adoptedHandleLink: link }) + ) + + expect(record.providerHandleChain).toEqual([link]) + expect(record.lease.runtimeFence).toBe(link.mintedAtFence) + }) + + it('refuses a conversation another record already holds, by its root', () => { + // The held link names a leaf; the adoption names none. Keying on the exact handle would let two + // writers onto one conversation on different branches. + const state = storeState([holderOfAdoptedConversation()]) + + expect(() => + commitAgentSessionAtRestCreate(state, createRequest({ adoptedHandleLink: adoptedLink() })) + ).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'agent_session_conflict', + details: { reason: 'conversationHeldElsewhere' } + }) + }) + ) + expect(state.records.has(SESSION)).toBe(false) + }) + + it('admits a conversation no record holds', () => { + const state = storeState([holderOfAdoptedConversation()]) + const other = adoptedLink({ + handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } + }) + + expect( + commitAgentSessionAtRestCreate(state, createRequest({ adoptedHandleLink: other })).record + .providerHandleChain + ).toEqual([other]) + }) +}) + +describe('the tab a create reserves', () => { + let directory: string + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-create-tab-')) + }) + afterEach(async () => { + await rm(directory, { recursive: true, force: true }) + }) + + it('takes no tab at create, then answers a replay naming another tab with the one it was given', async () => { + const store = await openTestAgentSessionRecordStore(directory) + const request = createRequest({ surfaceTabId: 'chat-tab-1' }) + await store.createAtRest(request) + // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. + expect(store.getSessionTabId(SESSION)).toBeNull() + + await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') + expect(await store.createAtRest({ ...request, surfaceTabId: 'chat-tab-2' })).toMatchObject({ + replayed: true + }) + expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') + }) + + it("refuses a tab id another session's tab holds, and a malformed one", async () => { + const store = await openTestAgentSessionRecordStore(directory) + await store.createAtRest(createRequest({ surfaceTabId: 'chat-tab-1' })) + await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') + + await expect( + store.createAtRest(createRequest({ sessionId: 'session-other', surfaceTabId: 'chat-tab-1' })) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + await expect( + store.createAtRest(createRequest({ sessionId: 'session-other', surfaceTabId: '' })) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_operation_invalid' } }) + expect(store.getRecord('session-other')).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts new file mode 100644 index 000000000000..1fcaf76da97a --- /dev/null +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -0,0 +1,227 @@ +// A chat's creation: its record at rest and the operation row that answers a retry of it, in one +// write. Nothing starts here; the chat's first message starts its agent, as /clear's new chat does. + +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + agentSessionOperationKey, + pendingAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { agentSessionLeaseOwnerVerdict } from '../../shared/agent-session-lease-adjudication' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import { + agentSessionExecutionLocationsEqual, + isAgentSessionOptions, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import { + agentSessionProviderHandleRoot, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { + foundAgentSessionRecord, + type AgentSessionRecordIdentity +} from './agent-session-record-founding' +import { + evaluateAgentSessionReserveOperation, + requireAgentSessionRecordForReplay, + type AgentSessionReserveRequest +} from './agent-session-reservation-admission' + +export type AgentSessionAtRestCreateRequest = AgentSessionRecordIdentity & + Pick & { + /** The tab id a create reserves for this conversation, taken when its tab is published. */ + surfaceTabId?: string + /** The provider conversation this create adopts; its first start resumes it. */ + adoptedHandleLink?: AgentSessionProviderHandleLink + } + +export type AgentSessionAtRestCreateResult = { + record: AgentSessionRecord + /** Pending until the create's journal is open and any adopted history imported; a replay of a + * pending row does both again. */ + operationRow: AgentSessionOperationRow + replayed: boolean +} + +export function commitAgentSessionAtRestCreate( + state: AgentSessionStoreState, + request: AgentSessionAtRestCreateRequest +): AgentSessionAtRestCreateResult { + const decision = evaluateAgentSessionReserveOperation(state, request) + if (decision.decision === 'refused') { + const existing = state.records.get(request.sessionId) + // An aged-out row proves nothing more, and a create starts nothing: the chat it would answer + // with is the record already here, under a fresh row the create settles as it would its own. + if ( + decision.code === 'agent_session_operation_expired' && + existing && + sameCreate(existing, request) + ) { + return recordOperationRow(state, existing, request, true) + } + throw agentSessionRefusalError(decision.code, decision.details) + } + if (decision.decision === 'replay') { + return { + record: requireAgentSessionRecordForReplay(state, decision.row, request.sessionId), + operationRow: decision.row, + replayed: true + } + } + if (request.launchArgs && !isAgentSessionLaunchArgs(request.launchArgs)) { + throw new Error('agent_session_launch_args_invalid') + } + if (request.options && !isAgentSessionOptions(request.options)) { + throw new Error('agent_session_options_invalid') + } + if (state.unreadableRecords.has(request.sessionId)) { + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + } + const existing = state.records.get(request.sessionId) + if (existing && !refoundable(existing, request)) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) + } + if (existing?.lease.unreconciled) { + // This host has not adjudicated the lease since it loaded it: nothing proves its exit yet. + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) + } + assertAdoptedConversationUnowned(state, request) + assertReservedTabUnheld(state, request) + const founded = foundAgentSessionRecord(request, request, request.adoptedHandleLink) + const record = existing ? refounded(existing, founded) : founded + state.records.set(record.sessionId, record) + return recordOperationRow(state, record, request, false) +} + +function recordOperationRow( + state: AgentSessionStoreState, + record: AgentSessionRecord, + request: AgentSessionAtRestCreateRequest, + replayed: boolean +): AgentSessionAtRestCreateResult { + const operationRow = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) + state.operations.set( + agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), + operationRow + ) + return { record, operationRow, replayed } +} + +/** The record this create founded: same identity, and the conversation it adopted, if any. */ +function sameCreate(existing: AgentSessionRecord, request: AgentSessionAtRestCreateRequest) { + const adoptedRoot = request.adoptedHandleLink + ? agentSessionProviderHandleRoot(request.adoptedHandleLink.handle) + : null + const heldAdoption = existing.providerHandleChain.find((link) => link.origin === 'adopted') + return ( + sameIdentity(existing, request) && + (adoptedRoot === null + ? heldAdoption === undefined + : heldAdoption !== undefined && + agentSessionProviderHandleRoot(heldAdoption.handle) === adoptedRoot) + ) +} + +function sameIdentity(existing: AgentSessionRecord, request: AgentSessionAtRestCreateRequest) { + return ( + agentSessionExecutionLocationsEqual(existing.location, request.location) && + existing.provider === request.provider && + existing.accountHome.variable === request.accountHome.variable && + existing.accountHome.path === request.accountHome.path + ) +} + +/** + * A record a create may found again at rest: its agent never bound a conversation and its last + * start is proven gone, so founding it again is founding it fresh. An older host left exactly this + * when the start it ran at create failed, and the client's relaunch of that chat (same session id, + * new operation) must get a chat, not `sessionExists` on every message. + */ +function refoundable( + existing: AgentSessionRecord, + request: AgentSessionAtRestCreateRequest +): boolean { + return ( + !request.adoptedHandleLink && + existing.providerHandleChain.length === 0 && + agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' && + sameIdentity(existing, request) + ) +} + +/** + * Founded again over the lease already there, so what that lease carries holds: its fence moves + * on through the one mint (never back, never onto a floor a recovered copy set), and the chat keeps + * what it already has, such as its name. + */ +function refounded(existing: AgentSessionRecord, founded: AgentSessionRecord): AgentSessionRecord { + return { + ...existing, + ...founded, + createdAt: existing.createdAt, + lease: { + ...existing.lease, + ...founded.lease, + runtimeFence: nextAgentSessionFence(existing.lease) + } + } +} + +/** + * Refuse an adoption whose conversation ANOTHER record already holds. + * + * It runs inside the store transaction because the pre-commit check in the RPC resolver cannot be + * the guard: two concurrent adoptions of one conversation mint different session ids, so neither + * sees the other's record and both would pass. Codex permits two app-servers on one thread + * silently, so the cost of missing this is a corrupted conversation rather than an error. + */ +function assertAdoptedConversationUnowned( + state: AgentSessionStoreState, + request: Pick +): void { + const adopted = request.adoptedHandleLink + if (!adopted) { + return + } + const root = agentSessionProviderHandleRoot(adopted.handle) + for (const record of state.records.values()) { + if (record.sessionId === request.sessionId) { + continue + } + const holdsSameConversation = record.providerHandleChain.some( + (link) => agentSessionProviderHandleRoot(link.handle) === root + ) + if (holdsSameConversation) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } + } +} + +/** + * A tab id names one conversation, so a reserved id another session's tab holds is a conflict. + * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never + * gets that far leaves nothing in the table to restore or release. + */ +function assertReservedTabUnheld( + state: AgentSessionStoreState, + request: Pick +): void { + if (request.surfaceTabId === undefined) { + return + } + if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) + if (holder !== undefined && holder !== request.sessionId) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) + } +} diff --git a/src/main/runtime/agent-session-conversation-clear-commit.test.ts b/src/main/runtime/agent-session-conversation-clear-commit.test.ts index 3e7c63a41c34..982e03cc0b4a 100644 --- a/src/main/runtime/agent-session-conversation-clear-commit.test.ts +++ b/src/main/runtime/agent-session-conversation-clear-commit.test.ts @@ -8,7 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SOURCE = 'session-alpha' @@ -29,7 +32,7 @@ const open = () => openTestAgentSessionRecordStore(directory) async function storeWithSource(): Promise { const store = await open() - await store.reserveOwner({ + await foundAndReserveTestAgentSessionRecord(store, { sessionId: SOURCE, location: { executionHostId: 'local', @@ -41,7 +44,6 @@ async function storeWithSource(): Promise { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, launchArgs: ['--flag'], options: { model: 'sonnet' }, - expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts index 07c1345f8da4..b50424ccd9b0 100644 --- a/src/main/runtime/agent-session-conversation-name-store.test.ts +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -5,7 +5,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -26,12 +29,11 @@ function operationId(): string { .replaceAll(/[^0-9a-f]/g, '0')}` } -const reserveRequest = (): AgentSessionReserveRequest => ({ +const reserveRequest = (): Omit => ({ sessionId: SESSION, location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -51,7 +53,7 @@ afterEach(async () => { async function reservedStore(): Promise { const store = await openTestAgentSessionRecordStore(directory) - await store.reserveOwner(reserveRequest()) + await foundAndReserveTestAgentSessionRecord(store, reserveRequest()) return store } diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 9904ca7994f4..94458ae91efe 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -10,6 +10,7 @@ import { import type { AgentSessionFailedAcquisitionSettlement } from './agent-session-acquisition-failure-settlement' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundAndReserveTestAgentSessionRecord, openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' @@ -113,12 +114,11 @@ describe('a failed acquisition', () => { /** Reserve and observe the spawn at NOW, as an attach does before it can fail. */ async function spawnedOwner(store: AgentSessionRecordStore): Promise { - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId: SESSION, location: agentSessionRecordFixture().location, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: OPERATION_ID, diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts index 6cb9a576078f..6bf0416fefda 100644 --- a/src/main/runtime/agent-session-launch-env-admission.test.ts +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -2,7 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' @@ -23,7 +25,7 @@ function request(overrides: Partial = {}): AgentSess }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, + expectedFence: 1, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -38,6 +40,21 @@ function request(overrides: Partial = {}): AgentSess } } +/** The record a create founds at rest, which every start then reserves. */ +async function foundedStore(): Promise { + const store = await openTestAgentSessionRecordStore(directory) + const { sessionId, location, provider, accountHome, claimKeyId } = request() + await foundTestAgentSessionRecord(store, { + sessionId, + location, + provider, + accountHome, + claimKeyId, + now: NOW + }) + return store +} + beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) }) @@ -48,11 +65,11 @@ afterEach(async () => { describe('agent session launch environment admission', () => { it('does not persist ambient launch variables', async () => { - const store = await openTestAgentSessionRecordStore(directory) + const store = await foundedStore() await store.reserveOwner(request()) await store.reserveOwner( request({ - expectedFence: 1, + expectedFence: 2, spawnToken: 'spawn-b', launchEnv: { PATH: '/custom/bin:/usr/bin', @@ -74,7 +91,8 @@ describe('agent session launch environment admission', () => { }) it('rejects an environment that could not be validated before writing', async () => { - const store = await openTestAgentSessionRecordStore(directory) + const store = await foundedStore() + const founded = store.getRecord(SESSION) const launchEnv = Object.fromEntries( Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) ) @@ -82,15 +100,16 @@ describe('agent session launch environment admission', () => { await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( 'agent_session_launch_env_invalid' ) - expect(store.getRecord(SESSION)).toBeNull() + expect(store.getRecord(SESSION)).toEqual(founded) }) it('rejects an overlong environment key before writing it', async () => { - const store = await openTestAgentSessionRecordStore(directory) + const store = await foundedStore() + const founded = store.getRecord(SESSION) await expect( store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) ).rejects.toThrow('agent_session_launch_env_invalid') - expect(store.getRecord(SESSION)).toBeNull() + expect(store.getRecord(SESSION)).toEqual(founded) }) }) diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index ec636a96eed5..75df25979b27 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundAndReserveTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' @@ -20,7 +21,7 @@ async function establishOwner( ): Promise { const sessionId = `session-${suffix}` const spawnToken = `spawn-${suffix}` - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId, location: { executionHostId: 'local', @@ -30,7 +31,6 @@ async function establishOwner( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: directory }, - expectedFence: null, spawnToken, claimKeyId: 'key-1', handoffOperationId: null, @@ -92,7 +92,7 @@ describe('agent-session lease renewal batch', () => { const { directory, store } = await liveStore() await store.evictProvenDeadOwner({ sessionId: 'session-b', - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, now: NOW + 5_000 }) @@ -101,8 +101,8 @@ describe('agent-session lease renewal batch', () => { await expect( store.renewLeases([ - { sessionId: 'session-a', fence: 1, childProbe: MATCHED, now: NOW + 10_000 }, - { sessionId: 'session-b', fence: 1, childProbe: MATCHED, now: NOW + 10_000 } + { sessionId: 'session-a', fence: 2, childProbe: MATCHED, now: NOW + 10_000 }, + { sessionId: 'session-b', fence: 2, childProbe: MATCHED, now: NOW + 10_000 } ]) ).rejects.toThrow('agent_session_checkpoint_stale') diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index d759a2461e0c..3deae4f51acf 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_RECORD_SCHEMA_VERSION, type AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' /** Who a conversation's agent is and how it launches: the same whether it is created or founded. */ export type AgentSessionRecordIdentity = Pick< @@ -27,16 +28,18 @@ export function agentSessionRecordIdentityFields( } /** - * A conversation no agent has run yet, at rest: its first send starts one. The empty handle chain - * is what makes that start a fresh conversation rather than a resume. + * A conversation no agent has run yet, at rest: its first send starts one. An empty handle chain + * makes that start a fresh conversation; an adopted link makes it a resume. */ export function foundAgentSessionRecord( identity: AgentSessionRecordIdentity, - lease: { claimKeyId: string; now: number } + lease: { claimKeyId: string; now: number }, + /** The provider conversation a create adopts; its first start resumes it instead. */ + adoptedHandleLink?: AgentSessionProviderHandleLink ): AgentSessionRecord { return { ...agentSessionRecordIdentityFields(identity, lease.now), - providerHandleChain: [], + providerHandleChain: adoptedHandleLink ? [adoptedHandleLink] : [], lease: { sessionId: identity.sessionId, runtimeKind: 'native', diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 58385e292726..6c2661ac6a21 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SESSION = 'session-options' @@ -47,7 +50,7 @@ it('drops provider-rejected persisted options before the next owner proof', asyn it('persists resumed provider options atomically with owner proof', async () => { const store = await openTestAgentSessionRecordStore(directory) - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId: SESSION, location: { executionHostId: 'local', @@ -57,7 +60,6 @@ it('persists resumed provider options atomically with owner proof', async () => }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - expectedFence: null, spawnToken: 'spawn-options', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/agent-session-record-store-commit-listeners.ts b/src/main/runtime/agent-session-record-store-commit-listeners.ts new file mode 100644 index 000000000000..8982ae159cf6 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-commit-listeners.ts @@ -0,0 +1,59 @@ +// Who the record store tells, once a transaction commits, of what it changed: each session it wrote +// a proof of death for, and its first chat. Kept apart from the transaction itself, which only asks +// it to watch the draft it applies. + +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export class AgentSessionRecordStoreCommitListeners { + private readonly deathEvidence = new Set<(sessionId: string) => void>() + private readonly firstRecord = new Set<() => void>() + + /** Every transition that writes a proof of death lands here. Must not throw. */ + onDeathEvidence(listener: (sessionId: string) => void): () => void { + this.deathEvidence.add(listener) + return () => this.deathEvidence.delete(listener) + } + + /** Must not throw. */ + onFirstRecord(listener: () => void): () => void { + this.firstRecord.add(listener) + return () => this.firstRecord.delete(listener) + } + + /** Applies one transaction's change to its draft, noting what to tell once it commits. */ + watch( + holdsRecords: () => boolean, + draft: AgentSessionStoreState, + apply: (draft: AgentSessionStoreState) => T + ): { result: T; notify: () => void } { + const heldBefore = holdsRecords() + if (this.deathEvidence.size === 0) { + return { + result: apply(draft), + notify: () => this.notifyFirstRecord(heldBefore, holdsRecords) + } + } + const before = new Map( + [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) + ) + const result = apply(draft) + const proven = [...draft.records] + .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) + .map(([id]) => id) + return { + result, + notify: () => { + for (const sessionId of proven) { + this.deathEvidence.forEach((listener) => listener(sessionId)) + } + this.notifyFirstRecord(heldBefore, holdsRecords) + } + } + } + + private notifyFirstRecord(heldBefore: boolean, holdsRecords: () => boolean): void { + if (!heldBefore && holdsRecords()) { + this.firstRecord.forEach((listener) => listener()) + } + } +} diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 40ee8a36b86c..68ac2a55de51 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -7,15 +7,15 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import type { AgentSessionAtRestCreateRequest } from './agent-session-at-rest-create' const NOW = 1_800_000_000_000 const NATIVE: AgentSessionExecutionLocation = { @@ -24,10 +24,8 @@ const NATIVE: AgentSessionExecutionLocation = { workspaceId: 'workspace-1', workspaceKind: 'git-worktree' } -const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } let directory: string -let counter = 0 beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-tab-table-')) @@ -37,30 +35,20 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -function operationId(now = NOW): string { - counter += 1 - return `${now}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { +/** A chat's create, which is where a tab id is reserved. */ +function create( + store: AgentSessionRecordStore, + overrides: Partial> = {} +) { + return foundTestAgentSessionRecord(store, { sessionId: 'session-alpha', location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', claimKeyId: 'key-1', - handoffOperationId: null, - probe: INDETERMINATE, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, now: NOW, ...overrides - } + }) } async function open(): Promise { @@ -72,7 +60,7 @@ describe('chat tab table', () => { it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await create(store, { surfaceTabId: 'tab-alpha' }) // A create that dies before its tab is shown leaves nothing to restore or release. expect(store.getSessionTabId('session-alpha')).toBeNull() expect((await readPersistedTestAgentSessionStore(directory)).sessionTabs).toBeUndefined() @@ -85,27 +73,16 @@ describe('chat tab table', () => { expect(persisted.records['session-alpha']).not.toHaveProperty('surfaceTabId') await expect( - store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - surfaceTabId: 'tab-alpha', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) + create(store, { sessionId: 'session-beta', surfaceTabId: 'tab-alpha' }) ).rejects.toThrow('agent_session_conflict') expect(store.getRecord('session-beta')).toBeNull() }) it('refuses showing a tab with the situation typed, as every chat refusal is', async () => { const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await create(store, { surfaceTabId: 'tab-alpha' }) await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') - await store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) + await create(store, { sessionId: 'session-beta' }) // The message stays the code: readers of a thrown refusal treat it as one. await expect( @@ -126,30 +103,24 @@ describe('chat tab table', () => { it('frees a reserved id once its chat is hidden', async () => { const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await create(store, { surfaceTabId: 'tab-alpha' }) await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') await store.setSessionTabVisibility('session-alpha', false) - await store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - surfaceTabId: 'tab-alpha', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) + await create(store, { sessionId: 'session-beta', surfaceTabId: 'tab-alpha' }) await store.setSessionTabVisibility('session-beta', true, 'tab-alpha') expect(store.getSessionTabId('session-beta')).toBe('tab-alpha') }) it('refuses a tab id that could not prefix a pane key', async () => { const store = await open() - await expect( - store.reserveOwner(reserveRequest({ surfaceTabId: 'agent-session:session-alpha' })) - ).rejects.toThrow('agent_session_operation_invalid') + await expect(create(store, { surfaceTabId: 'agent-session:session-alpha' })).rejects.toThrow( + 'agent_session_operation_invalid' + ) }) it('gives a shown chat the id clients derive, and puts a hidden one back under its old id', async () => { const store = await open() - await store.reserveOwner(reserveRequest()) + await create(store) await store.setSessionTabVisibility('session-alpha', true) expect(store.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) await store.setSessionTabVisibility('session-alpha', false) diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index eae3ae3baf4c..972d6a8b0069 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -5,7 +5,9 @@ * through the test database registry, so `closeTestJournalHostDatabases` closes it too. */ +import { randomUUID } from 'node:crypto' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' import { @@ -14,6 +16,11 @@ import { } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionAtRestCreateRequest } from './agent-session-at-rest-create' +import type { + AgentSessionReserveRequest, + AgentSessionReserveResult +} from './agent-session-reservation-admission' import { AGENT_SESSION_STORE_SCHEMA_VERSION, type RetiredAgentSessionClaimKey @@ -35,6 +42,43 @@ export type PersistedTestAgentSessionStore = { sessionTabs?: { tabId: string; sessionId: string }[] } +/** A record as production founds one: its create, at rest, under an operation of its own. */ +export async function foundTestAgentSessionRecord( + store: AgentSessionRecordStore, + request: Omit +): Promise { + const { record } = await store.createAtRest({ + ...request, + operation: { + callerKey: 'test:create', + operationId: `${request.now}-${randomUUID().replaceAll('-', '')}`, + fingerprint: 'test:create' + } + }) + return record +} + +/** A founded record, then reserved for its first start at the founded fence, as a start reserves. */ +export async function foundAndReserveTestAgentSessionRecord( + store: AgentSessionRecordStore, + request: Omit & + Pick +): Promise { + const founded = await foundTestAgentSessionRecord(store, { + sessionId: request.sessionId, + location: request.location, + provider: request.provider, + accountHome: request.accountHome, + ...(request.options ? { options: request.options } : {}), + ...(request.launchArgs ? { launchArgs: request.launchArgs } : {}), + ...(request.surfaceTabId ? { surfaceTabId: request.surfaceTabId } : {}), + ...(request.adoptedHandleLink ? { adoptedHandleLink: request.adoptedHandleLink } : {}), + claimKeyId: request.claimKeyId, + now: request.now + }) + return store.reserveOwner({ ...request, expectedFence: founded.lease.runtimeFence }) +} + function databaseFor(stateDirectory: string): Database.Database { return openTestJournalHostDatabase(stateDirectory).db } diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 12aa70b330cd..4094ab271a45 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -14,6 +14,8 @@ import type { PersistedAgentSessionRecord } from '../../shared/agent-session-leg import type { AgentSessionRecordStore } from './agent-session-record-store' import { editPersistedTestAgentSessionStore, + foundAndReserveTestAgentSessionRecord, + foundTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, seedTestAgentSessionStoreFromNewerBuild @@ -59,7 +61,8 @@ function reserveRequest( location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, + // The fence a create founds a record at, which its first start reserves from. + expectedFence: 1, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -89,7 +92,7 @@ function handleLink( linkId: 'link-1', handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, origin: 'created', - mintedAtFence: 1, + mintedAtFence: 2, observedAt: NOW, ...overrides } @@ -97,6 +100,19 @@ function handleLink( let directory: string +/** A record founded at rest by its create, then reserved by its first start: fence 1, then 2. */ +function reserveFresh( + store: AgentSessionRecordStore, + overrides: Partial = {} +) { + return foundAndReserveTestAgentSessionRecord(store, reserveRequest(overrides)) +} + +/** The rows this suite's client wrote; each create writes one of its own. */ +function clientRows(store: AgentSessionRecordStore) { + return store.listOperationRows().filter((row) => row.callerKey === 'client-1') +} + async function open(hostId = 'local'): Promise { return openTestAgentSessionRecordStore(directory, { hostId }) } @@ -106,7 +122,7 @@ async function establishOwner( store: AgentSessionRecordStore, overrides: Partial = {} ): Promise { - const reserved = await store.reserveOwner(reserveRequest(overrides)) + const reserved = await reserveFresh(store, overrides) const fence = reserved.record.lease.runtimeFence const sessionId = reserved.record.sessionId await store.commitProcessIdentity({ @@ -130,7 +146,7 @@ async function markLegacyConflicted( lease: Partial = {} ): Promise { if (!store.getRecord('session-alpha')) { - await store.reserveOwner(reserveRequest()) + await reserveFresh(store) } await store.transitionHandoff('session-alpha', (record) => ({ ...record, @@ -154,10 +170,10 @@ afterEach(async () => { describe('acquisition path', () => { it('admits a writer only after reservation, observed identity, and a proved handle', async () => { const store = await open() - const reserved = await store.reserveOwner(reserveRequest()) - expect(reserved.disposition).toBe('created') + const reserved = await reserveFresh(store) + expect(reserved.disposition).toBe('reserved') expect(reserved.record.lease).toMatchObject({ - runtimeFence: 1, + runtimeFence: 2, claimStatus: 'reserved', handoffStage: 'new-owner-proving', ownerProcess: null, @@ -166,18 +182,18 @@ describe('acquisition path', () => { // Proving before the spawn is observed is refused. await expect( - store.proveOwner({ sessionId: 'session-alpha', fence: 1, link: handleLink(), now: NOW }) + store.proveOwner({ sessionId: 'session-alpha', fence: 2, link: handleLink(), now: NOW }) ).rejects.toThrow('agent_session_ownership_unknown') await store.commitProcessIdentity({ sessionId: 'session-alpha', - fence: 1, + fence: 2, process: processIdentity(), now: NOW }) const proved = await store.proveOwner({ sessionId: 'session-alpha', - fence: 1, + fence: 2, link: handleLink(), now: NOW }) @@ -185,18 +201,18 @@ describe('acquisition path', () => { claimStatus: 'live', handoffStage: null, provenHandleLinkId: 'link-1', - runtimeFence: 1 + runtimeFence: 2 }) expect(proved.providerHandleChain).toHaveLength(1) }) it('refuses a child that cannot echo the reserved spawn token', async () => { const store = await open() - await store.reserveOwner(reserveRequest()) + await reserveFresh(store) await expect( store.commitProcessIdentity({ sessionId: 'session-alpha', - fence: 1, + fence: 2, process: processIdentity({ spawnToken: 'spawn-other' }), now: NOW }) @@ -205,10 +221,10 @@ describe('acquisition path', () => { it('accepts provider proof only for the reserved provider at the current fence', async () => { const store = await open() - await store.reserveOwner(reserveRequest()) + await reserveFresh(store) await store.commitProcessIdentity({ sessionId: 'session-alpha', - fence: 1, + fence: 2, process: processIdentity(), now: NOW }) @@ -216,7 +232,7 @@ describe('acquisition path', () => { await expect( store.proveOwner({ sessionId: 'session-alpha', - fence: 1, + fence: 2, link: handleLink({ handle: { provider: 'codex', threadId: 'thread-1' } }), @@ -226,8 +242,8 @@ describe('acquisition path', () => { await expect( store.proveOwner({ sessionId: 'session-alpha', - fence: 1, - link: handleLink({ mintedAtFence: 2 }), + fence: 2, + link: handleLink({ mintedAtFence: 3 }), now: NOW }) ).rejects.toThrow('agent_session_provider_handle_stale_fence') @@ -240,7 +256,7 @@ describe('acquisition path', () => { await expect( store.proveOwner({ sessionId: 'session-alpha', - fence: 1, + fence: 2, link: handleLink({ linkId: 'link-2', origin: 'resumed', @@ -251,15 +267,15 @@ describe('acquisition path', () => { ).rejects.toThrow('agent_session_ownership_unknown') }) - it('refuses a create that carries a fence and a re-create that does not', async () => { + it('refuses a start of a record no create founded, and one from a fence the record left', async () => { const store = await open() - await expect(store.reserveOwner(reserveRequest({ expectedFence: 0 }))).rejects.toThrow( - 'agent_session_checkpoint_stale' - ) + await expect(store.reserveOwner(reserveRequest())).rejects.toMatchObject({ + refusal: { code: 'agent_session_checkpoint_stale', details: { reason: 'recordMissing' } } + }) await establishOwner(store) - await expect(store.reserveOwner(reserveRequest({ expectedFence: null }))).rejects.toThrow( - 'agent_session_conflict' - ) + await expect( + store.reserveOwner(reserveRequest({ probe: { outcome: 'pid-absent' } })) + ).rejects.toThrow('agent_session_checkpoint_stale') }) it.each([ @@ -275,7 +291,7 @@ describe('acquisition path', () => { store.reserveOwner( reserveRequest({ ...overrides, - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) @@ -290,7 +306,7 @@ describe('concurrent claims', () => { await establishOwner(store) const request = () => reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, spawnToken: 'spawn-b', operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } @@ -303,37 +319,44 @@ describe('concurrent claims', () => { expect(granted).toHaveLength(1) const refused = results.find((result) => result.status === 'rejected') expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_checkpoint_stale') - expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(3) }) - it('serializes concurrent creates of the same session id', async () => { + it('serializes concurrent first starts of one founded session', async () => { const store = await open() + const { sessionId, location, provider, accountHome, claimKeyId } = reserveRequest() + await foundTestAgentSessionRecord(store, { + sessionId, + location, + provider, + accountHome, + claimKeyId, + now: NOW + }) const results = await Promise.allSettled([ store.reserveOwner(reserveRequest()), store.reserveOwner(reserveRequest()) ]) expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) - expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) }) it('replays a retried operation id instead of reserving twice', async () => { const store = await open() const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } - const first = await store.reserveOwner(reserveRequest({ operation })) + const first = await reserveFresh(store, { operation }) const second = await store.reserveOwner(reserveRequest({ operation })) expect(second.disposition).toBe('replayed') expect(second.record.lease.runtimeFence).toBe(first.record.lease.runtimeFence) - expect(store.listOperationRows()).toHaveLength(1) + expect(clientRows(store)).toHaveLength(1) }) it('refuses the same operation id carrying different parameters', async () => { const store = await open() const operationId_ = operationId() - await store.reserveOwner( - reserveRequest({ - operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-1' } - }) - ) + await reserveFresh(store, { + operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-1' } + }) await expect( store.reserveOwner( reserveRequest({ @@ -349,10 +372,10 @@ describe('concurrent claims', () => { await establishOwner(store) const before = store.getRecord('session-alpha') await expect( - store.reserveOwner(reserveRequest({ expectedFence: 1, probe: INDETERMINATE })) + store.reserveOwner(reserveRequest({ expectedFence: 2, probe: INDETERMINATE })) ).rejects.toThrow('agent_session_ownership_unknown') expect(store.getRecord('session-alpha')).toEqual(before) - expect(store.listOperationRows()).toHaveLength(1) + expect(clientRows(store)).toHaveLength(1) }) it('never keeps a change in memory that failed to commit to disk', async () => { @@ -381,7 +404,7 @@ describe('expiry is not eviction', () => { await expect( store.reserveOwner( reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe, now: wellPastDeadline, operation: { @@ -393,16 +416,16 @@ describe('expiry is not eviction', () => { ) ).rejects.toThrow(/agent_session_(ownership_unknown|conflict)/) } - expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) // Only proof of death moves it. const evicted = await store.evictProvenDeadOwner({ sessionId: 'session-alpha', - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, now: wellPastDeadline }) - expect(evicted.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released' }) + expect(evicted.lease).toMatchObject({ runtimeFence: 3, claimStatus: 'released' }) expect(evicted.lease.deathEvidence?.kind).toBe('pid-absent') }) @@ -412,7 +435,7 @@ describe('expiry is not eviction', () => { await expect( store.evictProvenDeadOwner({ sessionId: 'session-alpha', - expectedFence: 1, + expectedFence: 2, probe: INDETERMINATE, now: NOW }) @@ -424,7 +447,7 @@ describe('expiry is not eviction', () => { await establishOwner(store) const renewed = await store.renewLease({ sessionId: 'session-alpha', - fence: 1, + fence: 2, childProbe: MATCHED, now: NOW + 5_000 }) @@ -432,7 +455,7 @@ describe('expiry is not eviction', () => { await expect( store.renewLease({ sessionId: 'session-alpha', - fence: 1, + fence: 2, childProbe: { outcome: 'identity-matched', matchedOn: [] }, now: NOW + 10_000 }) @@ -455,12 +478,12 @@ describe('restart reconciliation', () => { }) // Every mutating path is closed while unreconciled. await expect( - reopened.renewLease({ sessionId: 'session-alpha', fence: 1, childProbe: MATCHED, now: NOW }) + reopened.renewLease({ sessionId: 'session-alpha', fence: 2, childProbe: MATCHED, now: NOW }) ).rejects.toThrow('execution_owner_reconciling') await expect( reopened.reserveOwner( reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) @@ -476,7 +499,7 @@ describe('restart reconciliation', () => { const record = reopened.getRecord('session-alpha') expect(record?.lease).toMatchObject({ unreconciled: false, - runtimeFence: 1, + runtimeFence: 2, claimStatus: 'live' }) expect(record?.lease.provenHandleLinkId).toBe('link-1') @@ -505,7 +528,7 @@ describe('restart reconciliation', () => { expect(await stale).toEqual(new Map()) const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ - runtimeFence: 1, + runtimeFence: 2, claimStatus: 'live', ownerProcess: { pid: 4242, spawnToken: 'spawn-a' }, unreconciled: false @@ -517,7 +540,7 @@ describe('restart reconciliation', () => { await establishOwner(first) await first.evictProvenDeadOwner({ sessionId: 'session-alpha', - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'exit-observed' }, now: NOW }) @@ -525,7 +548,7 @@ describe('restart reconciliation', () => { const second = await open() await second.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) const afterRestart = second.getRecord('session-alpha')?.lease.runtimeFence ?? 0 - expect(afterRestart).toBeGreaterThanOrEqual(2) + expect(afterRestart).toBeGreaterThanOrEqual(3) const reacquired = await second.reserveOwner( reserveRequest({ @@ -552,12 +575,12 @@ describe('restart reconciliation', () => { const reopened = await open() await reopened.reconcileOnRestart({ probe: async () => INDETERMINATE, now: NOW + 1_000 }) const lease = reopened.getRecord('session-alpha')?.lease - expect(lease).toMatchObject({ handoffStage: 'recovering', runtimeFence: 1 }) + expect(lease).toMatchObject({ handoffStage: 'recovering', runtimeFence: 2 }) expect(lease?.ownerProcess).not.toBeNull() await expect( reopened.reserveOwner( reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', @@ -621,7 +644,7 @@ describe('restart reconciliation', () => { }) const reacquired = await reopened.reserveOwner( reserveRequest({ - expectedFence: lease?.runtimeFence ?? null, + expectedFence: lease?.runtimeFence ?? 0, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) @@ -631,12 +654,12 @@ describe('restart reconciliation', () => { it('frees a reservation that provably never spawned', async () => { const first = await open() - await first.reserveOwner(reserveRequest()) + await reserveFresh(first) const reopened = await open() await reopened.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ claimStatus: 'released', - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null }) }) @@ -644,7 +667,7 @@ describe('restart reconciliation', () => { it('carries the operation ledger across a restart so a retry is still a replay', async () => { const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } const first = await open() - await first.reserveOwner(reserveRequest({ operation })) + await reserveFresh(first, { operation }) await first.recordOperationOutcome({ callerKey: operation.callerKey, operationId: operation.operationId, @@ -652,7 +675,7 @@ describe('restart reconciliation', () => { }) const reopened = await open() - expect(reopened.listOperationRows()).toHaveLength(1) + expect(clientRows(reopened)).toHaveLength(1) const replayed = await reopened.reserveOwner(reserveRequest({ operation })) expect(replayed.disposition).toBe('replayed') expect(replayed.record.sessionId).toBe('session-alpha') @@ -672,7 +695,7 @@ describe('host and workspace isolation', () => { store.reserveOwner( reserveRequest({ location, - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0fd..ae5a0de96668 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,13 +70,18 @@ import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-sess import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import { AgentSessionRecordStoreCommitListeners } from './agent-session-record-store-commit-listeners' +import { + commitAgentSessionAtRestCreate, + type AgentSessionAtRestCreateRequest, + type AgentSessionAtRestCreateResult +} from './agent-session-at-rest-create' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 export class AgentSessionRecordStore { - private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() - private readonly firstRecordListeners = new Set<() => void>() + private readonly commitListeners = new AgentSessionRecordStoreCommitListeners() private constructor( private readonly transactions: AgentSessionStoreTransactions, @@ -164,6 +169,12 @@ export class AgentSessionRecordStore { commitConversationClear = (clear: AgentSessionConversationClear): Promise => this.transact((draft) => commitConversationClearRecord(draft, clear)) + /** A new chat at rest and the operation row its create answers retries from, in one write. */ + createAtRest = ( + request: AgentSessionAtRestCreateRequest + ): Promise => + this.transact((draft) => commitAgentSessionAtRestCreate(draft, request)) + /** Unfenced on purpose: the name is a durable note, so writing it never contends with the * writer lease. `null` clears it. */ setConversationName = (sessionId: string, name: string | null): Promise => @@ -330,16 +341,12 @@ export class AgentSessionRecordStore { /** Told, once committed, of each session a transaction wrote a proof of death for — whichever * transition wrote it, since every one lands here. Must not throw. */ - onDeathEvidence(listener: (sessionId: string) => void): () => void { - this.deathEvidenceListeners.add(listener) - return () => this.deathEvidenceListeners.delete(listener) - } + onDeathEvidence = (listener: (sessionId: string) => void): (() => void) => + this.commitListeners.onDeathEvidence(listener) /** Told, once committed, when the store records its first chat. Must not throw. */ - onFirstRecord(listener: () => void): () => void { - this.firstRecordListeners.add(listener) - return () => this.firstRecordListeners.delete(listener) - } + onFirstRecord = (listener: () => void): (() => void) => + this.commitListeners.onFirstRecord(listener) /** Serializes every mutation. `apply` changes only the draft it is given; readers see the change * once its rows have committed. */ @@ -347,28 +354,13 @@ export class AgentSessionRecordStore { apply: (draft: AgentSessionStoreState) => T, options?: { inMemoryWhenReadOnly?: boolean } ): Promise => { - let proven: string[] = [] - let heldBefore = true + let notify = (): void => {} const result = await this.transactions.transact((draft) => { - heldBefore = this.holdsRecords() - if (this.deathEvidenceListeners.size === 0) { - return apply(draft) - } - const before = new Map( - [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) - ) - const applied = apply(draft) - proven = [...draft.records] - .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) - .map(([id]) => id) - return applied + const watched = this.commitListeners.watch(this.holdsRecords, draft, apply) + notify = watched.notify + return watched.result }, options) - for (const sessionId of proven) { - this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) - } - if (!heldBefore && this.holdsRecords()) { - this.firstRecordListeners.forEach((listener) => listener()) - } + notify() return result } } diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e5f54265dcb7..f512783cb0ec 100644 --- a/src/main/runtime/agent-session-released-reservation-replay.test.ts +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -1,4 +1,4 @@ -// A create the host was running when it died is retried under the same operation id. Recovery has +// A start the host was running when it died is retried under the same operation id. Recovery has // released its reservation by then, so the retry continues it at the next fence; a reservation that // is still held keeps answering exactly as it did. @@ -13,7 +13,10 @@ import { import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionLease } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -37,7 +40,8 @@ function open(): Promise { return openTestAgentSessionRecordStore(directory) } -function createRequest( +/** The start of a record its create founded at fence 1. */ +function startRequest( overrides: Partial = {} ): AgentSessionReserveRequest { return { @@ -50,7 +54,7 @@ function createRequest( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, + expectedFence: 1, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: OPERATION, @@ -61,21 +65,21 @@ function createRequest( } } -/** The create reserved, then a restart that could prove nothing released it at fence 2. */ +/** The start reserved, then a restart that could prove nothing released it at fence 3. */ async function releasedByRestart(now: number): Promise { const first = await open() - await first.reserveOwner(createRequest()) + await foundAndReserveTestAgentSessionRecord(first, startRequest()) const store = await open() await store.reconcileOnRestart({ probe: async () => INDETERMINATE, now }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2 + runtimeFence: 3 }) return store } -describe('a create retried after recovery released its reservation', () => { +describe('a start retried after recovery released its reservation', () => { it.each([ ['its operation row is still pending', 1_000], ['its operation row has expired', PAST_EXPIRY] @@ -83,39 +87,39 @@ describe('a create retried after recovery released its reservation', () => { const now = NOW + elapsed const store = await releasedByRestart(now) - const continued = await store.reserveOwner(createRequest({ spawnToken: () => 'spawn-b', now })) + const continued = await store.reserveOwner(startRequest({ spawnToken: () => 'spawn-b', now })) expect(continued.disposition).toBe('reserved') expect(continued.record.lease).toMatchObject({ claimStatus: 'reserved', handoffStage: 'new-owner-proving', - runtimeFence: 3, + runtimeFence: 4, reservedSpawnToken: 'spawn-b', handoffOperationId: OPERATION }) - expect(store.listOperationRows()).toEqual([ + expect(store.listOperationRows().filter((row) => row.callerKey === 'client-1')).toEqual([ expect.objectContaining({ operationId: OPERATION, outcome: { status: 'pending' } }) ]) // A spawn from the first reservation reports in late: refused at its fence, and by token. const lateSpawn = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' } await expect( - store.commitProcessIdentity({ sessionId: SESSION, fence: 1, process: lateSpawn, now }) + store.commitProcessIdentity({ sessionId: SESSION, fence: 2, process: lateSpawn, now }) ).rejects.toThrow('agent_session_checkpoint_stale') await expect( - store.commitProcessIdentity({ sessionId: SESSION, fence: 3, process: lateSpawn, now }) + store.commitProcessIdentity({ sessionId: SESSION, fence: 4, process: lateSpawn, now }) ).rejects.toThrow('agent_session_ownership_unknown') // Retried again while that reservation stands: the same reservation, never a second spawn. const mint = vi.fn(() => 'spawn-c') - const retried = await store.reserveOwner(createRequest({ spawnToken: mint, now })) + const retried = await store.reserveOwner(startRequest({ spawnToken: mint, now })) expect(retried.disposition).toBe('replayed') - expect(retried.record.lease).toMatchObject({ runtimeFence: 3, reservedSpawnToken: 'spawn-b' }) + expect(retried.record.lease).toMatchObject({ runtimeFence: 4, reservedSpawnToken: 'spawn-b' }) expect(mint).not.toHaveBeenCalled() }) it('refuses an expired retry of a session that has no record', async () => { const store = await open() - await expect(store.reserveOwner(createRequest({ now: NOW + PAST_EXPIRY }))).rejects.toThrow( + await expect(store.reserveOwner(startRequest({ now: NOW + PAST_EXPIRY }))).rejects.toThrow( 'agent_session_operation_expired' ) }) @@ -125,30 +129,30 @@ describe('a create retried after recovery released its reservation', () => { ['one nothing could verify', INDETERMINATE, 'ownership_unknown'] ] as const)('still refuses a retry once its child is live and %s', async (_case, probe, code) => { const store = await open() - await store.reserveOwner(createRequest()) + await foundAndReserveTestAgentSessionRecord(store, startRequest()) await store.commitProcessIdentity({ sessionId: SESSION, - fence: 1, + fence: 2, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, now: NOW }) await store.proveOwner({ sessionId: SESSION, - fence: 1, + fence: 2, link: { linkId: 'link-1', handle: { provider: 'codex', threadId: 'thread-1' }, origin: 'created', - mintedAtFence: 1, + mintedAtFence: 2, observedAt: NOW }, now: NOW }) - await expect(store.reserveOwner(createRequest({ probe }))).rejects.toThrow( + await expect(store.reserveOwner(startRequest({ probe }))).rejects.toThrow( `agent_session_${code}` ) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeFence: 1 }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeFence: 2 }) }) it.each<[string, Partial, string]>([ @@ -160,13 +164,13 @@ describe('a create retried after recovery released its reservation', () => { ] ])('still refuses a retry whose reservation is %s', async (_case, lease, code) => { const store = await open() - await store.reserveOwner(createRequest()) + await foundAndReserveTestAgentSessionRecord(store, startRequest()) await store.transitionHandoff(SESSION, (record) => ({ ...record, lease: { ...record.lease, ...lease } })) - await expect(store.reserveOwner(createRequest())).rejects.toThrow(code) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(1) + await expect(store.reserveOwner(startRequest())).rejects.toThrow(code) + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(2) }) }) diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index cad534931e60..83d6827c1584 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -1,4 +1,5 @@ -// Adoption admission inside the reservation transaction: which conversation a new record may claim. +// Reservation admission for an agent start: the record its create founded, at the fence the start +// read. Founding, adoption and tab ids are the create's; see agent-session-at-rest-create. import { describe, expect, it } from 'vitest' import { @@ -10,7 +11,6 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { applyAgentSessionReservation, type AgentSessionReserveRequest @@ -28,29 +28,15 @@ const LOCATION: AgentSessionExecutionLocation = { } const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } -/** The link an adopting create seeds: fence 1, because that is a new record's first. */ -function adoptedLink( - overrides: Partial = {} -): AgentSessionProviderHandleLink { - return { - linkId: 'claude-1-provider-session-alpha-1-empty', - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, - origin: 'adopted', - mintedAtFence: 1, - observedAt: NOW, - ...overrides - } -} - function reserveRequest( overrides: Partial = {} ): AgentSessionReserveRequest { return { - sessionId: 'session-adopting', + sessionId: 'session-starting', location: LOCATION, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, + expectedFence: 2, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -73,153 +59,42 @@ function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionSt } } -describe('adopted handle chain seeding', () => { - it('seeds a new record with the adopted link alone, at the first fence of the record', () => { - const link = adoptedLink() - const { record, disposition } = applyAgentSessionReservation( - storeState(), - reserveRequest({ adoptedHandleLink: link }), - LEASE_TTL_MS - ) - - expect(disposition).toBe('created') - expect(record.providerHandleChain).toEqual([link]) - // The owner probe requires the head link to carry the record's current fence. - expect(record.providerHandleChain[0]?.mintedAtFence).toBe(record.lease.runtimeFence) - }) - - it('leaves a blank create with no chain, so the adapter starts a conversation', () => { - const { record } = applyAgentSessionReservation(storeState(), reserveRequest(), LEASE_TTL_MS) - - expect(record.providerHandleChain).toEqual([]) - }) -}) - -describe('adopted conversation ownership', () => { - it('refuses when another record already holds the same conversation root', () => { - // The held link names a leaf; the adoption names none. Same root is the whole test: keying on - // the exact handle would let two writers onto one conversation on different branches. - const holder = agentSessionRecordFixture() - - expect(() => - applyAgentSessionReservation( - storeState([holder]), - reserveRequest({ adoptedHandleLink: adoptedLink() }), - LEASE_TTL_MS - ) - ).toThrow('agent_session_conflict') - }) - - it('admits an adoption of a conversation no record holds', () => { - const holder = agentSessionRecordFixture() +/** A record whose last start ended and released its lease at fence 2. */ +function released(): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: 'session-starting', + runtimeKind: 'native', + runtimeFence: 2, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'the start failed', observedAt: 1 } + }) + ), + location: LOCATION, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandleChain: [] + } +} +describe('reserving a start', () => { + it('refuses a session no create founded, and one whose record this build cannot read', () => { expect(() => - applyAgentSessionReservation( - storeState([holder]), - reserveRequest({ - adoptedHandleLink: adoptedLink({ - handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } - }) - }), - LEASE_TTL_MS - ) - ).not.toThrow() - }) - - it('exempts the requesting session so a committed create can be re-run', () => { - // Pins the guard's own contract. No wire shape reaches it today: `adopt` is accepted only on - // create-by-intent, which always carries a null expected fence, and an existing record with a - // null expected fence is refused a few lines below anyway. - const link = adoptedLink() - const committed: AgentSessionRecord = { - ...agentSessionRecordFixture( - agentSessionLeaseFixture({ - sessionId: 'session-adopting', - runtimeFence: 1, - handoffStage: 'new-owner-proving', - claimStatus: 'reserved', - ownerProcess: null, - provenHandleLinkId: null, - handoffOperationId: 'handoff-1' - }) - ), - location: LOCATION, - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandleChain: [link] - } - - const { record, disposition } = applyAgentSessionReservation( - storeState([committed]), - reserveRequest({ - adoptedHandleLink: link, - expectedFence: 1, - handoffOperationId: 'handoff-1' - }), - LEASE_TTL_MS + applyAgentSessionReservation(storeState(), reserveRequest(), LEASE_TTL_MS) + ).toThrow('agent_session_checkpoint_stale') + const unreadable = storeState() + unreadable.unreadableRecords.set('session-starting', { reason: 'invalid', raw: {} }) + expect(() => applyAgentSessionReservation(unreadable, reserveRequest(), LEASE_TTL_MS)).toThrow( + 'execution_owner_reconciling' ) - - expect(disposition).toBe('retry-reservation') - expect(record.providerHandleChain).toEqual([link]) - }) - - it('refuses a Codex adoption another record already holds', () => { - const holder: AgentSessionRecord = { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-codex' })), - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - providerHandleChain: [ - { - linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, - origin: 'created', - mintedAtFence: 7, - observedAt: NOW - } - ] - } - - expect(() => - applyAgentSessionReservation( - storeState([holder]), - reserveRequest({ - sessionId: 'session-codex-adopting', - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - adoptedHandleLink: adoptedLink({ - linkId: 'codex-1-thread-1-adopted', - handle: { provider: 'codex', threadId: 'thread-1' } - }) - }), - LEASE_TTL_MS - ) - ).toThrow('agent_session_conflict') }) -}) -describe('re-create over a failed create', () => { - const EXITED = agentSessionLeaseFixture({ - sessionId: 'session-adopting', - runtimeKind: 'native', - runtimeFence: 2, - provenHandleLinkId: null, - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed', detail: 'the create failed', observedAt: 1 } - }) - function failedCreate(overrides: Partial = {}): AgentSessionRecord { - return { - ...agentSessionRecordFixture(EXITED), - location: LOCATION, - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandleChain: [], - ...overrides - } - } - - it('reserves a record that never bound a conversation and whose attempt is proven gone', () => { + it('reserves a released record one fence past the fence its start read', () => { const { record, disposition } = applyAgentSessionReservation( - storeState([failedCreate()]), + storeState([released()]), reserveRequest(), LEASE_TTL_MS ) @@ -228,18 +103,13 @@ describe('re-create over a failed create', () => { expect(record.lease).toMatchObject({ claimStatus: 'reserved', runtimeFence: 3 }) }) - it('refuses when the record bound a conversation, or its attempt may still run', () => { - const bound = failedCreate({ providerHandleChain: [adoptedLink()] }) - const unproven = failedCreate({ - lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'recovering' } - }) - // Released so a send can start over, but nothing proved the attempt gone. - const releasedUnproven = failedCreate({ lease: { ...EXITED, deathEvidence: null } }) - - for (const record of [bound, unproven, releasedUnproven]) { - expect(() => - applyAgentSessionReservation(storeState([record]), reserveRequest(), LEASE_TTL_MS) - ).toThrow('agent_session_conflict') - } + it('refuses a start that read a fence the record has since left', () => { + expect(() => + applyAgentSessionReservation( + storeState([released()]), + reserveRequest({ expectedFence: 1 }), + LEASE_TTL_MS + ) + ).toThrow('agent_session_checkpoint_stale') }) }) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index db5707d3b4d8..7b2337038214 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -20,7 +20,6 @@ import { } from '../../shared/agent-session-operation-ledger' import { agentSessionLeaseIsReleased, - agentSessionLeaseOwnerVerdict, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -35,18 +34,12 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args' -import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' -import { - agentSessionProviderHandleRoot, - type AgentSessionHandleProvider, - type AgentSessionProviderHandleLink -} from '../../shared/agent-session-provider-handle' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' import { reserveAgentSessionOwner, type AgentSessionReservation } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { sessionId: string @@ -59,14 +52,8 @@ export type AgentSessionReserveRequest = { launchEnv?: AgentSessionLaunchEnv /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> - /** The tab id a create reserved for this conversation, taken when its tab is published. An id - * another session's tab holds is refused here, before anything is spawned. */ - surfaceTabId?: string - /** Set only when this create adopts an existing provider conversation. Seeds the handle chain so - * the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */ - adoptedHandleLink?: AgentSessionProviderHandleLink - /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ - expectedFence: number | null + /** The fence of the record this start read. A record is founded by its create, never here. */ + expectedFence: number /** A supplier is invoked only when this operation wins a new reservation. */ spawnToken: string | (() => string) claimKeyId: string @@ -77,11 +64,7 @@ export type AgentSessionReserveRequest = { leaseTtlMs?: number } -export type AgentSessionReserveDisposition = - | 'created' - | 'reserved' - | 'retry-reservation' - | 'replayed' +export type AgentSessionReserveDisposition = 'reserved' | 'retry-reservation' | 'replayed' export type AgentSessionReserveResult = { record: AgentSessionRecord @@ -91,7 +74,7 @@ export type AgentSessionReserveResult = { export function evaluateAgentSessionReserveOperation( state: AgentSessionStoreState, - request: AgentSessionReserveRequest + request: Pick ): AgentSessionOperationDecision { state.operations = pruneAgentSessionOperationRows(state.operations, request.now) return evaluateAgentSessionOperation({ @@ -165,21 +148,11 @@ export function applyAgentSessionReservation( leaseTtlMs: request.leaseTtlMs ?? leaseTtlMs, now: request.now } - // Inside the transaction, not only in the RPC resolver: two concurrent adoptions of one - // conversation mint different session ids, so the compare-and-swap never collides and a - // pre-commit check passes for both. Codex would then hold one thread from two app-servers, which - // it permits silently and which corrupts the conversation rather than erroring. - assertAdoptedConversationUnowned(state, request) const existing = state.records.get(request.sessionId) if (!existing) { - if (state.unreadableRecords.has(request.sessionId)) { - throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) - } - if (request.expectedFence !== null) { - throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) - } - assertReservedTabUnheld(state, request) - return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } + throw state.unreadableRecords.has(request.sessionId) + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } if ( !agentSessionExecutionLocationsEqual(existing.location, request.location) || @@ -190,16 +163,6 @@ export function applyAgentSessionReservation( // Why: location, provider, and account are the session identity; changing one is a fork. throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) } - // A create may take over only a record that never bound a conversation and whose last - // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. - const recreatable = - existing.providerHandleChain.length === 0 && - !request.adoptedHandleLink && - agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' - if (request.expectedFence === null && !recreatable) { - throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) - } - assertReservedTabUnheld(state, request) const pinned = { ...existing, ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), @@ -207,102 +170,12 @@ export function applyAgentSessionReservation( } return reserveAgentSessionOwner({ record: pinned, - expectedFence: request.expectedFence ?? existing.lease.runtimeFence, + expectedFence: request.expectedFence, probe: request.probe, reservation }) } -/** - * Refuse an adoption whose conversation ANOTHER record already holds. - * - * The self-exemption is part of that definition, not a replay mechanism: replay is settled earlier - * by the operation ledger, and an adoption always arrives with a null expected fence, so a request - * naming an existing session id is refused a few lines below regardless. Keeping the scan scoped to - * other records is what makes this guard mean what its name says. - * - * It runs inside the store transaction because the pre-commit check in the RPC resolver cannot be - * the guard: two concurrent adoptions of one conversation mint different session ids, so the - * compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread - * silently, so the cost of missing this is a corrupted conversation rather than an error. - */ -function assertAdoptedConversationUnowned( - state: AgentSessionStoreState, - request: AgentSessionReserveRequest -): void { - const adopted = request.adoptedHandleLink - if (!adopted) { - return - } - const root = agentSessionProviderHandleRoot(adopted.handle) - for (const record of state.records.values()) { - if (record.sessionId === request.sessionId) { - continue - } - const holdsSameConversation = record.providerHandleChain.some( - (link) => agentSessionProviderHandleRoot(link.handle) === root - ) - if (holdsSameConversation) { - throw agentSessionRefusalError('agent_session_conflict', { - reason: 'conversationHeldElsewhere' - }) - } - } -} - -/** - * A tab id names one conversation, so a reserved id another session's tab holds is a conflict. - * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never - * gets that far leaves nothing in the table to restore or release. - */ -function assertReservedTabUnheld( - state: AgentSessionStoreState, - request: AgentSessionReserveRequest -): void { - if (request.surfaceTabId === undefined) { - return - } - if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'requestMalformed' - }) - } - const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) - if (holder !== undefined && holder !== request.sessionId) { - throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) - } -} - -function createAgentSessionRecord( - request: AgentSessionReserveRequest, - reservation: AgentSessionReservation -): AgentSessionRecord { - return { - ...agentSessionRecordIdentityFields(request, request.now), - // Fence 1 below is this record's first, and the owner probe requires the head link to carry the - // record's current fence — so an adopted link must be minted at that same fence. - providerHandleChain: request.adoptedHandleLink ? [request.adoptedHandleLink] : [], - lease: { - sessionId: request.sessionId, - runtimeKind: 'native', - // Why: fence 1 is the first reservation; 0 is reserved for "no owner has ever existed". - runtimeFence: 1, - handoffStage: 'new-owner-proving', - provenHandleLinkId: null, - ownerProcess: null, - reservedSpawnToken: reservation.spawnToken, - leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, - lastRenewedAt: reservation.now, - handoffOperationId: reservation.handoffOperationId, - journalCheckpoint: null, - claimKeyId: reservation.claimKeyId, - claimStatus: 'reserved', - unreconciled: false, - deathEvidence: null - } - } -} - /** * Compare-and-swap reservation plus its client-operation row, committed together. A replayed * operation returns the recorded outcome and never reaches the reservation. diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e028..dcd58702117e 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -13,6 +13,7 @@ import { } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundAndReserveTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' @@ -37,7 +38,7 @@ async function liveChat( sessionId: string ): Promise { counter += 1 - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId, location: { executionHostId: 'local', @@ -47,7 +48,6 @@ async function liveChat( }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, spawnToken: `spawn-${counter}`, claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index 31e473fb5e66..fc059e3411bb 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -27,7 +30,7 @@ function reserveRequest( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, + expectedFence: 1, spawnToken: `spawn-${operations}`, claimKeyId: 'key-1', handoffOperationId: null, @@ -42,13 +45,16 @@ function reserveRequest( } } -/** Reserve, observe the spawn and prove the link the adapter reported, as the host does. */ +/** Reserve (founding the record first, as its create does), observe the spawn and prove the link + * the adapter reported, as the host does. */ async function prove( store: AgentSessionRecordStore, request: AgentSessionReserveRequest, link: (fence: number) => AgentSessionProviderHandleLink ) { - const { record } = await store.reserveOwner(request) + const { record } = store.getRecord(SESSION) + ? await store.reserveOwner(request) + : await foundAndReserveTestAgentSessionRecord(store, request) const fence = record.lease.runtimeFence await store.commitProcessIdentity({ sessionId: SESSION, diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts index fd30df7dad0e..1b0a8d84d3e3 100644 --- a/src/main/runtime/claude-structured-exit-mid-response.test.ts +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -11,6 +11,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-exit-mid-response' const CALLER = { callerKey: 'client-1' } @@ -45,7 +46,9 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { it('says Claude stopped, and that the conversation can continue', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await send(host, 'hello') diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index cd4f44d59f8a..e419ee36c541 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-failed-start' const CALLER = { callerKey: 'client-1' } @@ -21,7 +22,9 @@ describe('a Claude chat whose first start died before initialize', () => { it('resumes on reopen, launching its id fresh and continuing the same chain', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) claude.child(SESSION).exit(new Error('claude stream-json exited (code 1): not signed in')) @@ -31,7 +34,8 @@ describe('a Claude chat whose first start died before initialize', () => { // The user signs in and reopens the chat. claude.behave(SESSION, {}) - const reopened = await host.attach( + const reopened = await attachForTests( + host, CALLER, claude.attachParams(SESSION, failed?.lease.runtimeFence ?? null) ) @@ -61,7 +65,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { claude.behave(SESSION, { exitsDuringSpawn: { diagnostic, at } }) const host = await claude.install() - const created = await host.attach(CALLER, claude.attachParams(SESSION, null)) + const created = await attachForTests(host, CALLER, claude.attachParams(SESSION, null)) expect(created).toMatchObject({ ok: false, diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 9fa9cbfcfa6a..f4b81fceb52c 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -15,6 +15,7 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/struct import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-idle-release' const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) @@ -70,7 +71,9 @@ function sessionState(state: 'running' | 'idle'): Record { describe('a live Claude chat whose CLI reports idle', () => { it('releases a send whose write ended in doubt, and leaves a pending one alone', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const child = claude.child(SESSION) @@ -128,7 +131,9 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo ['it went idle with no terminal state', 'idle', DISPATCH_DOUBT_PROVIDER_IDLE] ])('records released doubt when %s', async (_label, ending, reason) => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const child = claude.child(SESSION) @@ -165,7 +170,9 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo it('holds a send it has only queued, through an idle that may precede its start', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const child = claude.child(SESSION) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 2fd3cfa8a13c..ba8bc91bd9ce 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -11,6 +11,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } @@ -42,7 +43,9 @@ function rejectionTexts(events: AgentSessionSubscribeEvent[]): string[] { describe('a reopened Claude chat whose CLI dies before initialize', () => { it('publishes the startup failure, with the diagnostic, to the open chat', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await waitForStructuredAgentSessionRecovery() diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index e941acf10698..2f7824bae6aa 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -14,6 +14,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } @@ -82,7 +83,9 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers a new one once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) @@ -132,7 +135,7 @@ describe('a send while the first Claude start is still answering initialize', () it('is queued, and written once the CLI proves its start', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await host.attach(CALLER, claude.attachParams(SESSION, null)) + await attachForTests(host, CALLER, claude.attachParams(SESSION, null)) await send(host, 'hello') expect(claude.child(SESSION).calls).not.toContain('send') @@ -148,7 +151,7 @@ describe('a send while the first Claude start is still answering initialize', () it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await host.attach(CALLER, claude.attachParams(SESSION, null)) + await attachForTests(host, CALLER, claude.attachParams(SESSION, null)) const startedFence = fence(host) const held = await send(host, 'hello') diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 209f04a69fe4..6aaecff03e75 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -15,6 +15,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-send-restart-dies-first' const CALLER = { callerKey: 'client-1' } @@ -134,7 +135,9 @@ describe('a send whose restarted Claude child dies before it proves its start', it('is rejected with the diagnostic, adds no row, and a new send is one new attempt', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) @@ -175,7 +178,9 @@ describe('a send whose restarted Claude child dies before it proves its start', async (at) => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) @@ -199,7 +204,9 @@ describe('a send whose restarted Claude child dies before it proves its start', it('reaches a subscriber that was open across the restart and the exit', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const events: AgentSessionSubscribeEvent[] = [] @@ -253,7 +260,9 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const unsubscribe = await host.subscribe({ diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index b05d5feadddf..d35958435928 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -77,7 +77,7 @@ function createIntentParams() { return { envelope: envelope('agentSession.create', fields, null), ...fields } } -function ensureParams(fence: number) { +function resolvedCreateParams(fence: number) { const params = { location: { executionHostId: 'local', @@ -204,6 +204,37 @@ function textOf(item: AgentJournalRenderItem): string { : '' } +function message(text: string) { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +/** A message at the record's current fence; the chat's first one is what starts Claude. */ +async function send(text: string): Promise<{ + clientMessageId: string + submission: { dispatchState: string; reason: string | null } +}> { + const body = message(text) + return ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), + body + }) +} + +/** A created chat whose first message has started Claude. */ +async function startedChat(): Promise { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await send('hello') + await vi.waitFor(() => expect(claude.connections).toHaveLength(1)) +} + +/** The submission a message ended as, read from a fresh subscription's snapshot. */ +async function submissionOf(clientMessageId: string) { + const snapshot = (await subscribe()).find((frame) => frame.type === 'snapshot') + return snapshot?.type === 'snapshot' + ? snapshot.page.submissions.find((entry) => entry.clientMessageId === clientMessageId) + : undefined +} + beforeEach(async () => { operations = 0 claudeAuthPolicy = { stripAuthEnv: false } @@ -228,7 +259,7 @@ beforeEach(async () => { getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ - ...ensureParams(1), + ...resolvedCreateParams(1), envelope: input.envelope, providerHandle: undefined }), @@ -320,7 +351,7 @@ describe('a structured Claude session over agentSession.*', () => { CLAUDE_CONFIG_DIR: '/shell/claude' } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const env = claude.live().launch.env expect(env).not.toHaveProperty('ANTHROPIC_API_KEY') @@ -338,7 +369,7 @@ describe('a structured Claude session over agentSession.*', () => { ANTHROPIC_API_KEY: 'sk-ant-SHELL-ONLY' } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() expect(claude.live().launch.env).toMatchObject({ CODEX_LB_API_KEY: 'shell-exported', @@ -352,7 +383,7 @@ describe('a structured Claude session over agentSession.*', () => { recordAccountHomePath = join(homedir(), '.claude') shellEnv = { ...shellEnv, CLAUDE_CONFIG_DIR: '/shell/claude', SHELL_ONLY_MARKER: 'from-shell' } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const env = claude.live().launch.env expect(env).not.toHaveProperty('CLAUDE_CONFIG_DIR') @@ -364,111 +395,79 @@ describe('a structured Claude session over agentSession.*', () => { shellEnv = { ...shellEnv, CODEX_LB_API_KEY: 'shell-exported', LISTED_ONLY: 'yes' } shellEnvironmentPolicy = { inheritAll: false, names: ['LISTED_ONLY'] } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const env = claude.live().launch.env expect(env?.LISTED_ONLY).toBe('yes') expect(env).not.toHaveProperty('CODEX_LB_API_KEY') }) - it('refuses a create whose configured env overrides the pinned managed account auth', async () => { + it('rejects the first message when the configured env overrides the pinned managed account auth', async () => { claudeAuthPolicy = { stripAuthEnv: true } // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const params = createIntentParams() - const sentence = - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - const refused = await call('agentSession.create', params) - - // The thrown answer keeps its wire code; only its words are the ones its replay reads. - expect(refused).toMatchObject({ - ok: false, - error: { code: 'runtime_error', message: sentence } - }) - // Its replay reads the same sentence, beside the situation it names. - expect(await call('agentSession.create', params)).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'managedAccountEnvOverride' }, - message: sentence - } - } - }) + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + + const sent = await send('hello') + + // Only the person can remove those variables, so the message ends at once with that sentence. + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + reason: + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', + rejection: { kind: 'managedAccountEnvOverride' } + }) + ) // Refused before spawn: no provider child was ever opened. expect(claude.connections).toHaveLength(0) }) - it('publishes, then ends the session quietly when initialization has no credentials', async () => { + it('rejects the first message, adding no row, when initialization has no credentials', async () => { claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) - - // The create answers once the child is spawned; the missing credentials arrive after. await ok<{ fence: number }>('agentSession.create', createIntentParams()) - await waitForStructuredAgentSessionRecovery() - // No message waited on this start, so the empty chat says nothing. - expect(itemsOf(await subscribe()).filter((item) => item.body?.kind === 'status')).toEqual([]) - expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) - // A failed start is not auto-resumed into the same failure. - expect(claude.connections).toHaveLength(1) - - // The first message starts it again and says why; signing in is the person's to do, so it is - // not tried again on a timer and adds no row. - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - const sent = await ok<{ clientMessageId: string }>('agentSession.send', { - envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), - body - }) + // The first message starts Claude and says why it could not; signing in is the person's to do, + // so it is not tried again on a timer and adds no row. + const sent = await send('hello') await waitForStructuredAgentSessionRecovery() await vi.waitFor(async () => { - const frames = await subscribe() - const snapshot = frames.find((frame) => frame.type === 'snapshot') - expect( - snapshot?.type === 'snapshot' - ? snapshot.page.submissions.find( - (entry) => entry.clientMessageId === sent.clientMessageId - ) - : undefined - ).toMatchObject({ + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'rejected', reason: 'Claude is not signed in for the selected account. Sign in, then send your message again.', rejection: { kind: 'notSignedIn' } }) - expect(itemsOf(frames).filter((item) => item.body?.kind === 'status')).toEqual([]) + expect(itemsOf(await subscribe()).filter((item) => item.body?.kind === 'status')).toEqual([]) }) - expect(claude.connections).toHaveLength(2) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + // A failed start is not auto-resumed into the same failure. + expect(claude.connections).toHaveLength(1) }) // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', + 'releases a chat whose CLI self-exited starting for its first message with its tree %s, said on the message', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', exitVerdict: { root: 'exited', tree } }) + await ok<{ fence: number }>('agentSession.create', createIntentParams()) - const failed = await call('agentSession.create', createIntentParams()) - - // Answered once, as the refusal a replay of this operation gives, never thrown first. - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - // The CLI's stderr is log text; the person reads what the chat's start failure says. - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } - }) + const sent = await send('hello') + + // The CLI's stderr is log text; the person reads what the start failure says, on the message. + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + reason: 'Claude stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } + }) + ) const lease = leaseOf(SESSION) - // Latching here would refuse every later attach with agent_session_ownership_unknown, + // Latching here would refuse every later start with agent_session_ownership_unknown, // wedging a user who only needs to sign in. expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) expect(lease.deathEvidence).toMatchObject({ @@ -477,31 +476,30 @@ describe('a structured Claude session over agentSession.*', () => { }) claude.setSelfExit(null) - // Signing in and reopening the chat works: the reservation was not latched. - await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) + // Signing in and sending again works: the reservation was not latched. + await send('again') + await vi.waitFor(() => expect(claude.live().sent.length).toBeGreaterThan(0)) } ) - it('answers a create whose whole CLI tree exited as exited on the first call', async () => { + it('rejects the first message when the whole CLI tree exited starting for it', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', // The common case: the close ladder proves the root and every descendant gone. exitVerdict: { root: 'exited', tree: 'exited' } }) + // The create answers at once; nothing has started yet. + await ok<{ fence: number }>('agentSession.create', createIntentParams()) - const failed = await call('agentSession.create', createIntentParams()) + const sent = await send('hello') - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } - }) + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + reason: 'Claude stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } + }) + ) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) claude.setSelfExit(null) }) @@ -513,7 +511,13 @@ describe('a structured Claude session over agentSession.*', () => { exitVerdict: { root: 'live', tree: 'unverifiable' } }) - await call('agentSession.create', createIntentParams()) + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const sent = await send('hello') + // A start whose process could not be proven gone books no other try: the message ends. + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(claude.connections).toHaveLength(1) claude.setSelfExit(null) // The adapter closed the stdio of what it spawned, and no owner was recorded to stop. The next @@ -528,7 +532,7 @@ describe('a structured Claude session over agentSession.*', () => { it.each(['unverifiable', 'live'] as const)( 'restarts a chat whose stop saw the Claude root exit with its tree %s', async (tree) => { - await ok('agentSession.create', createIntentParams()) + await startedChat() const first = claude.live() first.exitVerdict = { root: 'exited', tree } first.close = async () => { @@ -554,7 +558,7 @@ describe('a structured Claude session over agentSession.*', () => { ) it('routes a published Claude first-hand exit through fenced host reconciliation', async () => { - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const connection = claude.live() connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) @@ -570,7 +574,7 @@ describe('a structured Claude session over agentSession.*', () => { it.each(['unverifiable', 'live'] as const)( 'restarts a chat on its next send after a Claude crash whose tree was %s', async (tree) => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const connection = claude.live() connection.exitVerdict = { root: 'exited', tree } connection.close = async () => { @@ -578,15 +582,12 @@ describe('a structured Claude session over agentSession.*', () => { return false } // Claude takes the message but crashes before echoing it. + const before = connection.sent.length connection.send = async (message) => { connection.sent.push(message) } - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } - const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( - 'agentSession.send', - { envelope: envelope('agentSession.send', { body }, created.fence), body } - ) - await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) + const inFlight = send('in flight') + await vi.waitFor(() => expect(connection.sent).toHaveLength(before + 1)) connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) expect((await inFlight).submission).toMatchObject({ @@ -597,11 +598,7 @@ describe('a structured Claude session over agentSession.*', () => { await waitForStructuredAgentSessionRecovery() expect(claude.connections).toHaveLength(1) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) - const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } - await ok('agentSession.send', { - envelope: envelope('agentSession.send', { body: next }, leaseOf(SESSION).runtimeFence), - body: next - }) + await send('after') await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) @@ -612,6 +609,21 @@ describe('a structured Claude session over agentSession.*', () => { it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { shellEnv = { ...shellEnv, ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + // Created at rest: the first message is what starts Claude. + expect(claude.connections).toHaveLength(0) + const stream = await subscribe() + + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } + const sent = await ok<{ + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `claude:${PROVIDER_SESSION}:user-1` + }) expect(claude.live().launch.options).toMatchObject({ sessionId: PROVIDER_SESSION }) expect(claude.live().launch.options.resume).toBeUndefined() expect(claude.live().launch.env).toMatchObject({ @@ -633,19 +645,6 @@ describe('a structured Claude session over agentSession.*', () => { ok: true, result: { providerSession: { key: 'session_id', id: PROVIDER_SESSION } } }) - const stream = await subscribe() - - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } - const sent = await ok<{ - submission: { dispatchState: string; providerItemId: string | null } - }>('agentSession.send', { - envelope: envelope('agentSession.send', { body }, created.fence), - body - }) - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `claude:${PROVIDER_SESSION}:user-1` - }) claude.live().handlers.onMessage?.({ type: 'stream_event', @@ -804,8 +803,8 @@ describe('a structured Claude session over agentSession.*', () => { provider: 'claude', leafUuid: 'assistant-leaf' }) - // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next - // open resumes the conversation. + // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; its next + // message resumes the conversation. const old = claude.live() old.handlers.onMessage?.({ type: 'result', @@ -817,12 +816,10 @@ describe('a structured Claude session over agentSession.*', () => { await vi.waitFor(() => expect(leaseOf(SESSION).claimStatus).toBe('released')) expect(old.closed).toBe(true) const rested = leaseOf(SESSION) - const resumed = await ok<{ fence: number }>( - 'agentSession.ensure', - ensureParams(rested.runtimeFence) - ) - expect(resumed.fence).toBe(rested.runtimeFence + 1) - expect(claude.live()).not.toBe(old) + await send('next') + await vi.waitFor(() => expect(claude.live()).not.toBe(old)) + const resumed = leaseOf(SESSION).runtimeFence + expect(resumed).toBe(rested.runtimeFence + 1) // Claude owns where the conversation continues; the stored leaf is the stopped turn's, which // Claude ended with its own result. expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) @@ -838,9 +835,12 @@ describe('a structured Claude session over agentSession.*', () => { expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( lastCompletedTurn ) - // Open, close, open with no turn in between keeps that leaf. - const reopened = await ok<{ fence: number }>('agentSession.ensure', ensureParams(resumed.fence)) - expect(reopened.fence).toBe(resumed.fence + 1) + // Start, stop, start with no completed turn in between keeps that leaf. + const second = claude.live() + await getStructuredAgentSessionHost()?.close(SESSION, 'evict') + await send('again') + await vi.waitFor(() => expect(claude.live()).not.toBe(second)) + expect(leaseOf(SESSION).runtimeFence).toBeGreaterThan(resumed) expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( lastCompletedTurn ) diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 51cf5fe1e5b3..6fbbdb942814 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -12,6 +12,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-startup-fault' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,8 @@ describe('a Claude start that Orca fails while the CLI is still running', () => claude.behave(SESSION, { optionWritesFail: true }) const host = await claude.install() await expect( - host.attach( + attachForTests( + host, CALLER, claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) ) @@ -94,7 +96,9 @@ describe('a Claude start that Orca fails while the CLI is still running', () => it('records on a held message a start that could not happen when init names another session', async () => { claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') @@ -117,7 +121,9 @@ describe('a Claude start that Orca fails while the CLI is still running', () => it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 56ba00618836..2f7095561439 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -12,6 +12,7 @@ import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launc import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } @@ -111,7 +112,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' const host = await claude.install() const saved = { model: 'sonnet', permissionMode: 'plan' } await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) + attachForTests(host, CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) // The restore asked; the CLI never answered; startup went on without it. @@ -140,7 +141,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) + attachForTests( + host, + CALLER, + claude.attachParams(SESSION, null, { options: { model: 'sonnet' } }) + ) ).resolves.toMatchObject({ ok: true }) await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), @@ -163,7 +168,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) + attachForTests( + host, + CALLER, + claude.attachParams(SESSION, null, { options: { model: 'sonnet' } }) + ) ).resolves.toMatchObject({ ok: true }) await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), @@ -231,7 +240,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) + attachForTests( + host, + CALLER, + claude.attachParams(SESSION, null, { options: { model: 'sonnet' } }) + ) ).resolves.toMatchObject({ ok: true }) // The record holds the saved model from creation; only the start's own report (effort) says // startup finished, and an option write before then is refused as still starting. @@ -250,7 +263,9 @@ describe('a Claude start whose CLI answers initialize but not a control request' it("lands with effort unknown when startup's own settings read goes unanswered", async () => { claude.behave(SESSION, { startupSettingsReadHangs: true, controlTimeoutMs: DEADLINE_MS }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 841c22ae9255..e08361d14557 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -157,7 +157,6 @@ describe('mobile RPC allowlist', () => { ).toEqual([ 'agentSession.createSupport', 'agentSession.create', - 'agentSession.ensure', 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 2bf67f0b4f88..8cc105d35602 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -223,8 +223,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ...(input.resumeFrom && adoption ? { // `adopt` is what makes the reservation seed the handle chain. Presence of - // `providerHandle` alone must not: `agentSession.ensure` already passes one today - // without adopting anything. + // `providerHandle` alone must not: an agent start passes one without adopting + // anything. adopt: { providerHandle: input.agent === 'claude' diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4333cc7e3a5e..c83dffa1436c 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -181,7 +181,28 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps // its agent running. No database answers no. hasOpenDispatch: (record) => - structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record) + structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record), + // A launch prompt's review reply writes through the same methods the review RPCs call. + reviewRuntime: { + resolveRepoReviewThread: (...args) => + this.gitHubReviewMutations.resolveRepoReviewThread(...args), + resolveGitLabRepoMRDiscussion: (...args) => + this.gitLabMutationCommands.resolveGitLabRepoMRDiscussion(...args), + addRepoPRReviewCommentReply: (...args) => + this.gitHubIssueComments.addRepoPRReviewCommentReply(...args), + addRepoIssueComment: (...args) => this.gitHubIssueComments.addRepoIssueComment(...args), + getRepoReviewReplyPosts: (...args) => + this.gitHubReviewQueries.getRepoReviewReplyPosts(...args), + // Only this machine's windows hear it; a paired client refetches on its own triggers. + reviewWritten: async (repoSelector, prNumber) => { + const repo = await this.resolveRepoSelector(repoSelector) + this.notifier?.githubPullRequestMutated?.({ + repoPath: repo.path, + repoId: repo.id, + number: prNumber + }) + } + } }) } } diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index f682dfc7ce28..4439c71a37f9 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -30,6 +30,7 @@ const sent = vi.hoisted((): { preambles: string[] } => ({ preambles: [] })) vi.mock('../rpc/methods/orchestration-structured-worker-session', () => ({ sendStructuredWorkerPreamble: async (args: { preamble: string }) => { sent.preambles.push(args.preamble) + return { state: 'accepted' as const } } })) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts new file mode 100644 index 000000000000..bcbe4d22dbf4 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts @@ -0,0 +1,142 @@ +// A structured worker's preamble is its first message, which starts its agent. What the dispatch +// is told about that start, read from a real host: ready, failed at the preamble, or unknown while a +// start is still being retried. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { mintAgentSessionOperationId } from '../../orchestration/structured-pointer-operation-id' +import { sendStructuredWorkerPreamble } from './orchestration-structured-worker-session' +import { structuredPreambleTurnStart } from './orchestration/worker/deliver-worker-dispatch-preamble' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' + +let root: string +let host: StructuredAgentSessionHost +let acquire: Mock +let clock: number + +const accountSwitch = () => + new AgentSessionPreSpawnError(new Error('switching'), { reason: 'accountSwitchInProgress' }) + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-worker-preamble-start-')) + clock = Date.now() + const store = await openTestAgentSessionRecordStore(root) + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: clock, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: clock + } + })) + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + supportsCreate: () => true, + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async ({ clientMessageId }) => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${clientMessageId}`, + ordinal: 1 + } + })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => clock + }) + const create = hostTestAttachParams(null) + // On the host's clock: the preamble's own operation ids are minted from it too. + create.envelope.clientOperationId = mintAgentSessionOperationId(clock) + expect(await host.create({ callerKey: 'client-1' }, create)).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function sendPreamble(budgetMs: number) { + return sendStructuredWorkerPreamble({ + host, + sessionId: SESSION, + dispatchId: 'dispatch-1', + preamble: 'You are a worker.', + budgetMs + }) +} + +describe('a structured worker preamble that starts its agent', () => { + it('is ready once the agent takes it', async () => { + const delivery = await sendPreamble(5_000) + + expect(delivery).toEqual({ state: 'accepted' }) + expect(structuredPreambleTurnStart(delivery)).toEqual({ verdict: 'observed' }) + }) + + it('fails at the preamble when the start is refused for the person to fix', async () => { + acquire.mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn') + ) + + await expect(sendPreamble(5_000)).rejects.toMatchObject({ + code: 'dispatch_preamble_undelivered' + }) + }) + + it('keeps waiting across a retried start, and is ready when the retry takes it', async () => { + acquire.mockRejectedValueOnce(accountSwitch()) + const delivery = sendPreamble(5_000) + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.startRetry).toBeDefined() + ) + + // The retry comes due; the delivery loop takes it on its next wake. + clock += 15_000 + host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION) + + expect(await delivery).toEqual({ state: 'accepted' }) + expect(acquire).toHaveBeenCalledTimes(2) + }) + + it('reads unknown, with the start failure, while a start is still retried at the budget', async () => { + acquire.mockRejectedValue(accountSwitch()) + + const delivery = await sendPreamble(200) + + expect(delivery).toMatchObject({ state: 'pending', startRetry: { attempts: 1 } }) + expect(structuredPreambleTurnStart(delivery)).toEqual({ + verdict: 'unobserved', + reason: expect.stringMatching( + /^The worker's agent did not start: .+ The dispatch preamble waits for its next start;/ + ) + }) + expect(acquire).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 3fbcafa9594d..0c8858de77b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { getAppEnvironment } from '../../../../shared/app-environment' import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -234,7 +235,7 @@ describe('structured worker session', () => { describe('structured worker dispatch preamble', () => { type PreambleHost = Parameters[0]['host'] - type Settled = Pick + type Settled = Pick function submissionOf(settled: Settled): AgentJournalSubmission { return { @@ -276,7 +277,7 @@ describe('structured worker dispatch preamble', () => { it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) - ).resolves.toBe('accepted') + ).resolves.toEqual({ state: 'accepted' }) }) it('waits for an accepted preamble to be delivered, and reports that delivery (W10)', async () => { @@ -287,7 +288,21 @@ describe('structured worker dispatch preamble', () => { { dispatchState: 'accepted', reason: null } ) ) - ).resolves.toBe('accepted') + ).resolves.toEqual({ state: 'accepted' }) + }) + + // The preamble is what starts the worker's agent, so a start still being retried is why it waits. + it('names the failed start a held preamble waits behind', async () => { + const startRetry = { + attempts: 1, + reason: "Codex couldn't start. Orca will try again shortly.", + rejection: { kind: 'startFailed' as const }, + failedAt: 1, + nextAttemptAt: 2 + } + await expect( + send(hostWithSubmission({ dispatchState: 'pending', reason: null, startRetry })) + ).resolves.toEqual({ state: 'pending', startRetry }) }) it('reports a preamble still held for an agent that outlasted the wait, without failing the start (W10)', async () => { @@ -295,7 +310,7 @@ describe('structured worker dispatch preamble', () => { // down, which rejected the preamble the start was about to deliver. await expect( send(hostWithSubmission({ dispatchState: 'pending', reason: null })) - ).resolves.toBe('pending') + ).resolves.toEqual({ state: 'pending' }) }) it('never claims delivery for a submission the provider never acknowledged', async () => { @@ -361,8 +376,9 @@ describe('structured worker dispatch preamble', () => { const verdicts: unknown[] = [] const heldThen = (verdict: Settled): PreambleHost => ({ ...hostWithSubmission({ dispatchState: 'pending', reason: null }), + // Only the watch past the start's wait hears the verdict; within it the agent never took it. waitForSendSettlement: async (_session, _id, options) => - options?.until === 'verdict' + options?.until === 'verdict' && (options.budgetMs ?? 0) > ORCHESTRATION_READINESS_TIMEOUT_MS ? { cursor: { epoch: 'epoch-1', sequence: 3 }, value: { clientMessageId: 'c1', submission: submissionOf(verdict) } @@ -385,7 +401,7 @@ describe('structured worker dispatch preamble', () => { ) } - expect(verdicts).toEqual(['pending', 'pending']) + expect(verdicts).toEqual([{ state: 'pending' }, { state: 'pending' }]) await vi.waitFor(() => expect(heard).toHaveBeenCalledOnce()) expect(heard).toHaveBeenCalledWith( 'The dispatch preamble was not delivered: Codex never finished starting, so Orca stopped it.' diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index e7add2aec99a..a44a1fddc92b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -3,8 +3,9 @@ * * Three things make this different from the PTY worker path, and all three live here: * - * - The session is created directly as structured, so readiness is the attach returning ok. There - * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. + * - The session is created directly as structured, at rest; its preamble is its first message and + * starts its agent, so readiness is the agent taking the preamble. There is no boot-to-idle gap to + * wait on and no `tui-idle` edge to read. * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is * open, reading that from the orchestration database; once the dispatch settles the agent rests * like any chat's, and the next mail starts it. @@ -13,7 +14,10 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' -import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalStartRetry +} from '../../../../shared/agent-session-journal-types' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' import { STRUCTURED_AGENT_SESSION_START_RETRY_DELAYS_MS } from '../../../../shared/structured-agent-session-start-retry' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' @@ -38,6 +42,7 @@ import { } from '../../structured-worker-identity' import { createKeyedTrailingEdgeCoalescer } from '../../keyed-trailing-edge-coalescer' import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create' +import type { SendSettlementWaitOptions } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' type StructuredWorkerBinding = { sessionId: string @@ -78,8 +83,8 @@ export async function createStructuredWorkerSession(args: { onJournalActivity: (sessionId: string) => void }): Promise<{ identity: StructuredWorkerIdentity; host: StructuredAgentSessionHost }> { const sessionId = randomUUID() - // Registered BEFORE the session is created, because `attach` is what spawns the provider child - // and the child's environment is read from this registry at spawn time. Registering afterwards + // Registered BEFORE the session is created, because the child's environment is read from this + // registry when its first message (the preamble) starts it. Registering afterwards // ships a worker with no ORCA_TERMINAL_HANDLE, whose bare `orca orchestration check` then // resolves to whatever single leaf sits in the worktree — by default the COORDINATOR's pane. // @@ -141,8 +146,8 @@ export async function createStructuredWorkerSession(args: { }) return { identity, host } } catch (error) { - // A start that fails after the session exists would otherwise strand a live provider child - // that no dispatch owns and that nothing else in the runtime will ever retire. + // A start that fails after the session exists would otherwise strand a chat (and, once its + // first message runs, a provider child) that no dispatch owns and nothing else will retire. structuredWorkerIdentities.forget(identity.handle) if (structuredCreateMayHaveCommitted(created)) { await discardStructuredWorkerSession(sessionId, args.runtime) @@ -152,12 +157,12 @@ export async function createStructuredWorkerSession(args: { } /** - * Whether a create may have attached a session, which is the question cleanup has to ask. + * Whether a create may have committed a session, which is the question cleanup has to ask. * - * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when `attach` SUCCEEDED - * and only the tab publish failed, and a throw out of the commit half is past `attach` too — the - * pre-commit half never throws, it refuses. Both leave a session with a published tab and no - * binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal + * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when the host's create + * SUCCEEDED and only the tab publish failed, and a throw out of the commit half is past the create + * too — the pre-commit half never throws, it refuses. Both leave a session with a published tab and + * no binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal * proves there is nothing to discard; everything else gets the best-effort close. */ function structuredCreateMayHaveCommitted( @@ -172,7 +177,7 @@ function structuredCreateMayHaveCommitted( * Stops the provider child, drops the DURABLE tab reference so nothing restores the chat after a * restart, and — only once the close came back without throwing — retires the background tab this * start published from the live snapshot. All three are no-ops for a session that was never - * attached, which is why a non-definitive refusal can reach here unconditionally. A close that + * created, which is why a non-definitive refusal can reach here unconditionally. A close that * threw leaves the tab alone: the child may still be running, and the tab is the way to reach it. * * Exported because a start can also fail AFTER `createStructuredWorkerSession` returned — on the @@ -215,6 +220,13 @@ type StructuredWorkerPreambleHost = Pick< deps: { store: { getRecord: (sessionId: string) => { lease: { runtimeFence: number } } | null } } } +/** What became of the preamble within the wait. `pending`: the worker's agent had not taken it; the + * host still holds it for that agent and never re-sends it. `startRetry`: its start failed and + * another was still booked when the wait ran out, which is why it had not. */ +export type StructuredWorkerPreambleDelivery = + | { state: 'accepted' } + | { state: 'pending'; startRetry?: AgentJournalStartRetry } + /** How long a held preamble is watched for its own verdict: every try of its start, each of which * may take a whole start. */ const PREAMBLE_VERDICT_WAIT_MS = @@ -232,16 +244,17 @@ function preambleUndeliveredReason(submission: { }: ${reasonClause(submission.reason)}.` } -/** Delivers the dispatch preamble as the worker's first turn. `pending`: the worker's agent had - * not taken it within the wait; the host still holds it for that agent, and never re-sends it. - * `whenUndelivered` hears it if the host later rejects it for good, its start's tries spent. */ +/** Delivers the dispatch preamble as the worker's first turn, which is what starts its agent. + * `whenUndelivered` hears it if the host later rejects a preamble still pending for good, its + * start's tries spent. */ export async function sendStructuredWorkerPreamble(args: { host: StructuredWorkerPreambleHost sessionId: string dispatchId: string preamble: string + budgetMs?: number whenUndelivered?: (reason: string) => void -}): Promise<'accepted' | 'pending'> { +}): Promise { const body: AgentJournalMessageItem = { kind: 'message', role: 'user', @@ -270,15 +283,7 @@ export async function sendStructuredWorkerPreamble(args: { const answered = agentSessionSendSubmission(result.value) const submission = answered?.dispatchState === 'pending' - ? (agentSessionSendSubmission( - ( - await args.host - .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS - }) - .catch(() => undefined) - )?.value - ) ?? answered) + ? ((await preambleVerdict(args, result.value.clientMessageId)) ?? answered) : answered if (submission?.dispatchState === 'pending' && args.whenUndelivered) { const { whenUndelivered } = args @@ -295,8 +300,14 @@ export async function sendStructuredWorkerPreamble(args: { }) .catch(() => undefined) } - if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { - return submission.dispatchState + if (submission?.dispatchState === 'accepted') { + return { state: 'accepted' } + } + if (submission?.dispatchState === 'pending') { + return { + state: 'pending', + ...(submission.startRetry ? { startRetry: submission.startRetry } : {}) + } } if (submission?.dispatchState === 'rejected') { // A rejection is a verdict, not a mystery: the preamble provably did not happen. @@ -319,6 +330,27 @@ export async function sendStructuredWorkerPreamble(args: { ) } +/** The preamble's verdict within the budget: a start that fails and is tried again inside it is + * still waited on. At the budget, a start still waiting for its next try answers with its failure. */ +async function preambleVerdict( + args: { host: StructuredWorkerPreambleHost; sessionId: string; budgetMs?: number }, + clientMessageId: string +) { + const read = (options: SendSettlementWaitOptions) => + args.host + .waitForSendSettlement(args.sessionId, clientMessageId, options) + .then((settled) => agentSessionSendSubmission(settled?.value)) + .catch(() => undefined) + return ( + (await read({ + budgetMs: args.budgetMs ?? ORCHESTRATION_READINESS_TIMEOUT_MS, + until: 'verdict' + })) ?? + // A zero budget answers at once only for a start waiting for its next try. + (await read({ budgetMs: 0 })) + ) +} + function requireInstalledHost(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() if (!host) { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index eaaf7d6f4aad..818eec3611b6 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -48,7 +48,7 @@ vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ( ...(await importOriginal>()), sendStructuredWorkerPreamble: async (args: { preamble: string }) => { structuredPreambles.push(args.preamble) - return 'accepted' + return { state: 'accepted' as const } }, releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index a2beea9ea05c..2c26a246a386 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -30,7 +30,7 @@ vi.mock('./orchestration/federation/federated-worker-start', () => ({ })) vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ ...(await importOriginal>()), - sendStructuredWorkerPreamble: async () => 'accepted', + sendStructuredWorkerPreamble: async () => ({ state: 'accepted' as const }), releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} })) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index d8b188e4d78f..dfeb74e62654 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -11,7 +11,10 @@ import { buildDispatchPreamble, dispatchPreambleSendOptions } from '../../../../orchestration/preamble' -import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' +import { + sendStructuredWorkerPreamble, + type StructuredWorkerPreambleDelivery +} from '../../orchestration-structured-worker-session' import type { WorkerTurnStartObservation } from './worker-start-turn-observation' import type { createStructuredWorkerSessionForWorktree } from './worker-topology' @@ -38,6 +41,8 @@ export async function deliverWorkerDispatchPreamble(args: { coordinatorHandle: string devMode: boolean | undefined requestId: string + /** How long a structured preamble waits for its agent. */ + preambleBudgetMs?: number /** A structured preamble the host held, then rejected for good: why. */ whenUndelivered?: (reason: string) => void }): Promise<{ @@ -68,21 +73,10 @@ export async function deliverWorkerDispatchPreamble(args: { sessionId: structuredSession.identity.sessionId, dispatchId: args.dispatchId, preamble, + ...(args.preambleBudgetMs === undefined ? {} : { budgetMs: args.preambleBudgetMs }), ...(args.whenUndelivered ? { whenUndelivered: args.whenUndelivered } : {}) }) - return { - structuredTurnStart: - delivery === 'accepted' - ? { verdict: 'observed' } - : { - verdict: 'unobserved', - reason: - 'The dispatch preamble was accepted, but the agent had not started to take it. It ' + - 'is delivered when the agent starts; if the worker then reports, this Dispatch ' + - 'settles normally. If Orca cannot start the agent, this Dispatch fails and its ' + - 'Run is told.' - } - } + return { structuredTurnStart: structuredPreambleTurnStart(delivery) } } return { prompt: ( @@ -94,3 +88,21 @@ export async function deliverWorkerDispatchPreamble(args: { ).prompt } } + +/** What a structured preamble's delivery says about the worker's turn start. */ +export function structuredPreambleTurnStart( + delivery: StructuredWorkerPreambleDelivery +): WorkerTurnStartObservation { + if (delivery.state === 'accepted') { + return { verdict: 'observed' } + } + const waiting = delivery.startRetry + ? `The worker's agent did not start: ${delivery.startRetry.reason} The dispatch preamble ` + + 'waits for its next start; if the worker then reports, this Dispatch settles normally.' + : 'The dispatch preamble was accepted, but the agent had not started to take it. It is ' + + 'delivered when the agent starts; if the worker then reports, this Dispatch settles normally.' + return { + verdict: 'unobserved', + reason: `${waiting} If Orca cannot start the agent, this Dispatch fails and its Run is told.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f276dc736cb3..fd5631ed874c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -51,6 +51,7 @@ export async function startLocalWorker(args: { mode: WorkerStartModeReceipt }): Promise { const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const startedAtMs = Date.now() const { orchestrationMutation } = args const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' @@ -198,9 +199,9 @@ export async function startLocalWorker(args: { persistWorkerReadinessStage(setupStage) failedStage = 'agent_readiness' - // A structured session is ready the moment its attach returns ok: there is no boot-to-idle - // gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy - // still holds it back, and that gate has to be waited on explicitly here. + // A structured session has no boot-to-idle gap and no terminal title to read an idle edge + // from: its agent starts with the preamble below. Only the repo's wait-for-setup policy still + // holds it back, and that gate has to be waited on explicitly here. const wait = structuredSession ? await awaitStructuredWorkerSetupGate({ runtime, @@ -262,6 +263,7 @@ export async function startLocalWorker(args: { launchReceipt: launch.receipt, mode, timeoutMs: params.timeoutMs ?? 60_000, + startedAtMs, effects, terminalRevealWarning: placed.warning, onStage: (stage) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts index ae98c4793ad4..1fafcd906504 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { MAX_TIMER_DELAY_MS } from '../../../../../../shared/timer-delay' import { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, ORCHESTRATION_READINESS_TIMEOUT_MS, - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS, + resolveStructuredWorkerPreambleBudgetMs } from '../../../../../../shared/orchestration-timing-budgets' import { resolveFederatedWorkerStartBudgets } from './worker-start-budgets' @@ -32,3 +34,25 @@ describe('worker-start transport budgets', () => { ) }) }) + +describe('a structured worker preamble budget', () => { + it('always ends inside the client grace a worker start gets, and never past the readiness wait', () => { + for (const timeoutMs of [1_000, 10_000, 60_000, 600_000]) { + for (const spentMs of [0, timeoutMs / 2, timeoutMs, timeoutMs + 40_000]) { + const budget = resolveStructuredWorkerPreambleBudgetMs({ + startedAtMs: 0, + timeoutMs, + nowMs: spentMs + }) + expect(budget).toBeGreaterThanOrEqual(0) + expect(budget).toBeLessThanOrEqual(ORCHESTRATION_READINESS_TIMEOUT_MS) + expect(spentMs + budget).toBeLessThanOrEqual( + Math.max(spentMs, timeoutMs + AGENT_PROMPT_EFFECT_TIMEOUT_MS) + ) + expect(AGENT_PROMPT_EFFECT_TIMEOUT_MS).toBeLessThan( + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + ) + } + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index c1e022352f27..20afebb15d0c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -10,8 +10,15 @@ vi.mock('./failed-worker-start-teardown', () => ({ tearDownFailedWorkerStart: te const { deliverAndSettleWorkerStartReadiness } = await import('./worker-start-readiness-settlement') -/** `verdict`: what the host finally answers for a preamble it held. */ -function settle(delivered: 'accepted' | undefined, verdict = new Promise(() => {})) { +/** `verdict`: what the host finally answers for a preamble it held, past the start's wait. */ +function settle( + delivered: 'accepted' | undefined, + start: { timeoutMs: number; startedAtMs: number } = { + timeoutMs: 60_000, + startedAtMs: Date.now() + }, + verdict = new Promise(() => {}) +) { let state = 'starting' const db = { getWorkerDispatch: () => ({ state }), @@ -36,12 +43,15 @@ function settle(delivered: 'accepted' | undefined, verdict = new Promise - options?.until === 'verdict' - ? verdict - : delivered - ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } - : undefined + // The watch for a held preamble's own verdict outlasts every start's wait. + waitForSendSettlement: vi.fn( + async (_session: string, _id: string, options?: { until?: string; budgetMs?: number }) => + options?.until === 'verdict' && (options.budgetMs ?? 0) > 60_000 + ? verdict + : delivered + ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } + : undefined + ) } const args = { runtime: { @@ -63,14 +73,14 @@ function settle(delivered: 'accepted' | undefined, verdict = new Promise {} } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fakes implement exactly the runtime, db and host members this settlement reaches. const receipt = deliverAndSettleWorkerStartReadiness(args as never) - return { db, receipt, notifyMessageArrived } + return { db, receipt, host, notifyMessageArrived } } describe('a structured worker whose agent outlasts the preamble wait', () => { @@ -101,13 +111,38 @@ describe('a structured worker whose agent outlasts the preamble wait', () => { await expect(receipt).resolves.toMatchObject({ state: 'ready', turnStart: 'observed' }) expect(db.markWorkerStartUnknown).not.toHaveBeenCalled() }) + + // The CLI gives a worker start its own timeout plus a fixed grace; a short timeout must not + // leave the preamble waiting past it, or the CLI times out before the receipt arrives. + it.each([ + [60_000, 0, 60_000], + [10_000, 0, 40_000], + [10_000, 25_000, 15_000], + [10_000, 45_000, 0] + ])( + 'waits for the agent within the start timeout %i ms less what %i ms already spent', + async (timeoutMs, spentMs, budgetMs) => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(1_000_000) + const { host, receipt } = settle(undefined, { + timeoutMs, + startedAtMs: 1_000_000 - spentMs + }) + await receipt + expect(host.waitForSendSettlement.mock.calls[0]?.[2]).toMatchObject({ budgetMs }) + } finally { + vi.useRealTimers() + } + } + ) }) describe('a structured worker whose held preamble the host later rejects for good', () => { it('fails the start it left unknown, tears it down, and tells the Run once', async () => { teardown.mockClear() const rejected = Promise.withResolvers() - const { db, receipt, notifyMessageArrived } = settle(undefined, rejected.promise) + const { db, receipt, notifyMessageArrived } = settle(undefined, undefined, rejected.promise) await expect(receipt).resolves.toMatchObject({ state: 'outcome_unknown' }) expect(db.failWorkerStart).not.toHaveBeenCalled() @@ -138,12 +173,13 @@ describe('a structured worker whose held preamble the host later rejects for goo it('does nothing when it lands, or when the worker moved on first', async () => { teardown.mockClear() const landed = settle( + undefined, undefined, Promise.resolve({ value: { submission: { dispatchState: 'accepted' } } }) ) await landed.receipt const reported = Promise.withResolvers() - const movedOn = settle(undefined, reported.promise) + const movedOn = settle(undefined, undefined, reported.promise) await movedOn.receipt movedOn.db.markWorkerDispatchReady() // The worker reported before the verdict: it is no longer a start to settle. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 7366b955cc11..d7da73330485 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -2,6 +2,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' +import { resolveStructuredWorkerPreambleBudgetMs } from '../../../../../../shared/orchestration-timing-budgets' import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' @@ -39,6 +40,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { launchReceipt: OrchestrationWorkerLaunchReceipt mode: WorkerStartModeReceipt timeoutMs: number + /** When the worker start began, so the preamble's wait ends inside the caller's own timeout. */ + startedAtMs: number effects: WorkerEffect[] terminalRevealWarning: string | undefined /** Keeps the caller's failure receipt naming the stage that actually failed. */ @@ -62,6 +65,11 @@ export async function deliverAndSettleWorkerStartReadiness(args: { coordinatorHandle: args.coordinatorHandle, devMode: args.devMode, requestId: args.requestId, + preambleBudgetMs: resolveStructuredWorkerPreambleBudgetMs({ + startedAtMs: args.startedAtMs, + timeoutMs: args.timeoutMs, + nowMs: Date.now() + }), whenUndelivered: undelivered.resolve }) effects.push({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 6d1ddf9f3594..12e0e9726525 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -229,7 +229,8 @@ describe('committed adopting create RPC replay', () => { } } }) - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + // The adopting create starts no agent; its first send would. + expect(sessionAdapter.acquire).not.toHaveBeenCalled() expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) expect(selectAccountHome).toHaveBeenCalledTimes(1) @@ -242,7 +243,7 @@ describe('committed adopting create RPC replay', () => { ok: true, result: { ok: false, refusal: { code: 'agent_session_identity_required' } } }) - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index b846be96dd22..68b41f9c0f53 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const CLIENT = { clientId: 'device-1', @@ -151,7 +152,7 @@ describe('opening a chat at rest (P2-01)', () => { describe('the accessor', () => { it('opens a closed conversation once, however many readers arrive together (P2-02)', async () => { // Written by the provider alone, so nothing but the readers below ever opens it here. - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + const attached = await attachForTests(rig.host, CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) rig.adapter.acquire.mock.calls .at(-1)?.[0] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts index cf708f374916..627e398ac265 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts @@ -9,9 +9,9 @@ import { commitStructuredAgentSessionCreate } from './structured-agent-session-c it('logs a created chat whose tab could not be published, and answers it unconfirmed', async () => { const log = recordingStructuredAgentSessionLogger() const failure = new Error('tab write failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members the commit reaches past attach. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members the commit reaches past create. const host = { - attach: vi.fn(async () => ({ ok: true, value: { sessionId: 'session-1' } })), + create: vi.fn(async () => ({ ok: true, value: { sessionId: 'session-1' } })), getSessionTabId: vi.fn(() => null), deps: { logger: log.logger } } as unknown as StructuredAgentSessionHost @@ -27,7 +27,7 @@ it('logs a created chat whose tab could not be published, and answers it unconfi caller: { callerKey: 'client-1' }, prepared: { host, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed attach reads none of it. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed create reads none of it. attachParams: {} as AgentSessionAttachParams, tab: { workspaceId: 'workspace-1', agent: 'codex' } }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 1d0899a014df..4a166f2f838e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -1,15 +1,16 @@ /** - * Creating a structured session for a worktree: resolve the create intent, attach it under the - * host-computed fingerprint, then publish its tab. + * Creating a structured session for a worktree: resolve the create intent, create it at rest under + * the host-computed fingerprint, then publish its tab. Nothing starts an agent here; the chat's + * first message does. * * Extracted from `agentSession.create` so orchestration can start a native-born structured worker * on exactly the same path. `activate` is the only knob the two callers differ on: a chat the user * asked for takes the surface, a background dispatch must not steal it (the terminal worker path's * `surfaceOwner: false`). * - * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before `attach` - * commits a session, so that span answers with a refusal, and nothing after it may be folded back - * in. Both callers run the same two halves, so orchestration gets that guarantee too. + * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before the + * host's `create` commits a session, so that span answers with a refusal, and nothing after it may + * be folded back in. Both callers run the same two halves, so orchestration gets that guarantee too. */ import { refuse } from '../../../../shared/agent-session-wire-refusals' @@ -120,7 +121,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { } } -/** The commit half. Past `attach`, a failure no longer proves the session does not exist. */ +/** The commit half. Past the host's `create`, a failure no longer proves the session does not exist. */ export async function commitStructuredAgentSessionCreate(args: { runtime: OrcaRuntimeService caller: StructuredAgentSessionCaller @@ -128,7 +129,7 @@ export async function commitStructuredAgentSessionCreate(args: { activate: boolean }): Promise> { const { prepared } = args - const result = await prepared.host.attach(args.caller, prepared.attachParams) + const result = await prepared.host.create(args.caller, prepared.attachParams) if (!result.ok || !prepared.tab) { return result } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 2a37bb921e15..bdb6819ce404 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -1,12 +1,7 @@ // Every `agentSession.*` method with valid params, as tables the suites iterate. Adding a method // means adding it to exactly one of these. -import { - attachParams, - envelope, - sendParams, - SESSION -} from './structured-agent-session-rpc.test-fixture' +import { envelope, sendParams, SESSION } from './structured-agent-session-rpc.test-fixture' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' /** Stops or retires work the caller already owns; none of these builds a host. */ @@ -54,7 +49,6 @@ export const WORK_METHODS = [ agent: 'codex' } }, - { method: 'agentSession.ensure', params: attachParams() }, { method: 'agentSession.send', params: sendParams() }, { method: 'agentSession.queuedMessageSend', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 769bd32f17b3..e8457d55edc4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -28,6 +28,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' const CONNECTION = 'connection-1' @@ -112,7 +113,9 @@ beforeEach(async () => { > ) dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index 17aadf32a801..4b342fb77306 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -1,5 +1,5 @@ -// The create route's pre-commit boundary: a failure before `attach` must reach the client as a -// refusal it can classify, and a failure at or after `attach` must not. +// The create route's pre-commit boundary: a failure before the host's `create` must reach the +// client as a refusal it can classify, and a failure at or after it must not. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -40,19 +40,19 @@ function createParams(overrides: Record = {}) { } } -let attach: ReturnType +let hostCreate: ReturnType function hostStub(): StructuredAgentSessionHost { - attach = vi.fn(async () => ({ + hostCreate = vi.fn(async () => ({ ok: true, replayed: false, fence: 1, cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only attach, and the logger a failure past it reports to. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create route reaches only `create` on the host it is handed, and the logger a failure past it reports to. return { - attach, + create: hostCreate, deps: { logger: recordingStructuredAgentSessionLogger().logger } } as unknown as StructuredAgentSessionHost } @@ -138,7 +138,7 @@ describe('a create refused before it commits', () => { expect(refusal?.message).toContain('structured agent chat') expect(refusal?.message).not.toContain('Codex') expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true) - expect(attach).not.toHaveBeenCalled() + expect(hostCreate).not.toHaveBeenCalled() }) it('answers a code-less failure as a definitive envelope too, keeping the cause in the message', async () => { @@ -154,7 +154,7 @@ describe('a create refused before it commits', () => { expect(refusal?.message).toContain('structured agent chat') expect(refusal?.message).not.toContain('Codex') expect(refusal?.message).toContain('No worktree matches id:workspace-1') - expect(attach).not.toHaveBeenCalled() + expect(hostCreate).not.toHaveBeenCalled() }) it('answers a host that will not install as a definitive envelope', async () => { @@ -188,13 +188,13 @@ describe('a create refused before it commits', () => { ) expect(refusalOf(response)?.code).toBe('agent_session_operation_conflict') - expect(attach).not.toHaveBeenCalled() + expect(hostCreate).not.toHaveBeenCalled() }) }) describe('the boundary the envelope stops at', () => { - it('leaves a failure at attach unknown, because it may have committed', async () => { - attach.mockRejectedValueOnce(new Error('attach exploded')) + it('leaves a failure in the host create unknown, because it may have committed', async () => { + hostCreate.mockRejectedValueOnce(new Error('create exploded')) const response = await create() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 903f8d440afb..084ecb5d6847 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -5,7 +5,7 @@ // tab, and a client drops every unpublished `agent-session` tab on each session-tabs sync. Without // this the row is unreachable, and the client's "retry in a moment" is advice that never comes true. // -// Distinct from `agentSession.ensure`, which attaches a location the CLIENT supplies. Here the host +// Distinct from an attach-shaped create, whose location the CLIENT supplies. Here the host // reads its own record and answers with that record's workspace and provider, so a client knowing // only a session id cannot aim the publication somewhere else. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a09e82464ba3..f3fe0e4ba1ab 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -127,7 +127,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { export function hostStub(): StructuredAgentSessionHost { reset(hostCalls) Object.assign(hostCalls, { - attach: vi.fn(async () => ({ + create: vi.fn(async () => ({ ok: true, replayed: false, fence: 1, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 1694a4521885..cb027bedad7b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -174,7 +174,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(33) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -386,14 +386,14 @@ describe('method routing', () => { ...params, callerKey: 'trusted-local:runtime' }) - expect(hostCalls.attach).toHaveBeenCalledWith( + expect(hostCalls.create).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, options: { model: 'gpt-5.6-sol', effort: 'medium' } }) ) - expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') + expect(hostCalls.create.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( expect.objectContaining({ sessionId: SESSION, activate: true }) ) @@ -419,7 +419,7 @@ describe('method routing', () => { }) // Beside `options`, after the attach fingerprint: which tab shows the chat is not which // conversation this attaches to. - expect(hostCalls.attach).toHaveBeenCalledWith( + expect(hostCalls.create).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ surfaceTabId: 'chat-tab-1' }) ) @@ -446,7 +446,7 @@ describe('method routing', () => { ok: true, result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } }) - expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.create).not.toHaveBeenCalled() }) it.each(['agent-session:with-colon', 'web-terminal-local-surface'])( @@ -468,7 +468,7 @@ describe('method routing', () => { ok: false, error: { code: 'invalid_argument', message: expect.stringContaining('Invalid chat tab ID') } }) - expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.create).not.toHaveBeenCalled() } ) @@ -533,7 +533,7 @@ describe('method routing', () => { ...params, callerKey: 'trusted-local:runtime' }) - expect(hostCalls.attach).toHaveBeenCalledWith( + expect(hostCalls.create).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } @@ -548,7 +548,7 @@ describe('method routing', () => { ) }) - it('reports an unknown create outcome when attach commits before tab publication fails', async () => { + it('reports an unknown create outcome when the create commits before tab publication fails', async () => { const worktree = 'id:workspace-1' const params = { envelope: envelope({ @@ -569,7 +569,7 @@ describe('method routing', () => { }) }) - expect(hostCalls.attach).toHaveBeenCalledOnce() + expect(hostCalls.create).toHaveBeenCalledOnce() expect(response).toMatchObject({ ok: true, result: { @@ -579,19 +579,16 @@ describe('method routing', () => { }) }) - it('separates create from ensure by the fence the client may declare', async () => { + it('refuses a create that names a runtime fence', async () => { const created = await call('agentSession.create', attachParams()) expect(created).toMatchObject({ ok: true }) const fenced = await call('agentSession.create', attachParams({ envelope: envelope() })) expect(fenced).toMatchObject({ ok: false }) - - const ensured = await call('agentSession.ensure', attachParams({ envelope: envelope() })) - expect(ensured).toMatchObject({ ok: true }) }) - /** A client-supplied location skips the worktree-resolving support check, so both attach-shaped - * entries must ask the executing host directly or a host that cannot fence a provider child + /** A client-supplied location skips the worktree-resolving support check, so an attach-shaped + * create must ask the executing host directly or a host that cannot fence a provider child * would create one anyway. */ it('returns a refusal envelope when create cannot support a client-supplied location', async () => { hostCalls.supportsCreate.mockReturnValue(false) @@ -605,20 +602,7 @@ describe('method routing', () => { refusal: { code: 'structured_agent_session_unsupported' } } }) - expect(hostCalls.attach).not.toHaveBeenCalled() - expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex') - }) - - it('keeps ensure failures as top-level errors for an unsupported client location', async () => { - hostCalls.supportsCreate.mockReturnValue(false) - - const refused = await call('agentSession.ensure', attachParams()) - - expect(refused).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } - }) - expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.create).not.toHaveBeenCalled() expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex') }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index e0d24119086c..dba28d9d1973 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -50,7 +50,7 @@ import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent import { STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS } from './structured-agent-session-conversation-outline' import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from './structured-agent-session-options-read' import { - AttachParams, + type AttachParams, CancelParams, ConversationCommandParams, CreateParams, @@ -69,7 +69,7 @@ import { import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' /** - * The attach-shaped entries take the location from the client instead of resolving it from a + * An attach-shaped create takes the location from the client instead of resolving it from a * worktree, so they never reach the worktree-resolving create-support check. Ask the executing * host the same question directly: the answer includes host-measured facts the client cannot see * or forge, such as whether this machine can read a provider child's process start time. @@ -91,14 +91,6 @@ async function resolveClientSuppliedAttach(params: z.infer, return { host, attachParams } } -async function attachClientSuppliedLocation( - params: z.infer, - ctx: RpcContext -): Promise { - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return host.attach(callerFor(ctx), attachParams) -} - export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', @@ -156,8 +148,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ reason: 'requestMalformed' }) } - // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so - // a client can tell "nothing was created" from "the outcome is unknown". + // Everything up to the host's create is pre-commit, and answers with a refusal rather than a + // throw so a client can tell "nothing was created" from "the outcome is unknown". const prepared = await resolveUncommittedStructuredCreate(async () => { if ('worktree' in params) { const conflict = agentSessionFingerprintConflict( @@ -195,11 +187,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }) } }), - defineMethod({ - name: 'agentSession.ensure', - params: AttachParams, - handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx) - }), defineMethod({ name: 'agentSession.send', params: SendParams, diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index c97672269eb8..05bc61322939 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -487,7 +487,8 @@ describe('a create that reserves its tab', () => { }) it('restores no tab for a reserved create that stopped before its tab was published', async () => { - const attached = await host.attach( + // The host's create committed; the tab it reserved was never published. + const attached = await host.create( caller, hostTestAttachParams(null, { envelope: { @@ -510,17 +511,28 @@ describe('a create that reserves its tab', () => { }) }) - it('leaves no tab behind when the create fails, so nothing is restored and the id is free', async () => { + // A create starts no agent; the first message does, and its failure is that message's. + it('keeps the chat its tab when its first message cannot start the agent', async () => { acquireFails = true - expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: false }) - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() - expect(store.listVisibleSessionIds()).toEqual([]) - - acquireFails = false - expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: true, value: { tabId: 'reserved-tab' } }) + const body = hostTestMessage('first') + expect( + await host.send(caller, { + envelope: envelopeFor('agentSession.send', HOST_TEST_SESSION, { body }), + body + }) + ).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect((await host.journalSnapshot(HOST_TEST_SESSION)).submissions[0]).toMatchObject({ + dispatchState: 'rejected' + }) + ) + + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe('reserved-tab') + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ ok: false }) }) }) diff --git a/src/main/runtime/runtime-github-review-query-commands.ts b/src/main/runtime/runtime-github-review-query-commands.ts index e9d248648288..72b2b4915c6e 100644 --- a/src/main/runtime/runtime-github-review-query-commands.ts +++ b/src/main/runtime/runtime-github-review-query-commands.ts @@ -2,6 +2,10 @@ import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-req import type { Repo } from '../../shared/repo-types' import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' import { getIssue, getPRCheckDetails, getPRChecks, getPRComments } from '../github/client' +import { + getReviewReplyPosts, + type ReviewReplyPosts +} from '../github/client/fetch/review-reply-posts' import { getPRFileContents } from '../github/work-item-details' type LocalGitArgs = [] | [LocalProjectGhExecOptions] @@ -14,6 +18,21 @@ type RuntimeGitHubReviewQueryCommandsDeps = { export class RuntimeGitHubReviewQueryCommands { constructor(private readonly deps: RuntimeGitHubReviewQueryCommandsDeps) {} + /** What a review reply may already have posted on this PR, read as the repo's gh account; throws + * on any failure (`getReviewReplyPosts`). */ + async getRepoReviewReplyPosts( + repoSelector: string, + args: Parameters[1] + ): Promise { + const repo = await this.deps.resolveRepo(repoSelector) + return getReviewReplyPosts( + repo.path, + args, + repo.connectionId ?? null, + ...this.deps.getLocalGitArgs(repo) + ) + } + async getRepoIssue( repoSelector: string, number: number diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index c5b279e41db2..02ed3bec5af2 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -29,6 +29,8 @@ export type RuntimeNotifier = { worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void + /** The host wrote to a GitHub PR on a user's behalf; views of it refetch, as after their own. */ + githubPullRequestMutated?(payload: { repoPath: string; repoId: string; number: number }): void reposChanged(): void activateWorktree( repoId: string, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index d544dc6720b0..bc07ddd03d2e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -214,7 +214,6 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.unsubscribeAll', 'agentSession.createSupport', 'agentSession.create', - 'agentSession.ensure', 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 47a8c609db20..34062a93d2e3 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -25,6 +25,7 @@ import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { liveTestJournalRows, openTestJournalHostDatabase @@ -168,7 +169,7 @@ beforeEach(async () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach(CALLER, attachParams) + const attached = await attachForTests(host, CALLER, attachParams) if (!attached.ok) { throw new Error(JSON.stringify(attached.refusal)) } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a9159..be447270cb5f 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -34,6 +34,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // The turns a send or Stop is waiting on to open, so a test knows the wait began. @@ -231,7 +232,7 @@ beforeEach(async () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach(CALLER, attachParams) + const attached = await attachForTests(host, CALLER, attachParams) if (!attached.ok) { throw new Error(JSON.stringify(attached.refusal)) } diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 2bf1a423fe12..5de9dfbcddb3 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -13,6 +13,7 @@ import { structuredAgentSessionsHeld } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { foundAndReserveTestAgentSessionRecord } from './agent-session-record-store-test-harness' import type { RpcContext, RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import type { OrcaRuntimeService } from './orca-runtime' @@ -65,7 +66,7 @@ function operationId(suffix: string): string { } async function recordChat(host: StructuredAgentSessionHost): Promise { - await host.deps.store.reserveOwner({ + await foundAndReserveTestAgentSessionRecord(host.deps.store, { sessionId: 'session-1', location: { executionHostId: 'local', @@ -75,7 +76,6 @@ async function recordChat(host: StructuredAgentSessionHost): Promise { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(stateDirectory, 'claude') }, - expectedFence: null, spawnToken: 'spawn-1', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/structured-agent-session-integration-create.test.ts b/src/main/runtime/structured-agent-session-integration-create.test.ts new file mode 100644 index 000000000000..011db656d72f --- /dev/null +++ b/src/main/runtime/structured-agent-session-integration-create.test.ts @@ -0,0 +1,178 @@ +// A chat's create, driven over `agentSession.*` with everything but the Codex child real: it +// founds the chat at rest, its replay starts nothing, its first message starts the agent (resuming +// an adopted conversation), and a chat an older host failed to start can be created again. + +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const ADOPTED_THREAD = 'thread-adopted-earlier' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +async function send(fence: number, text: string) { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + return harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, fence), + body + }) +} + +function startedTurn(): Promise { + return vi.waitFor(() => + expect(harness.codex.live().calls).toContainEqual( + expect.objectContaining({ method: 'turn/start' }) + ) + ) +} + +/** The intent the RPC resolver builds, with the adoption it resolves for a "resume" create. */ +function adoptingCreate(transcriptPath: string): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + ...harness.envelope('agentSession.create', {}, null), + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + adopt: { providerHandle: { kind: 'codex', threadId: ADOPTED_THREAD }, transcriptPath } + } + params.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + return params +} + +describe('creating a structured codex chat over agentSession.*', () => { + it('stays at rest when the same create is replayed, and its first send starts the agent', async () => { + const params = harness.createIntentParams() + const created = await harness.ok<{ fence: number }>('agentSession.create', params) + const replay = await harness.call('agentSession.create', params) + + expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true, fence: 1 } }) + expect(harness.codex.connections).toHaveLength(0) + + await send(created.fence, 'go') + await startedTurn() + expect(harness.codex.connections).toHaveLength(1) + }) + + it('answers agentSession.ensure as a method that no longer exists', async () => { + expect(await harness.call('agentSession.ensure', harness.createIntentParams())).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + }) + + it('resumes the adopted conversation when its first message starts the agent', async () => { + const transcriptPath = join(harness.root, 'adopted-rollout.jsonl') + const rollout = [ + { type: 'session_meta', payload: { id: ADOPTED_THREAD, cwd: '/repos/workspace-1' } }, + { + type: 'response_item', + timestamp: '2026-09-06T18:00:01.000Z', + payload: { type: 'message', role: 'user', content: 'from before' } + } + ] + await writeFile(transcriptPath, `${rollout.map((line) => JSON.stringify(line)).join('\n')}\n`) + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) + const created = await host.create({ callerKey: 'client-1' }, adoptingCreate(transcriptPath)) + expect(created).toMatchObject({ ok: true, fence: 1 }) + expect(harness.codex.connections).toHaveLength(0) + + await send(1, 'carry on') + await startedTurn() + + expect(harness.codex.live().resumedThreadId).toBe(ADOPTED_THREAD) + expect(harness.codex.live().calls).not.toContainEqual( + expect.objectContaining({ method: 'thread/start' }) + ) + }) + + it('creates again a chat whose start an older host ran at create and lost', async () => { + // What an older host left: the create reserved the record, its start failed and was proven + // gone, and the renderer kept the chat as a failed launch it relaunches under a new operation. + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) + const { store } = host.deps + const now = Date.now() + const location: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + } + const identity = { + sessionId: SESSION, + location, + provider: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' } + } + const oldCreate = { callerKey: 'client-1', operationId: `${now}-${'a'.repeat(32)}` } + await store.createAtRest({ + ...identity, + claimKeyId: 'key-1', + operation: { ...oldCreate, fingerprint: 'old-create' }, + now + }) + const reserved = await store.reserveOwner({ + ...identity, + expectedFence: 1, + spawnToken: 'spawn-old', + claimKeyId: 'key-1', + handoffOperationId: oldCreate.operationId, + probe: { outcome: 'reservation-unused' }, + operation: { ...oldCreate, fingerprint: 'old-create' }, + now + }) + await store.settleFailedAcquisition({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + spawnToken: 'spawn-old', + ...oldCreate, + outcome: { status: 'failed', code: 'agent_session_operation_invalid', message: 'signed out' }, + exitProof: 'exit-proven', + now + }) + const left = store.getRecord(SESSION) + expect(left?.providerHandleChain).toEqual([]) + + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + // Founded again at rest, one fence past the failed start's: a fence never moves back. + expect(created.fence).toBe((left?.lease.runtimeFence ?? 0) + 1) + await send(created.fence, 'signed in now') + await startedTurn() + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration-provider-rows.test.ts b/src/main/runtime/structured-agent-session-integration-provider-rows.test.ts new file mode 100644 index 000000000000..ffd005cee9ad --- /dev/null +++ b/src/main/runtime/structured-agent-session-integration-provider-rows.test.ts @@ -0,0 +1,133 @@ +// What a running Codex agent writes into the journal, driven over `agentSession.*`; split from +// `structured-agent-session-integration.test.ts`, whose shared RPC harness this uses. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + TURN, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +function drainStreamedEvents(): Promise { + return getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() +} + +/** A chat is created at rest; its first send is what starts the agent these rows come from. */ +async function sendToStart(fence: number): Promise { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'start' }] } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, fence), + body + }) + await vi.waitFor(() => + expect(harness.codex.live().calls).toContainEqual( + expect.objectContaining({ method: 'turn/start' }) + ) + ) +} + +describe('a running structured codex agent', () => { + it('persists truncated command output before publishing its journal row', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await sendToStart(created.fence) + const output = 'large command output\n'.repeat(2_000) + + harness.codex.notify('item/completed', { + threadId: THREAD, + turnId: TURN, + item: { + type: 'commandExecution', + id: 'item-large-output', + command: 'print-many-lines', + status: 'completed', + exitCode: 0, + aggregatedOutput: output + } + }) + await drainStreamedEvents() + + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') + const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined + expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) + }) + + it('keeps an answered prompt resolved after the provider exits', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await sendToStart(created.fence) + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + harness.codex.notify('item/started', { + threadId: THREAD, + turnId: TURN, + item: { + type: 'commandExecution', + id: 'item-needs-answer', + command: 'build', + status: 'inProgress' + } + }) + harness.codex.ask(9, 'item/commandExecution/requestApproval', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-needs-answer', + availableDecisions: ['accept', 'decline'] + }) + await drainStreamedEvents() + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const approval = journal.snapshot().items.find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ + kind: 'approval', + resolution: { state: 'pending' } + }) + + await harness.ok('agentSession.respondToApproval', { + envelope: harness.envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + }, + created.fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + }) + harness.codex.live().handlers.onExit?.(new Error('provider exited after answer')) + await drainStreamedEvents() + + expect( + journal.snapshot().items.find((item) => item.itemId === approval?.itemId)?.body + ).toMatchObject({ + kind: 'approval', + resolution: { state: 'resolved', selectedOptionId: 'accept' } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index b4d0399d10da..fd3718d50f2d 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -75,20 +75,32 @@ describe('a structured codex session over agentSession.*', () => { const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter - const historyEntered = Promise.withResolvers() - const historyGate = Promise.withResolvers() - const originalHistoryFilePath = adapter.historyFilePath.bind(adapter) - vi.spyOn(adapter, 'historyFilePath').mockImplementation(async (input) => { - historyEntered.resolve() - await historyGate.promise - return originalHistoryFilePath(input) + // Held once the child exists and before the attach binds it: rows it emits now are buffered. + const acquired = Promise.withResolvers() + const bindGate = Promise.withResolvers() + const originalAcquire = adapter.acquire.bind(adapter) + vi.spyOn(adapter, 'acquire').mockImplementation(async (input) => { + const result = await originalAcquire(input) + acquired.resolve() + await bindGate.promise + return result }) - const creating = harness.ok<{ fence: number }>( + const created = await harness.ok<{ fence: number }>( 'agentSession.create', harness.createIntentParams() ) - await historyEntered.promise + // The first send's start is the acquiring attach. + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + const sending = harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, created.fence), + body + }) + await acquired.promise harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) harness.codex.notify('item/started', { threadId: THREAD, @@ -108,8 +120,8 @@ describe('a structured codex session over agentSession.*', () => { }) await new Promise((resolve) => setImmediate(resolve)) const waitedForJournalBind = !stopped - historyGate.resolve() - await creating + bindGate.resolve() + await sending await stopping expect(waitedForJournalBind).toBe(true) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 3d9e72f74046..b0d836894667 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -252,6 +252,19 @@ async function ok(method: string, params: unknown): Promise { return result.value as T } +/** A chat is created at rest; its first send is what starts the agent, for a test that needs it. */ +async function sendToStart(fence: number, text = 'start'): Promise { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + const sent = await ok<{ clientMessageId: string }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, fence), + body + }) + await vi.waitFor(() => + expect(codex.live().calls).toContainEqual(expect.objectContaining({ method: 'turn/start' })) + ) + return sent.clientMessageId +} + /** Opens a live subscription and keeps collecting frames after the call settles. */ async function subscribe( requestId: string, @@ -431,18 +444,26 @@ describe('a structured codex session over agentSession.*', () => { // The previous process exits, closing its database. await journals.closeAll() - const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( + const created = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( 'agentSession.create', createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } + // The live options come from the agent, which the first send starts. + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + ) }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -451,11 +472,8 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items).toEqual([]) - expect(codex.live().launch.env).toMatchObject({ - CODEX_PROFILE: 'configured', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', - CODEX_HOME: '/home/dev/.codex' - }) + // The chat is created at rest: its first send starts the agent. + expect(() => codex.live()).toThrow('no codex app-server has been opened') const store = await readPersistedTestAgentSessionStoreText(root) // The record this create wrote, so the checks below read the runtime's own rows. expect(store).toContain('/home/dev/.codex') @@ -476,6 +494,11 @@ describe('a structured codex session over agentSession.*', () => { // which message landed where is knowable only from the echo. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) await handedOverAs({ threadId: THREAD, clientUserMessageId: sent.clientMessageId }) + expect(codex.live().launch.env).toMatchObject({ + CODEX_PROFILE: 'configured', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + }) codex.notify('turn/started', { turn: { id: TURN } }) // Codex echoes the message back carrying the `clientId` it was sent under, @@ -516,23 +539,17 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items).toEqual([]) - expect(codex.live().calls[0]).toMatchObject({ - method: 'thread/start', - params: { cwd: `/repos/${WORKSPACE}` } - }) const fence = created.fence const stream = await subscribe('sub-1') expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await call('agentSession.options', { sessionId: SESSION }) - expect(options).toMatchObject({ + // At rest the list is the host's model catalog, which this harness has none of; a pick made now + // is the record's intent, which the first send's start applies. + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } + result: { models: [] } }) await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), @@ -569,6 +586,11 @@ describe('a structured codex session over agentSession.*', () => { model: 'gpt-live', effort: 'high' }) + // The send started the agent; the create had not. + expect(codex.live().calls[0]).toMatchObject({ + method: 'thread/start', + params: { cwd: `/repos/${WORKSPACE}` } + }) // ── stream ────────────────────────────────────────────────────────────── codex.notify('turn/started', { turn: { id: TURN } }) @@ -678,17 +700,24 @@ describe('a structured codex session over agentSession.*', () => { ).toBe(true) expect(itemsOf(missed).map(textOf).filter(Boolean)).toEqual(['Stopped.']) - // A runtime taking the session over is the other half of reconnect: the - // fence advances, the old child is reaped, and its replacement resumes the - // thread this session proved rather than forking a new one. + // The other half of reconnect: a closed chat's next send starts a replacement that resumes the + // thread this session proved rather than forking a new one, at an advanced fence. const reaped = codex.live() - const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( - 'agentSession.ensure', - attachParams(fence) - ) - expect(resumed.fence).toBe(fence + 1) + const leaseFence = () => + getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence + // The create founded fence 1 and the first send's start moved it to 2. + expect(leaseFence()).toBe(fence + 1) + await ok('agentSession.close', { sessionId: SESSION }) expect(reaped.closed).toBe(true) + const closedFence = leaseFence() ?? 0 + // Still the create's fence: each write names its target, so a client's fence never goes stale. + await sendToStart(fence, 'again') + expect(leaseFence()).toBe(closedFence + 1) expect(codex.live().resumedThreadId).toBe(THREAD) + // The journal belongs to the session, not to the process that just died. + expect( + (await getStructuredAgentSessionHost()!.journalSnapshot(SESSION)).items.map(textOf) + ).toContain('Two files.') expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, result: { @@ -696,8 +725,6 @@ describe('a structured codex session over agentSession.*', () => { current: { model: 'gpt-live', effort: 'high' } } }) - // The journal belongs to the session, not to the process that just died. - expect(resumed.page.items.map(textOf)).toContain('Two files.') // ── page history ──────────────────────────────────────────────────────── const tail = await historyPage('tail', { limit: 2 }) @@ -724,6 +751,7 @@ describe('a structured codex session over agentSession.*', () => { 'tool-call', 'approval', 'status', + 'message', 'message' ]) expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([ @@ -733,24 +761,30 @@ describe('a structured codex session over agentSession.*', () => { '', '', '', - 'Stopped.' + 'Stopped.', + 'again' ]) }) it('caches shell exports but re-reads configured overrides for a resume', async () => { shellEnvironmentPolicy = { inheritAll: false, names: [] } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + // Nothing is read for a create: the first send's start reads the overrides. + expect(codexOverrideReads).toBe(0) + await sendToStart(created.fence) expect(codex.live().launch.env?.EXAMPLE_GATEWAY_TOKEN).toBeUndefined() expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ bootEnvironmentReads: 1, codexOverrideReads: 1 }) + const started = codex.live() configuredCodexProfile = 'updated' shellEnvironmentPolicy = { inheritAll: false, names: ['EXAMPLE_GATEWAY_TOKEN'] } - const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) + await ok('agentSession.close', { sessionId: SESSION }) + await sendToStart(created.fence, 'again') - expect(resumed.fence).toBe(created.fence + 1) + expect(codex.live()).not.toBe(started) expect(codex.live().resumedThreadId).toBe(THREAD) expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated', @@ -783,7 +817,8 @@ describe('a structured codex session over agentSession.*', () => { }) it('joins final deferred writes before runtime teardown completes', async () => { - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await sendToStart(created.fence) codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) codex.notify('item/started', { threadId: THREAD, @@ -842,86 +877,4 @@ describe('a structured codex session over agentSession.*', () => { ) ).toBe(false) }) - - it('persists truncated command output before publishing its journal row', async () => { - await ok<{ fence: number }>('agentSession.create', createIntentParams()) - const output = 'large command output\n'.repeat(2_000) - - codex.notify('item/completed', { - threadId: THREAD, - turnId: TURN, - item: { - type: 'commandExecution', - id: 'item-large-output', - command: 'print-many-lines', - status: 'completed', - exitCode: 0, - aggregatedOutput: output - } - }) - await drainStreamedEvents() - - const host = getStructuredAgentSessionHost() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') - const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined - expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) - }) - - it('keeps an answered prompt resolved after the provider exits', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) - codex.notify('item/started', { - threadId: THREAD, - turnId: TURN, - item: { - type: 'commandExecution', - id: 'item-needs-answer', - command: 'build', - status: 'inProgress' - } - }) - codex.ask(9, 'item/commandExecution/requestApproval', { - threadId: THREAD, - turnId: TURN, - itemId: 'item-needs-answer', - availableDecisions: ['accept', 'decline'] - }) - await drainStreamedEvents() - const host = getStructuredAgentSessionHost() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const approval = journal.snapshot().items.find((item) => item.body?.kind === 'approval') - expect(approval?.body).toMatchObject({ - kind: 'approval', - resolution: { state: 'pending' } - }) - - await ok('agentSession.respondToApproval', { - envelope: envelope( - 'agentSession.respondTo:approval', - { - itemId: approval?.itemId, - expectedRevision: approval?.revision, - optionId: 'accept' - }, - created.fence - ), - itemId: approval?.itemId, - expectedRevision: approval?.revision, - optionId: 'accept' - }) - codex.live().handlers.onExit?.(new Error('provider exited after answer')) - await drainStreamedEvents() - - expect( - journal.snapshot().items.find((item) => item.itemId === approval?.itemId)?.body - ).toMatchObject({ - kind: 'approval', - resolution: { state: 'resolved', selectedOptionId: 'accept' } - }) - }) }) diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index dd76d01e6c64..126084276220 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -12,6 +12,7 @@ import { journalPragmaNumber } from '../native-chat/agent-session-journal/journa import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { foundAndReserveTestAgentSessionRecord } from './agent-session-record-store-test-harness' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, @@ -118,7 +119,7 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( expect(first.unverifiable()).toBe(true) expect(databaseVersion()).toBe(3) // A chat started meanwhile lives in the database and keeps its row through the copy. - await host.deps.store.reserveOwner({ + await foundAndReserveTestAgentSessionRecord(host.deps.store, { sessionId: CREATED_WHILE_OWED, location: { executionHostId: 'local', @@ -128,7 +129,6 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, spawnToken: 'spawn-new', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 2727b2389f2f..379bd9827ec8 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' describe('structured session runtime provider-exit wiring', () => { @@ -94,7 +95,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) if (!attached.ok) { throw new Error( JSON.stringify({ refusal: attached.refusal, connections: connections.length }) @@ -199,7 +200,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) if (!attached.ok) { throw new Error( JSON.stringify({ refusal: attached.refusal, connections: connections.length }) @@ -305,7 +306,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) expect(attached.ok).toBe(true) connections[0]?.handlers.onExit?.(new Error('the first child exited')) await vi.waitFor(() => @@ -412,7 +413,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({ + expect(await attachForTests(host, { callerKey: 'runtime-test' }, attachParams)).toMatchObject({ ok: true }) diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 9bce63de54da..5bfd795a8dc5 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const STALLED = 'claude-started-stalled' const HEALTHY = 'claude-started-healthy' @@ -22,13 +23,17 @@ describe('a Claude child proving its start', () => { claude.behave(STALLED, { stallsControlReads: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(HEALTHY, null)) + ).resolves.toMatchObject({ ok: true }) await waitForStructuredAgentSessionRecovery() // This child answers startup, then never answers another control read. - await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(STALLED, null)) + ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => expect(claude.child(STALLED).calls).toContain('get_settings')) @@ -60,13 +65,15 @@ describe('a Claude child proving its start', () => { const host = await claude.install() // This start never returns from its spawn, so its lease stays reserved with no child. claude.behave(HEALTHY, { spawnHangs: true }) - void host.attach(CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) + void attachForTests(host, CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) await vi.waitFor(() => expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') ) const childrenWhileHung = claude.children(HEALTHY).length - await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(STALLED, null)) + ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => @@ -94,7 +101,9 @@ describe('a Claude child proving its start', () => { await writeHeld return replaceSessionOptions(input) }) - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(HEALTHY, null)) + ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => expect(writing).toBe(true)) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..909826d0f557 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -127,6 +127,7 @@ export type StructuredAgentSessionRuntimeDeps = { statusSink?: StructuredAgentSessionHostDeps['statusSink'] /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] + reviewRuntime?: StructuredAgentSessionHostDeps['reviewRuntime'] /** The account home a structured launch would pin right now, for catalog * reads with no session record. Absent disables the catalog surface. */ resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver @@ -322,6 +323,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.reviewRuntime ? { reviewRuntime: deps.reviewRuntime } : {}), ...(await modelCatalogHostDeps({ store, deps, envResolvers })) }) setStructuredAgentSessionHost(host) diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index aee47ab15bae..c4da330b75fb 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -10,7 +10,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-owner-probe' import { @@ -52,7 +55,7 @@ function environ(token: string | null): string { return ['PATH=/usr/bin', ...(token ? [`${CODEX_SPAWN_TOKEN_ENV}=${token}`] : [])].join('\0') } -function reserveRequest(): AgentSessionReserveRequest { +function reserveRequest(): Omit { const operationId = `${NOW}-${'1'.padStart(32, '0')}` return { sessionId: SESSION, @@ -64,7 +67,6 @@ function reserveRequest(): AgentSessionReserveRequest { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, spawnToken: MINTED_TOKEN, claimKeyId: 'key-1', handoffOperationId: null, @@ -100,7 +102,7 @@ describe('a process that inherited a spawn token', () => { it('is never signalled when the host installs and reconciles, even when this store minted the token', async () => { // A chat ran and ended: its root exited and the lease was released, clearing the token. const seed = await openStore() - const reserved = await seed.reserveOwner(reserveRequest()) + const reserved = await foundAndReserveTestAgentSessionRecord(seed, reserveRequest()) const fence = reserved.record.lease.runtimeFence await seed.commitProcessIdentity({ sessionId: SESSION, @@ -157,7 +159,7 @@ describe('a process that inherited a spawn token', () => { describe('the reservation owner probe', () => { async function reconcileReservation(): Promise { const crashed = await openStore() - await crashed.reserveOwner(reserveRequest()) + await foundAndReserveTestAgentSessionRecord(crashed, reserveRequest()) // The restart after a crash between reservation and recorded identity. const store = await openStore() await store.reconcileOnRestart({ diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 2e8080c9cd61..8e4f8c1869d3 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import { AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError @@ -26,15 +25,15 @@ import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' -import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' -import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' +import { idOf, isRecord } from './rpc/orchestration-session-caller-test-fixture' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' +import { createCoordinatorRpcCaller } from './structured-chat-coordinator-rpc.test-fixture' import { attachParams, fakeCodex, @@ -43,6 +42,7 @@ import { resetProviderFaults, type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' @@ -56,43 +56,13 @@ let runtime: OrcaRuntimeService let db: OrchestrationDb let host: StructuredAgentSessionHost let dispatcher: RpcDispatcher -let requests = 0 const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR) -function request( - method: string, - params: Record, - options: { sessionId?: string } = {} -): RpcRequest { - requests += 1 - return { - id: `rpc-${requests}`, - authToken: 'test', - method, - params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: `req-${requests}`, - ...(options.sessionId - ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } - : {}) - } -} - -async function call( - method: string, - params: Record, - options?: { sessionId?: string } -): Promise> { - const response = await dispatcher.dispatch(request(method, params, options)) - if (!response.ok) { - throw new Error(`${method} failed: ${JSON.stringify(response)}`) - } - return resultOf(response) -} +const { request, call } = createCoordinatorRpcCaller(() => dispatcher) async function openChat(sessionId: string): Promise { - const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) - expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + const opened = await attachForTests(host, { callerKey: 'test-surface' }, attachParams(sessionId)) + expect(opened, JSON.stringify(opened)).toMatchObject({ ok: true }) await host.setSessionTabVisibility(sessionId, true) threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) return connectionFor(sessionId) diff --git a/src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts new file mode 100644 index 000000000000..7479e0400b13 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts @@ -0,0 +1,44 @@ +// The orchestration RPC surface as the coordinator-mail suite calls it: each request numbered, +// under the current contract, naming the caller's agent session as its compatibility evidence. + +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import type { RpcRequest } from './rpc/core' +import type { RpcDispatcher } from './rpc/dispatcher' +import { resultOf } from './rpc/orchestration-session-caller-test-fixture' + +export function createCoordinatorRpcCaller(dispatcher: () => RpcDispatcher) { + let requests = 0 + + function request( + method: string, + params: Record, + options: { sessionId?: string } = {} + ): RpcRequest { + requests += 1 + return { + id: `rpc-${requests}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `req-${requests}`, + ...(options.sessionId + ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } + : {}) + } + } + + async function call( + method: string, + params: Record, + options?: { sessionId?: string } + ): Promise> { + const response = await dispatcher().dispatch(request(method, params, options)) + if (!response.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response)}`) + } + return resultOf(response) + } + + return { request, call } +} diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index eaf5644164f8..bfdfaa929deb 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -33,6 +33,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) @@ -132,7 +133,7 @@ describe('the chat strip and the session list read the same host child records', }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` - const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'switch-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) const fence = attached.ok ? attached.value.fence : 0 const summaries: AgentSessionStatusSummary[] = [] diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 47fa6d201009..f8b5ce539a86 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -24,6 +24,7 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' const caller = { callerKey: 'desktop' } @@ -103,7 +104,7 @@ async function seedPendingRewind(phase: 'prepared' | 'provider-succeeded') { async function reattach() { await host.close(HOST_TEST_SESSION, 'evict') - expect(await host.attach(caller, attachParams(fence()))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, attachParams(fence()))).toMatchObject({ ok: true }) } beforeEach(async () => { @@ -133,7 +134,7 @@ beforeEach(async () => { now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) }) - expect(await host.attach(caller, attachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, attachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 5e0cf36f14d7..0f90f1f0f57c 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -19,6 +19,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const THREAD = 'thread-runtime-child-work' @@ -89,7 +90,7 @@ describe('structured Codex child work through the production runtime', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) // Creating the session starts its child; nothing else has to keep it running. expect(connections).toHaveLength(1) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 0322a816d709..846cc0e71b11 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -17,6 +17,7 @@ import { registerRendererDocumentNavigation } from './renderer-document-navigati import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender' import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay' import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' +import { broadcastGitHubWorkItemMutation } from '../ipc/github-work-item-mutation-events' let runtimeNotifierTokenCounter = 0 let activeRuntimeNotifierToken: number | null = null @@ -43,6 +44,8 @@ export function registerRuntimeWindowLifecycle( }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), + githubPullRequestMutated: (payload) => + broadcastGitHubWorkItemMutation({ ...payload, type: 'pr' }), reposChanged: () => send('repos:changed'), automationsChanged: (payload) => send('automations:changed', payload), activateWorktree: ( diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index 61953989b364..c1a0c8e05824 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -195,7 +195,7 @@ export function CheckRunDetailsPanel({ toast.success( translate( 'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819', - 'Started an AI agent for this check.' + 'Sent to an AI agent for this check.' ) ) } diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts index 029ba1d0decd..2795bc853b15 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' @@ -210,5 +211,7 @@ describe('check-run-details-fix-with-ai', () => { }) ) expect(startFixChecksAgent.mock.calls[0]?.[0]?.basePrompt).toContain('assertion failed') + // Said once the chat has the prompt: its agent may still be starting. + expect(toast.success).toHaveBeenCalledWith('Sent to an AI agent for this check.') }) }) diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts index 394bcac8123e..eb3ab95074e4 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts @@ -89,7 +89,7 @@ export async function startCheckRunDetailsFixWithAI(args: { toast.success( translate( 'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819', - 'Started an AI agent for this check.' + 'Sent to an AI agent for this check.' ) ) } diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts index 21b10c2cd888..22c98e8b71db 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts @@ -244,7 +244,7 @@ export async function fixBrokenGitHubChecks({ toast.success( translate( 'auto.components.GitHubItemDialog.28986b3747', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) } diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59b1fa10802b..eb4bafc630c2 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -254,7 +254,9 @@ const PIECES: Record + translate('components.native-chat.failureWords.reviewReplyFailed', COPY.reviewReplyFailed) } export const sayAgentSessionFailureTranslated: AgentSessionFailureSay = (id, values = {}) => diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 7841e2de1908..d9efe25b1814 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -264,6 +264,27 @@ describe('a turn no message opened', () => { ]) }) + it('keeps an Orca failure about the conversation in sight when the turn around it folds', () => { + // A review reply's failed write lands mid-turn but belongs to no turn. + const messages = [ + text('u1', 'fix these', 'user'), + toolRun('work'), + failure('review-reply'), + text('a1', 'Done.') + ] + const slots = build(messages, { + turnKeys: ['u1', 'u1', undefined, 'u1'], + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + expect(slots.find((slot) => slot.message.id === 'review-reply')?.folded).toBe(false) + // Scoped to the turn instead, the agent's answer after it would fold it away. + const inTurn = build(messages, { + turnKeys: ['u1', 'u1', 'u1', 'u1'], + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + expect(inTurn.find((slot) => slot.message.id === 'review-reply')).toBeUndefined() + }) + it('folds an error the agent recovered from behind the answer that followed it', () => { const messages = [text('u1', 'go', 'user'), failure('retry'), text('a1', 'Done.')] const slots = build(messages, { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddba..e6615e2d0f8b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' @@ -180,7 +181,7 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + launch?: Pick ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -190,7 +191,7 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...(launch ? { source: 'launch' as const, ...launch } : {}) } return commitStructuredAgentSessionOutbox( sessionId, @@ -203,9 +204,15 @@ export function appendStructuredAgentSessionOutboxMessage( export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, - text: string + text: string, + reviewReply?: AgentSessionReviewReply ): StructuredAgentSessionOutboxEntry | null { - return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch') + return appendStructuredAgentSessionOutboxMessage( + sessionId, + text, + [], + reviewReply ? { reviewReply } : {} + ) } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts new file mode 100644 index 000000000000..3ecfb25f0681 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts @@ -0,0 +1,105 @@ +// A chat's older history, one page at a time behind the live window: concurrent asks share one +// page, and a page whose anchor a live batch slid past is read again rather than leave a hole. + +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' +import { + oldestStructuredAgentSessionCursor, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import type { NativeChatOlderPageResult } from './native-chat-pagination' + +/** Bounded so a busy stream cannot turn one scroll-to-top into an endless read chain. */ +export const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 + +export function createStructuredAgentSessionOlderPageReader(args: { + sessionId: string + target: RuntimeClientTarget + getState: () => StructuredAgentSessionState + apply: (action: StructuredAgentSessionAction) => void + /** The live read's guard: a page read under one that has since stopped is superseded. */ + captureHistoryReadGuard: () => () => boolean + markLoadingOlder: () => void + clearLoadingOlder: () => void +}): { loadOlder: () => Promise } { + let olderPage: { shouldStop: () => boolean; promise: Promise } | null = + null + const readOlderPage = async (shouldStop: () => boolean): Promise => { + try { + // A live batch can head-trim past the anchor mid-read, and the reducer drops + // that page rather than leave a hole in the transcript. Re-anchor and retry. + for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { + const cursor = oldestStructuredAgentSessionCursor(args.getState()) + if (shouldStop()) { + return 'superseded' + } + if (!cursor) { + return 'exhausted' + } + const result = await callStructuredAgentSession( + args.target, + 'agentSession.history', + { + sessionId: args.sessionId, + direction: 'before', + cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + } + ) + if (shouldStop()) { + return 'superseded' + } + if (!result.ok) { + return 'failed' + } + // The reducer drops a page whose anchor slid, so only an intact anchor lands. + if (oldestStructuredAgentSessionCursor(args.getState())?.sequence === cursor.sequence) { + args.apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) + if (oldestStructuredAgentSessionCursor(args.getState())?.sequence !== cursor.sequence) { + return 'applied' + } + return args.getState().hasOlder ? 'unchanged' : 'exhausted' + } + } + return 'unchanged' + } catch { + // A failed page leaves the loaded conversation intact; the list offers a retry, and + // the next invalidation (re-attach, snapshot, reset) re-enables paging. + return shouldStop() ? 'superseded' : 'failed' + } + } + + const loadOlder = (): Promise => { + // Concurrent callers (scroll-to-top, the button, a rail jump) share one page + // and its result rather than reading a refusal as "no progress". + if (olderPage && !olderPage.shouldStop()) { + return olderPage.promise + } + const shouldStop = args.captureHistoryReadGuard() + if (shouldStop()) { + return Promise.resolve('superseded') + } + if (!oldestStructuredAgentSessionCursor(args.getState()) || !args.getState().hasOlder) { + return Promise.resolve('exhausted') + } + args.markLoadingOlder() + const page = { shouldStop, promise: readOlderPage(shouldStop) } + olderPage = page + void page.promise.finally(() => { + if (olderPage !== page) { + return + } + olderPage = null + if (!shouldStop()) { + args.clearLoadingOlder() + } + }) + return page.promise + } + return { loadOlder } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts new file mode 100644 index 000000000000..8151f9f5cdec --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom + +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' + +const mocks = vi.hoisted( + (): { + applyEvent?: (event: AgentSessionSubscribeEvent) => void + applyError?: (message: string, refusal?: AgentSessionRefusalReference) => void + started: number + disposed: number + } => ({ started: 0, disposed: 0 }) +) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn() +})) +vi.mock('./structured-agent-session-read-transport', () => ({ + startStructuredAgentSessionReadTransport: (args: { + applyEvent: (event: AgentSessionSubscribeEvent) => void + applyError: (message: string, refusal?: AgentSessionRefusalReference) => void + }) => { + mocks.started += 1 + mocks.applyEvent = args.applyEvent + mocks.applyError = args.applyError + return { + captureHistoryReadGuard: () => () => false, + dispose: () => { + mocks.disposed += 1 + } + } + } +})) + +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../shared/agent-session-review-reply' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { + getStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from './structured-agent-session-read-owner' + +afterEach(() => { + resetStructuredAgentSessionReadOwnersForTests() +}) + +function receipt(): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'e', sequence: 3 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + } +} + +it("hears a hidden chat's review-reply receipt: the armed watch keeps the chat's read open", () => { + const settled = vi.fn() + // No pane shows the chat: only the watch activates its read. + watchStructuredReviewReplySettled('session-1', settled, { + holdRead: () => getStructuredAgentSessionReadOwner('session-1', { kind: 'local' }).activate() + }) + expect(mocks.started).toBe(1) + + mocks.applyEvent?.(receipt()) + + expect(settled).toHaveBeenCalledOnce() + // Fired, so the read it held is let go. + expect(mocks.disposed).toBe(1) +}) + +it('lets go of the read once the host says it holds no such chat', () => { + const settled = vi.fn() + mocks.disposed = 0 + watchStructuredReviewReplySettled('session-1', settled, { + holdRead: () => getStructuredAgentSessionReadOwner('session-1', { kind: 'local' }).activate() + }) + + mocks.applyError?.('no such chat', { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + }) + mocks.applyEvent?.(receipt()) + + expect(mocks.disposed).toBe(1) + expect(settled).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index e3f87a8e95e6..297470ff3e7b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -16,6 +16,14 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { NATIVE_CHAT_INITIAL_LIMIT, type NativeChatOlderPageResult } from './native-chat-pagination' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' +import { + dropStructuredReviewReplyWatchers, + noticeStructuredReviewReplyReceipt +} from '@/lib/structured-agent-session-review-reply-settled' +import { + createStructuredAgentSessionOlderPageReader, + OLDER_PAGE_ANCHOR_ATTEMPTS +} from './structured-agent-session-read-older-pages' export type StructuredAgentSessionReadSnapshot = { state: StructuredAgentSessionState @@ -35,9 +43,6 @@ export type StructuredAgentSessionReadOwner = { const owners = new Map() -/** Bounded so a busy stream cannot turn one scroll-to-top into an endless read chain. */ -const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 - function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean { return item.body.kind !== 'status' || !item.body.providerFrame } @@ -175,55 +180,32 @@ function createReadOwner( } } - let olderPage: { shouldStop: () => boolean; promise: Promise } | null = - null - const readOlderPage = async (shouldStop: () => boolean): Promise => { - try { - // A live batch can head-trim past the anchor mid-read, and the reducer drops - // that page rather than leave a hole in the transcript. Re-anchor and retry. - for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { - const cursor = oldestStructuredAgentSessionCursor(snapshot.state) - if (shouldStop()) { - return 'superseded' - } - if (!cursor) { - return 'exhausted' - } - const result = await callStructuredAgentSession( - target, - 'agentSession.history', - { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } - ) - if (shouldStop()) { - return 'superseded' - } - if (!result.ok) { - return 'failed' - } - // The reducer drops a page whose anchor slid, so only an intact anchor lands. - if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence === cursor.sequence) { - apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) - if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence !== cursor.sequence) { - return 'applied' - } - return snapshot.state.hasOlder ? 'unchanged' : 'exhausted' - } - } - return 'unchanged' - } catch { - // A failed page leaves the loaded conversation intact; the list offers a retry, and - // the next invalidation (re-attach, snapshot, reset) re-enables paging. - return shouldStop() ? 'superseded' : 'failed' - } - } + const olderPages = createStructuredAgentSessionOlderPageReader({ + sessionId, + target, + getState: () => snapshot.state, + apply, + captureHistoryReadGuard: () => captureActiveHistoryReadGuard(), + markLoadingOlder: () => setSnapshot({ ...snapshot, loadingOlder: true }), + clearLoadingOlder + }) const start = (): void => { if (snapshot.state.epoch === null) { apply({ type: 'loading' }) } const transport = startStructuredAgentSessionReadTransport({ - applyEvent: (event) => apply({ type: 'event', event }), - applyError: (message, refusal) => apply({ type: 'error', message, refusal }), + applyEvent: (event) => { + apply({ type: 'event', event }) + noticeStructuredReviewReplyReceipt(sessionId, event) + }, + applyError: (message, refusal) => { + apply({ type: 'error', message, refusal }) + // The host holds no such chat: nothing it would say about a review reply is coming. + if (refusal?.details?.reason === 'recordMissing') { + dropStructuredReviewReplyWatchers(sessionId) + } + }, getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, @@ -265,33 +247,7 @@ function createReadOwner( stopActiveRun?.() }, getSnapshot: () => snapshot, - loadOlder: () => { - // Concurrent callers (scroll-to-top, the button, a rail jump) share one page - // and its result rather than reading a refusal as "no progress". - if (olderPage && !olderPage.shouldStop()) { - return olderPage.promise - } - const shouldStop = captureActiveHistoryReadGuard() - if (shouldStop()) { - return Promise.resolve('superseded') - } - if (!oldestStructuredAgentSessionCursor(snapshot.state) || !snapshot.state.hasOlder) { - return Promise.resolve('exhausted') - } - setSnapshot({ ...snapshot, loadingOlder: true }) - const page = { shouldStop, promise: readOlderPage(shouldStop) } - olderPage = page - void page.promise.finally(() => { - if (olderPage !== page) { - return - } - olderPage = null - if (!shouldStop()) { - clearLoadingOlder() - } - }) - return page.promise - }, + loadOlder: olderPages.loadOlder, subscribe: (listener) => { listeners.add(listener) return () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index eaee915ac685..fce253879aba 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -21,6 +21,9 @@ vi.mock('@/runtime/runtime-host-contact-regained', () => ({ import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../shared/agent-session-review-reply' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' type SubscribeAttempt = { closed: PromiseWithResolvers<{ unsubscribe: () => void }> @@ -609,4 +612,43 @@ describe('structured agent-session read transport unattached refusals', () => { startWithHydration(async () => undefined, vi.fn()).dispose() expect(mocks.watchHostContact).not.toHaveBeenCalled() }) + + it("leaves a review reply's receipt to the read owner, so one batch is noticed once", async () => { + vi.useFakeTimers() + try { + const settled = vi.fn() + const dispose = watchStructuredReviewReplySettled('session-a', settled) + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: () => {}, + applyError: vi.fn(), + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + sessionId: 'session-a', + target + }) + await vi.advanceTimersByTimeAsync(0) + attempts[0].onEvent({ + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 3 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + }) + await vi.advanceTimersByTimeAsync(100) + + expect(settled).not.toHaveBeenCalled() + transport.dispose() + dispose() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx index 9545c083fa76..6a744ae1e053 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx @@ -144,7 +144,7 @@ describe('an outbox on a host that accepts a send before any agent has it', () = }) ) setItem.mockRestore() - expect(delivery).toEqual({ delivered: false, failureNotified: false }) + expect(delivery).toEqual({ delivered: false, failureNotified: false, heldByChat: true }) expect(mocks.call).not.toHaveBeenCalled() expect(result.current.outbox).toMatchObject([{ state: 'queued' }]) diff --git a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx index f43c6f6be53a..74ea939b48d1 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx +++ b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx @@ -59,7 +59,7 @@ export function ChecksFixDialog({ toast.success( translate( 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) }} diff --git a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts index 3944540ef66e..80272aee4938 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts +++ b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts @@ -119,7 +119,7 @@ export async function fixBrokenPullRequestChecks(args: { toast.success( translate( 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) } diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 1e508b917145..7178be3bddb0 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -1,3 +1,5 @@ +import type { SourceControlAgentLaunched } from './runSourceControlAgentActionStart' +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import React from 'react' import { Dialog, @@ -46,7 +48,11 @@ export type SourceControlAgentActionDialogProps = { onLaunchAccepted?: () => void /** Fires when an accepted launch later failed to deliver its prompt. */ onLaunchAborted?: () => void - onLaunched?: () => void + /** `reviewReplyCarried`: a structured chat's message carries `reviewReply`, which its host writes + * once the agent takes the message; the caller writes nothing to the review itself. */ + onLaunched?: (launch: SourceControlAgentLaunched) => void + /** Planned at start, for a structured chat's launch prompt to carry. */ + reviewReply?: () => AgentSessionReviewReply | undefined startLabel?: string onStart?: (args: { agent: TuiAgent diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts index fadef627d972..835416182f04 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts @@ -62,6 +62,7 @@ export type ChecksPanelActiveContentModel = Pick< Pick< ChecksPanelAiAcknowledgementState, | 'consumeClaimedCommentResolutionAfterDeliveryRef' + | 'buildLaunchReviewReply' | 'handleLaunchAborted' | 'handleLaunchAccepted' > & diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index 9f1cc498e312..cdd3cbe4336c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -59,6 +59,7 @@ export function ChecksPanelActiveContent({ commentsSelectionClearRequest, conflictDetailsRefreshing, consumeClaimedCommentResolutionAfterDeliveryRef, + buildLaunchReviewReply, detachedHeadDisplay, editingTitle, getGitLabProjectRef, @@ -327,29 +328,33 @@ export function ChecksPanelActiveContent({ : null } onSaveAgentDefault={saveLaunchActionDefault} - // Why: claims the ack payload when the tab exists; the host writes still wait for delivery. + // Why: a structured chat's launch prompt carries the review writes for its host to make. + reviewReply={buildLaunchReviewReply} + // Why: claims the ack payload when the tab exists; the writes still wait for delivery. onLaunchAccepted={handleLaunchAccepted} onLaunchAborted={handleLaunchAborted} - onLaunched={() => { - // Why: prompt delivery succeeded — the only point at which host replies/resolves may run. - consumeClaimedCommentResolutionAfterDeliveryRef.current() + onLaunched={(launch) => { + // Why: the prompt was delivered (a paste landed, or a chat recorded its message): the panel + // writes now, unless the chat's message carries the writes for its host. + consumeClaimedCommentResolutionAfterDeliveryRef.current(launch) if (agentComposerState?.actionId === 'resolveConflicts') { toast.success( translate( 'auto.components.right.sidebar.ChecksPanel.a0181a8d76', - 'Started an AI agent for the conflicts.' + 'Sent to an AI agent for the conflicts.' ) ) return } if (agentComposerState?.actionId === 'resolveComments') { - // Why: resolve/reply toast is emitted by resolveSelectedThreadsAfterLaunch. + // Why: a terminal's writes report from resolveSelectedThreadsAfterLaunch; a chat's from + // its own failure line. return } toast.success( translate( 'auto.components.right.sidebar.ChecksPanel.2ef90c9819', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) }} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx new file mode 100644 index 000000000000..07f24df3e7cc --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -0,0 +1,396 @@ +// @vitest-environment happy-dom + +// "Resolve comments with AI" into a structured chat hands its review writes to the launch prompt, +// whose host runs them once the agent takes the message: the panel itself writes nothing. A +// terminal agent has no message to carry them, so the panel still writes once the paste lands. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type ReadHold = { sessionId: string; target: unknown; released: boolean } + +const mocks = vi.hoisted(() => ({ + hostRunsReviewReplies: vi.fn(async () => true), + readHolds: new Array(), + launchAgentInNewTab: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn() +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/lib/structured-agent-session-review-reply-support', () => ({ + structuredChatHostRunsReviewReplies: mocks.hostRunsReviewReplies +})) +vi.mock('@/components/native-chat/structured-agent-session-read-owner', () => ({ + getStructuredAgentSessionReadOwner: (sessionId: string, target: unknown) => ({ + activate: () => { + const hold = { sessionId, target, released: false } + mocks.readHolds.push(hold) + return () => { + hold.released = true + } + } + }) +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) + +import type { PRComment } from '../../../../../shared/github/comment-types' +import { + clearPendingPRCommentAiAck, + setPendingPRCommentAiAck, + type PendingPRCommentAiAck +} from '../pr-comments-ai-launch-ack' +import { runSourceControlAgentActionStart } from '../runSourceControlAgentActionStart' +import { useChecksPanelAiAcknowledgement } from './use-checks-panel-ai-acknowledgement' +import { agentJournalItemKey } from '../../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../../shared/agent-session-review-reply' +import { noticeStructuredReviewReplyReceipt } from '@/lib/structured-agent-session-review-reply-settled' + +const REVIEW_KEY = 'repo-1::42::sha-1' + +function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'alice', + authorAvatarUrl: '', + body: 'Please update this.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r1', + ...overrides + } +} + +/** An open review thread and a conversation comment. */ +function resolution(): PendingPRCommentAiAck { + const target = { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 } + return { + reviewContextKey: REVIEW_KEY, + provider: 'github', + selectedGroups: [ + { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'src/a.ts', isResolved: false }), + replies: [] + }, + { + kind: 'standalone', + comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) + } + ], + githubTarget: { ...target, prRepo: { owner: 'acme', repo: 'widgets' } }, + githubResolveTarget: target + } +} + +type AcknowledgementInput = Parameters[0] + +function acknowledgement(pending: PendingPRCommentAiAck = resolution()) { + setPendingPRCommentAiAck(pending) + const model = { + addPRConversationComment: vi.fn(async () => ({ + ok: true, + comment: comment({ id: 30 }) + })), + addPRReviewCommentReply: vi.fn(async () => ({ + ok: true, + comment: comment({ id: 31, threadId: 'T1' }) + })), + resolveReviewThread: vi.fn(async () => true), + asyncResultKeyRef: { current: REVIEW_KEY }, + claimedCommentResolutionRef: { current: null }, + commentsRef: { current: [] }, + commentsSelectionClearTokenRef: { current: 0 }, + pendingCommentResolutionRef: { current: pending }, + commentResolutionLaunchAcceptedRef: { current: false }, + setCommentResolutionAckBusyNow: vi.fn(), + setComments: vi.fn(), + setCommentsSelectionClearRequest: vi.fn(), + settings: null, + fetchComments: vi.fn(async () => {}), + fetchGitLabDetails: vi.fn(async () => {}) + } satisfies AcknowledgementInput + const hook = renderHook(() => useChecksPanelAiAcknowledgement(model)) + return { model, hook } +} + +function resolveCommentsWithAi(hook: ReturnType['hook']) { + return act(() => + runSourceControlAgentActionStart({ + selectedAgent: 'claude', + trimmedCommandInput: 'Fix the comments', + agentArgs: '', + agentArgsApply: false, + commandTemplate: '{basePrompt}', + saveTargetValue: 'none', + actionId: 'resolveComments', + repoId: null, + settings: null, + repo: null, + worktreeId: 'wt-1', + groupId: 'wt-1', + promptDelivery: 'submit-after-ready', + launchPlatform: 'linux', + launchSource: 'task_page', + reviewReply: hook.result.current.buildLaunchReviewReply, + onLaunchAccepted: hook.result.current.handleLaunchAccepted, + onLaunchAborted: hook.result.current.handleLaunchAborted, + onLaunched: (launch) => + hook.result.current.consumeClaimedCommentResolutionAfterDeliveryRef.current(launch), + onClose + }) + ) +} + +const onClose = vi.fn() +const DELIVERED = Promise.resolve({ delivered: true, failureNotified: false }) +const CARRIED = Promise.resolve({ + delivered: true, + failureNotified: false, + reviewReplyCarried: true as const +}) + +describe('Resolve comments with AI', () => { + beforeEach(() => { + vi.clearAllMocks() + clearPendingPRCommentAiAck() + }) + afterEach(() => { + cleanup() + }) + + it('hands a structured chat its review writes and writes nothing itself', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + reviewReply: expect.objectContaining({ + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + resolve: ['T1'], + conversationReply: expect.stringContaining('Fixing') + }) + }) + ) + expect(model.resolveReviewThread).not.toHaveBeenCalled() + expect(model.addPRConversationComment).not.toHaveBeenCalled() + expect(model.addPRReviewCommentReply).not.toHaveBeenCalled() + // No counts toast: the chat says it if a write fails. + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(model.setCommentsSelectionClearRequest).toHaveBeenCalledOnce() + expect(model.pendingCommentResolutionRef.current).toBeNull() + }) + + /** The chat's live stream bringing its review-reply receipt (a tombstone: all went through). */ + function receiptArrives(sessionId: string): void { + noticeStructuredReviewReplyReceipt(sessionId, { + type: 'batch', + sessionId, + batch: { + cursor: { epoch: 'e', sequence: 9 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + }) + } + + it('refetches the PR once the chat says its host wrote, on this host or a paired one', async () => { + mocks.readHolds.length = 0 + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'host-published' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'paired-session' }), + structuredChatTarget: { kind: 'environment', environmentId: 'paired-1' } + }) + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + expect(model.fetchComments).not.toHaveBeenCalled() + + // While it waits, the chat's read stays open on its host, shown or not. + expect(mocks.readHolds).toEqual([ + { + sessionId: 'paired-session', + target: { kind: 'environment', environmentId: 'paired-1' }, + released: false + } + ]) + + receiptArrives('paired-session') + receiptArrives('paired-session') + + expect(model.fetchComments).toHaveBeenCalledExactlyOnceWith({ force: true }) + expect(mocks.readHolds[0]?.released).toBe(true) + }) + + it("refetches with the panel's latest fetch after the agent pushed, and not at all on another PR", async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }), + structuredChatTarget: { kind: 'local' } + }) + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + // The agent pushed: same PR, new head, and the panel's fetch was rebuilt for it. + const fetchAtNewHead = vi.fn(async () => {}) + model.asyncResultKeyRef.current = 'repo-1::42::sha-2' + model.fetchComments = fetchAtNewHead + hook.rerender() + + receiptArrives('session-1') + + expect(fetchAtNewHead).toHaveBeenCalledExactlyOnceWith({ force: true }) + + const again = acknowledgement() + await expect(resolveCommentsWithAi(again.hook)).resolves.toBe(true) + again.model.asyncResultKeyRef.current = 'repo-1::77::sha-9' + again.hook.rerender() + + receiptArrives('session-1') + + expect(again.model.fetchComments).not.toHaveBeenCalled() + }) + + it('stops waiting for the receipt when the panel goes away', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }), + structuredChatTarget: { kind: 'local' } + }) + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + hook.unmount() + receiptArrives('session-1') + + expect(model.fetchComments).not.toHaveBeenCalled() + }) + + it('hands the comments to a chat that still holds its unrecorded prompt, and offers no second Start', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: Promise.resolve({ + delivered: false, + failureNotified: false, + heldByChat: true, + reviewReplyCarried: true + }), + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.toastError).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledOnce() + expect(model.resolveReviewThread).not.toHaveBeenCalled() + expect(model.addPRConversationComment).not.toHaveBeenCalled() + expect(model.setCommentsSelectionClearRequest).toHaveBeenCalledOnce() + expect(model.pendingCommentResolutionRef.current).toBeNull() + }) + + it('hands the comments back when the chat holds nothing to send again', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false }), + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(false) + + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(onClose).not.toHaveBeenCalled() + expect(model.pendingCommentResolutionRef.current).toMatchObject({ + reviewContextKey: REVIEW_KEY + }) + }) + + it("says once, at launch, that it can't reply without the PR's repository", async () => { + const { githubTarget: _missing, ...withoutReplyTarget } = resolution() + const { model, hook } = acknowledgement(withoutReplyTarget) + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + reviewReply: expect.objectContaining({ replies: [], resolve: ['T1'] }) + }) + ) + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + "Couldn't find the GitHub PR to reply on. Reply to those comments yourself." + ) + expect(model.resolveReviewThread).not.toHaveBeenCalled() + }) + + it('writes once from the panel when the chat runs on a host that cannot carry the reply', async () => { + mocks.hostRunsReviewReplies.mockResolvedValueOnce(false) + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'host-published' }, + promptDeliveryResult: DELIVERED, + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.not.objectContaining({ reviewReply: expect.anything() }) + ) + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledOnce()) + expect(model.addPRConversationComment).toHaveBeenCalledOnce() + }) + + it('writes once from the panel when a paired host declined the chat and its terminal took the paste', async () => { + const { model, hook } = acknowledgement() + // The structured route was taken, but the host opened a terminal, whose paste carried no reply. + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'host-published' }, + promptDeliveryResult: DELIVERED, + structuredSettlement: Promise.resolve({ kind: 'terminal' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ reviewReply: expect.anything() }) + ) + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledOnce()) + expect(model.addPRConversationComment).toHaveBeenCalledOnce() + }) + + it('writes once from the panel when a terminal agent took the paste', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-terminal', tabId: 'tab-1' }, + promptDeliveryResult: DELIVERED + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledOnce()) + expect(model.addPRConversationComment).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx index 5b587fbe4784..4c965dffe9ba 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx @@ -127,6 +127,7 @@ describe('checks panel concrete content', () => { commentsSelectionClearRequest: null, conflictDetailsRefreshing: false, consumeClaimedCommentResolutionAfterDeliveryRef: { current: vi.fn() }, + buildLaunchReviewReply: vi.fn(() => undefined), detachedHeadDisplay: null, editingTitle: false, getGitLabProjectRef: vi.fn(() => null), diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index d1441f09ff42..26a0ba745902 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -7,10 +7,15 @@ import { canPostPRReviewThreadReply, checksPanelReviewStableKey, hasPRCommentGroupNeedingReply, + peekPendingPRCommentAiAck, resolvePRReviewReplyThreadId, setPendingPRCommentAiAck, takePendingPRCommentAiAck } from '../pr-comments-ai-launch-ack' +import { buildPRCommentReviewReply } from '../pr-comment-review-reply-spec' +import type { SourceControlAgentLaunched } from '../runSourceControlAgentActionStart' +import { useChecksPanelReviewReplyRefetch } from './use-checks-panel-review-reply-refetch' +import type { AgentSessionReviewReply } from '../../../../../shared/agent-session-review-reply' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelReviewDataState } from './use-checks-panel-review-data' import type { ChecksPanelPollingState } from './use-checks-panel-polling' @@ -41,6 +46,12 @@ type ChecksPanelAiAcknowledgementInput = Pick< Pick & Pick +const cannotReplyOnPR = (): string => + translate( + 'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0', + "Couldn't find the GitHub PR to reply on. Reply to those comments yourself." + ) + export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgementInput) { const { addPRConversationComment, @@ -99,10 +110,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem resolution.provider === 'github' && githubTarget == null && hasPRCommentGroupNeedingReply(resolution.selectedGroups) - ? translate( - 'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0', - 'Could not resolve the GitHub PR to reply on.' - ) + ? cannotReplyOnPR() : undefined const resolveSnapshottedThread = buildSnapshottedThreadResolver({ provider: resolution.provider, @@ -301,38 +309,73 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem commentResolutionLaunchAcceptedRef ]) - /** Prompt reached the agent: only now may Orca write to the host. */ - const consumeClaimedCommentResolutionAfterDelivery = useCallback((): void => { - const resolution = - claimedCommentResolutionRef.current ?? - takePendingPRCommentAiAck() ?? - pendingCommentResolutionRef.current - claimedCommentResolutionRef.current = null - pendingCommentResolutionRef.current = null - commentResolutionLaunchAcceptedRef.current = false - if (!resolution) { - setCommentResolutionAckBusyNow(false) - return - } - setCommentResolutionAckBusyNow(true) - void resolveSelectedThreadsAfterLaunch(resolution) - .catch((err) => { - console.warn('Failed to resolve/reply on selected review comments after AI launch:', err) - toast.error( - translate( - 'auto.components.right.sidebar.ChecksPanel.495b2f8c4b', - 'Started the agent, but could not resolve or reply on the selected comments.' + const refetchOnceChatWrote = useChecksPanelReviewReplyRefetch({ + asyncResultKeyRef, + refreshCommentsAfterBulkResolve + }) + + /** What a structured chat's launch prompt carries for its host to write once the agent takes it. */ + const buildLaunchReviewReply = useCallback((): AgentSessionReviewReply | undefined => { + const pending = peekPendingPRCommentAiAck() ?? pendingCommentResolutionRef.current + return pending ? buildPRCommentReviewReply(pending, commentsRef.current) : undefined + }, [commentsRef, pendingCommentResolutionRef]) + + /** Prompt reached the agent: only now may Orca write to the host. A structured chat's message + * carries the writes for its host instead, so here they are only handed off. */ + const consumeClaimedCommentResolutionAfterDelivery = useCallback( + (launch?: SourceControlAgentLaunched): void => { + const resolution = + claimedCommentResolutionRef.current ?? + takePendingPRCommentAiAck() ?? + pendingCommentResolutionRef.current + claimedCommentResolutionRef.current = null + pendingCommentResolutionRef.current = null + commentResolutionLaunchAcceptedRef.current = false + if (!resolution) { + setCommentResolutionAckBusyNow(false) + return + } + if (launch?.reviewReplyCarried) { + clearSentCommentSelection(resolution.reviewContextKey) + setCommentResolutionAckBusyNow(false) + // The chat's host may be a paired server, whose own mutation notice never reaches this + // client: refetch once the chat says its host wrote. + if (launch.chat) { + refetchOnceChatWrote(launch.chat, resolution) + } + // The one write the chat's host can't make: without the PR's repository it can't reply. + if ( + resolution.provider === 'github' && + !resolution.githubTarget && + hasPRCommentGroupNeedingReply(resolution.selectedGroups) + ) { + toast.error(cannotReplyOnPR()) + } + return + } + setCommentResolutionAckBusyNow(true) + void resolveSelectedThreadsAfterLaunch(resolution) + .catch((err) => { + console.warn('Failed to resolve/reply on selected review comments after AI launch:', err) + toast.error( + translate( + 'auto.components.right.sidebar.ChecksPanel.495b2f8c4b', + 'Started the agent, but could not resolve or reply on the selected comments.' + ) ) - ) - }) - .finally(() => setCommentResolutionAckBusyNow(false)) - }, [ - resolveSelectedThreadsAfterLaunch, - setCommentResolutionAckBusyNow, - claimedCommentResolutionRef, - pendingCommentResolutionRef, - commentResolutionLaunchAcceptedRef - ]) + }) + .finally(() => setCommentResolutionAckBusyNow(false)) + }, + [ + clearSentCommentSelection, + refetchOnceChatWrote, + resolveSelectedThreadsAfterLaunch, + setCommentResolutionAckBusyNow, + claimedCommentResolutionRef, + pendingCommentResolutionRef, + commentResolutionLaunchAcceptedRef + ] + ) // Why: auto-start can capture a stale callback; always call the latest consumer. const consumeClaimedCommentResolutionAfterDeliveryRef = useRef( consumeClaimedCommentResolutionAfterDelivery @@ -364,6 +407,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem resolveSelectedThreadsAfterLaunch, handleLaunchAccepted, handleLaunchAborted, + buildLaunchReviewReply, consumeClaimedCommentResolutionAfterDeliveryRef } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index b9cc54d1a18a..66625156f264 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -140,7 +140,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe toast.success( translate( 'auto.components.right.sidebar.ChecksPanel.2ef90c9819', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx new file mode 100644 index 000000000000..4866d838d17f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import { getStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { + checksPanelReviewStableKey, + type PendingPRCommentAiAck +} from '../pr-comments-ai-launch-ack' +import type { SourceControlAgentLaunched } from '../runSourceControlAgentActionStart' +import type { ChecksPanelReview } from '../checks-panel-review' + +/** + * Refetches the PR once a structured chat says its host wrote the review reply it was handed: the + * host may be a paired server, whose own mutation notice never reaches this client. Each watch + * keeps the chat's read open while armed (owed work does, so a hidden chat still hears it) and + * dies once run, with the panel, when the chat closes, or after the reply's window. + */ +export function useChecksPanelReviewReplyRefetch(args: { + asyncResultKeyRef: RefObject + refreshCommentsAfterBulkResolve: (provider: ChecksPanelReview['provider']) => Promise +}): ( + chat: NonNullable, + resolution: PendingPRCommentAiAck +) => void { + const { asyncResultKeyRef, refreshCommentsAfterBulkResolve } = args + const watches = useRef(new Set<() => void>()) + const refreshLatest = useRef(refreshCommentsAfterBulkResolve) + useEffect(() => { + refreshLatest.current = refreshCommentsAfterBulkResolve + }, [refreshCommentsAfterBulkResolve]) + useEffect(() => { + const armed = watches.current + return () => { + for (const dispose of armed) { + dispose() + } + armed.clear() + } + }, []) + return useCallback( + (chat, resolution) => { + const provider = resolution.provider + const launchKey = checksPanelReviewStableKey(resolution.reviewContextKey) + const dispose = watchStructuredReviewReplySettled( + chat.sessionId, + () => { + watches.current.delete(dispose) + // Possibly hours later: only the review the panel shows now, with its fetch of now. + if (checksPanelReviewStableKey(asyncResultKeyRef.current) === launchKey) { + void refreshLatest.current(provider) + } + }, + { + holdRead: () => getStructuredAgentSessionReadOwner(chat.sessionId, chat.target).activate() + } + ) + watches.current.add(dispose) + }, + [asyncResultKeyRef] + ) +} diff --git a/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts new file mode 100644 index 000000000000..b3a624eda425 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { PRComment } from '../../../../shared/github/comment-types' +import { MAX_REVIEW_REPLY_TARGETS } from '../../../../shared/agent-session-review-reply' +import { buildPRCommentReviewReply } from './pr-comment-review-reply-spec' +import { PR_COMMENT_AI_FIXING_REPLY } from './pr-comment-fixing-reply-body' +import type { PendingPRCommentAiAck } from './pr-comments-ai-launch-ack' + +function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'alice', + authorAvatarUrl: '', + body: 'Please update this.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r1', + ...overrides + } +} + +const TARGET = { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 } + +/** An open thread to resolve, an outdated thread with a path only, and a conversation comment. */ +function github(): PendingPRCommentAiAck { + return { + reviewContextKey: 'repo-1::42::sha-1', + provider: 'github', + selectedGroups: [ + { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'src/a.ts', isResolved: false }), + replies: [] + }, + { kind: 'standalone', comment: comment({ id: 11, path: 'src/b.ts', line: 4 }) }, + { + kind: 'standalone', + comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) + } + ], + githubTarget: { ...TARGET, prRepo: { owner: 'acme', repo: 'widgets' } }, + githubResolveTarget: TARGET + } +} + +describe('the review reply a launch hands a structured chat', () => { + it('resolves what the host can close and replies, in the thread or once on the PR, to the rest', () => { + const live = [comment({ id: 12, threadId: 'T2', path: 'src/b.ts', line: 4 })] + + expect(buildPRCommentReviewReply(github(), live)).toEqual({ + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + prRepo: { owner: 'acme', repo: 'widgets' }, + resolve: ['T1'], + replies: [{ commentId: 11, threadId: 'T2', path: 'src/b.ts', line: 4 }], + replyBody: PR_COMMENT_AI_FIXING_REPLY, + conversationReply: expect.stringContaining('Fixing') + }) + }) + + it('only resolves on GitLab', () => { + const ack: PendingPRCommentAiAck = { + ...github(), + provider: 'gitlab', + gitlabTarget: { repoPath: '/repos/widgets', repoId: 'repo-1', iid: 8 } + } + + expect(buildPRCommentReviewReply(ack, [])).toEqual({ + provider: 'gitlab', + repoId: 'repo-1', + iid: 8, + resolve: ['T1'] + }) + }) + + it('leaves off a reply past its bounds rather than fail the launch', () => { + const many = Array.from({ length: MAX_REVIEW_REPLY_TARGETS + 1 }, (_, index) => ({ + kind: 'thread' as const, + threadId: `T${index}`, + root: comment({ id: index + 1, threadId: `T${index}`, path: 'a.ts', isResolved: false }), + replies: [] + })) + + expect(buildPRCommentReviewReply({ ...github(), selectedGroups: many }, [])).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts new file mode 100644 index 000000000000..ecca5fc66ef1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts @@ -0,0 +1,93 @@ +// The review reply a "Resolve comments with AI" launch hands a structured chat: what the checks +// panel would resolve and post after the paste, planned here and run by the host once the agent +// takes the message. + +import type { PRComment } from '../../../../shared/github/comment-types' +import { + AgentSessionReviewReplySchema, + type AgentSessionReviewReply +} from '../../../../shared/agent-session-review-reply' +import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt' +import { + buildPRCommentBatchConversationReplyBody, + PR_COMMENT_AI_FIXING_REPLY +} from './pr-comment-fixing-reply-body' +import { + canPostPRReviewThreadReply, + getPRCommentGroupReplyTarget, + getPRCommentGroupsNeedingReply, + resolvePRReviewReplyThreadId, + type PendingPRCommentAiAck +} from './pr-comments-ai-launch-ack' + +/** Undefined when the review has nothing a host can write to, or the spec breaks its bounds. */ +export function buildPRCommentReviewReply( + ack: PendingPRCommentAiAck, + liveComments: readonly PRComment[] +): AgentSessionReviewReply | undefined { + const resolve = ack.selectedGroups.filter(isResolvablePRCommentGroup).map((g) => g.threadId) + const spec = reviewReplyFor(ack, resolve, liveComments) + if (!spec) { + return undefined + } + const parsed = AgentSessionReviewReplySchema.safeParse(spec) + if (!parsed.success) { + console.warn('Review reply left off the launch prompt:', parsed.error.message) + return undefined + } + return parsed.data +} + +function reviewReplyFor( + ack: PendingPRCommentAiAck, + resolve: string[], + liveComments: readonly PRComment[] +): AgentSessionReviewReply | undefined { + if (ack.provider === 'gitlab') { + // GitLab acknowledges by resolving only, as the panel does after a paste. + return ack.gitlabTarget + ? { provider: 'gitlab', repoId: ack.gitlabTarget.repoId, iid: ack.gitlabTarget.iid, resolve } + : undefined + } + const target = ack.githubResolveTarget + if (ack.provider !== 'github' || !target) { + return undefined + } + // Replies need the PR's repository, which only the reply target is sure to name. + const replyTargets = ack.githubTarget + ? getPRCommentGroupsNeedingReply(ack.selectedGroups).map(getPRCommentGroupReplyTarget) + : [] + const inThread = replyTargets.filter(canPostPRReviewThreadReply) + const conversation = replyTargets.filter((comment) => !canPostPRReviewThreadReply(comment)) + const prRepo = ack.githubTarget?.prRepo ?? target.prRepo + return { + provider: 'github', + repoId: target.repoId, + prNumber: target.prNumber, + ...(prRepo + ? { + prRepo: { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}) + } + } + : {}), + resolve, + replies: inThread.map((comment) => { + const threadId = + resolvePRReviewReplyThreadId({ parent: comment, existingComments: liveComments }) ?? + comment.threadId + return { + commentId: comment.id, + ...(threadId ? { threadId } : {}), + ...(comment.path ? { path: comment.path } : {}), + ...(comment.line !== undefined ? { line: comment.line } : {}) + } + }), + replyBody: PR_COMMENT_AI_FIXING_REPLY, + ...(conversation.length > 0 + ? { conversationReply: buildPRCommentBatchConversationReplyBody(conversation) } + : {}) + } +} diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index 8f21d3c79206..d3079ca74dc4 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -342,6 +342,11 @@ export function setPendingPRCommentAiAck(payload: PendingPRCommentAiAck): void { pendingAiCommentAck = payload } +/** The unclaimed payload, left in place: a launch plans its review reply from it. */ +export function peekPendingPRCommentAiAck(): PendingPRCommentAiAck | null { + return pendingAiCommentAck +} + export function takePendingPRCommentAiAck(): PendingPRCommentAiAck | null { const payload = pendingAiCommentAck pendingAiCommentAck = null diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 2f5c044ab1d7..7ce826e8a614 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,5 +1,8 @@ +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { structuredChatHostRunsReviewReplies } from '@/lib/structured-agent-session-review-reply-support' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -46,15 +49,44 @@ type RunSourceControlAgentActionStartArgs = { * Fires as soon as the agent tab/session is created, before deferred * submit-after-ready prompt delivery finishes. Reversible bookkeeping only — * irreversible side effects (posting host replies, resolving threads) belong in - * onLaunched, which only fires once the prompt actually reached the agent. + * onLaunched, which fires once the prompt is delivered: a terminal's paste landed, or a + * structured chat recorded its message (whose agent may still be starting). */ onLaunchAccepted?: () => void /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ onLaunchAborted?: () => void - onLaunched?: () => void + /** `reviewReplyCarried`: a structured chat's message carries the review reply, which its host + * writes once the agent takes it, so the caller writes nothing to the review itself. + * `chat` names that chat and its host, when known, so the caller can refresh once it wrote. */ + onLaunched?: (launch: SourceControlAgentLaunched) => void + /** Planned at start: what a structured chat's host writes on the review once its agent takes + * the prompt. A terminal agent has no message to carry it. */ + reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } +export type SourceControlAgentLaunched = { + reviewReplyCarried: boolean + chat?: { sessionId: string; target: RuntimeClientTarget } +} + +/** The chat a structured launch opened: this client's own from its surface, a paired host's from + * the settlement that learned it. */ +async function structuredChat( + result: NonNullable> +): Promise { + // The host the launch used, never one worked out again later from what the UI shows then. + const target = result.structuredChatTarget + if (!target) { + return undefined + } + if (result.surface.kind === 'local-agent-session') { + return { sessionId: result.surface.sessionId, target } + } + const settled = await result.structuredSettlement?.catch(() => undefined) + return settled?.kind === 'structured' ? { sessionId: settled.sessionId, target } : undefined +} + export async function runSourceControlAgentActionStart({ selectedAgent, trimmedCommandInput, @@ -76,9 +108,12 @@ export async function runSourceControlAgentActionStart({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose }: RunSourceControlAgentActionStartArgs): Promise { let launched = false + let reviewReplyCarried = false + let reviewReplyChat: SourceControlAgentLaunched['chat'] let launchFailureNotified = false let launchAcceptedNotified = false // Why: `undefined` is what makes the launch fall back to the global Agents arguments; @@ -101,6 +136,11 @@ export async function runSourceControlAgentActionStart({ notifyLaunchAccepted() } } else if (worktreeId) { + // Only a chat whose host runs it carries one; otherwise the caller writes after delivery. + const plannedReviewReply = + reviewReply && (await structuredChatHostRunsReviewReplies(worktreeId)) + ? reviewReply() + : undefined const result = launchAgentInNewTab({ agent: selectedAgent, worktreeId, @@ -109,7 +149,8 @@ export async function runSourceControlAgentActionStart({ agentArgs: launchAgentArgs, promptDelivery, launchPlatform, - launchSource + launchSource, + ...(plannedReviewReply ? { reviewReply: plannedReviewReply } : {}) }) launched = Boolean(result) if (result?.surface.kind === 'local-terminal') { @@ -123,7 +164,16 @@ export async function runSourceControlAgentActionStart({ if (result?.promptDeliveryResult) { try { const deliveryResult = await result.promptDeliveryResult - launched = deliveryResult.delivered + // Read off what took the prompt: a paired host that declined the chat opens a terminal, + // which carries no review reply, so the panel writes after its paste. + reviewReplyCarried = deliveryResult.reviewReplyCarried === true + if (reviewReplyCarried) { + reviewReplyChat = await structuredChat(result) + } + // A chat that holds its review reply sends it again itself; offering Start again here + // would launch a second chat, and post the replies twice. + launched = + deliveryResult.delivered || (reviewReplyCarried && deliveryResult.heldByChat === true) launchFailureNotified = deliveryResult.failureNotified } catch (error) { console.error('promptDeliveryResult rejected', error) @@ -171,7 +221,10 @@ export async function runSourceControlAgentActionStart({ console.error('onSaveAgentDefault failed', error) } } - onLaunched?.() + onLaunched?.({ + reviewReplyCarried, + ...(reviewReplyChat ? { chat: reviewReplyChat } : {}) + }) onClose() return true } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 4a055a613eec..9f4629fdaa33 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -68,7 +68,7 @@ export function getDefaultSourceControlRecoveryLaunchCopy( ), success: translate( 'auto.components.right.sidebar.source.control.ai.recovery.launch.success', - 'Started an AI agent for the {{value0}} failure.', + 'Sent to an AI agent for the {{value0}} failure.', { value0: subject } ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx index 560651684426..21229013a48b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx @@ -207,7 +207,7 @@ export function SourceControlDialogLayer({ toast.success( translate( 'auto.components.right.sidebar.SourceControl.e48caaf0dd', - 'Started an AI agent for the conflicts.' + 'Sent to an AI agent for the conflicts.' ) ) } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 60e7a45bb1bb..d6e1fcc45de1 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -44,6 +44,7 @@ export function useSourceControlAgentActionDialog({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onStart }: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult { const settings = useAppStore((state) => state.settings) @@ -212,6 +213,7 @@ export function useSourceControlAgentActionDialog({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose: closeDialog }) diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fef..aeef39a74915 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -1,3 +1,5 @@ +import type { SourceControlAgentLaunched } from './runSourceControlAgentActionStart' +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { useCallback, useRef, useState } from 'react' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { @@ -50,7 +52,8 @@ type UseSourceControlAgentActionStartArgs = { ) => void | Promise onLaunchAccepted?: () => void onLaunchAborted?: () => void - onLaunched?: () => void + onLaunched?: (launch: SourceControlAgentLaunched) => void + reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } @@ -95,6 +98,7 @@ export function useSourceControlAgentActionStart({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose }: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult { const [deliveryPlan, setDeliveryPlan] = useState({ @@ -174,6 +178,7 @@ export function useSourceControlAgentActionStart({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose: () => { resetDeliveryPlan() onClose() @@ -198,6 +203,7 @@ export function useSourceControlAgentActionStart({ onLaunchAborted, onLaunchAccepted, onLaunched, + reviewReply, onSaveAgentDefault, onStart, promptDelivery, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d177774bda7f..71f8d063136e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2743,7 +2743,6 @@ }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress.", - "tryAgainAfterSwitch": "Try again after it finishes.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", @@ -2796,6 +2795,7 @@ "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", + "reviewReplyFailed": "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself.", "runCommandAgain": "Run /{{command}} again.", "sendToTryAgain": "Send your message to try again.", "signInFirst": "Sign in first.", @@ -2804,6 +2804,7 @@ "stopRefused": "{{agent}} didn't stop.", "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", "theAgent": "The agent", + "tryAgainAfterSwitch": "Try again after it finishes.", "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent." }, "notices": { @@ -2887,7 +2888,6 @@ "openCurrentConversation": "Open the current conversation to continue.", "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", - "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "ownerUnproven": "The previous agent in this chat may still be running.", "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", @@ -2897,6 +2897,7 @@ "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", "savedByNewerOrca": "Chats were saved by a newer Orca.", + "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a85223007f3d..33b5cd4409d5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1338,7 +1338,7 @@ "f4b1292569": "Start the default AI agent on these checks", "9a1004fc76": "Refresh checks", "03e542fcfe": "Failed to start an AI agent for the broken checks: {{value0}}", - "28986b3747": "Started an AI agent for the broken checks.", + "28986b3747": "Sent to an AI agent for the broken checks.", "1690fd7f4a": "No broken checks to fix.", "9e7c221b8d": "Failed to rerun checks", "e463ec935f": "Check reruns requested", @@ -1842,7 +1842,7 @@ "a18d01cda3": "No checks reported yet", "3912daf310": "This pull request has no reported checks yet.", "45877f5089": "No checks found", - "85e62c5266": "Started an AI agent for the broken checks.", + "85e62c5266": "Sent to an AI agent for the broken checks.", "ddfd42f460": "Review the prompt before starting an agent.", "a053bdd082": "Fix Broken Checks With AI", "1b14d0a69c": "Open in GitHub", @@ -12342,8 +12342,8 @@ "60ed678138": "selected" }, "ChecksPanel": { - "2ef90c9819": "Started an AI agent for the broken checks.", - "a0181a8d76": "Started an AI agent for the conflicts.", + "2ef90c9819": "Sent to an AI agent for the broken checks.", + "a0181a8d76": "Sent to an AI agent for the conflicts.", "34464d00b9": "updated", "058039787c": "Cancel", "2ab7fd4b6d": "Save", @@ -12389,7 +12389,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "Could not resolve the GitHub PR to reply on.", + "7e4b2a19c0": "Couldn't find the GitHub PR to reply on. Reply to those comments yourself.", "430f1a62d4": "Could not resolve the selected thread on the host.", "updateReactionFailed": "Failed to update reaction.", "gitlabMoreActions": "More MR actions", @@ -12697,7 +12697,7 @@ "f4c766f1ca": "Choose the agent and command template for this run.", "1a6a6e0bc5": "Generate Hosted Review Details", "6b122529d4": "Generate Commit Message", - "e48caaf0dd": "Started an AI agent for the conflicts.", + "e48caaf0dd": "Sent to an AI agent for the conflicts.", "901140f47d": "Review the prompt before starting an agent.", "19652ddd76": "Resolve Conflicts With AI", "c9ad22888e": "Pick the branch compare target for this repository.", @@ -13293,7 +13293,7 @@ "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", "5540ff50cc": "Could not build the agent launch command.", "216f762bd7": "Unable to resolve the workspace connection.", - "success": "Started an AI agent for the {{value0}} failure." + "success": "Sent to an AI agent for the {{value0}} failure." } } }, @@ -15638,7 +15638,7 @@ "4f2d9a8c17": "Select a repository before launching an AI action.", "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", "9b2f6d4a81": "This check is not failing.", - "2ef90c9819": "Started an AI agent for this check." + "2ef90c9819": "Sent to an AI agent for this check." } } } @@ -17636,7 +17636,8 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "{{agent}} from before may still be running. Your messages will send once it stops." + "previousExitUnverifiable": "{{agent}} from before may still be running. Your messages will send once it stops.", + "reviewReplyFailed": "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3a9419ff20ea..19b98e1a82ce 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -983,7 +983,7 @@ "f4b1292569": "Iniciar el agente de IA predeterminado en estos checks", "9a1004fc76": "Actualizar checks", "03e542fcfe": "No se pudo iniciar un agente de IA para los checks fallidos: {{value0}}", - "28986b3747": "Se inició un agente de IA para los checks fallidos.", + "28986b3747": "Se envió a un agente de IA para los checks fallidos.", "1690fd7f4a": "No hay checks fallidos que arreglar.", "9e7c221b8d": "No se pudieron volver a ejecutar los checks", "e463ec935f": "Se solicitó volver a ejecutar los checks", @@ -1467,7 +1467,7 @@ "a18d01cda3": "Aún no se reportaron checks", "3912daf310": "Este pull request aún no tiene checks reportados.", "45877f5089": "No se encontraron checks", - "85e62c5266": "Se inició un agente de IA para los checks fallidos.", + "85e62c5266": "Se envió a un agente de IA para los checks fallidos.", "ddfd42f460": "Revisa el prompt antes de iniciar un agente.", "a053bdd082": "Arreglar checks fallidos con IA", "1b14d0a69c": "Abrir en GitHub", @@ -10522,8 +10522,8 @@ "60ed678138": "seleccionados" }, "ChecksPanel": { - "2ef90c9819": "Se inició un agente de IA para los checks con errores.", - "a0181a8d76": "Se inició un agente de IA para los conflictos.", + "2ef90c9819": "Se envió a un agente de IA para los checks con errores.", + "a0181a8d76": "Se envió a un agente de IA para los conflictos.", "34464d00b9": "actualizado", "058039787c": "Cancelar", "2ab7fd4b6d": "Guardar", @@ -10569,7 +10569,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "No se pudo resolver el PR de GitHub para responder.", + "7e4b2a19c0": "No se encontró el PR de GitHub en el que responder. Responde tú mismo a esos comentarios.", "430f1a62d4": "No se pudo resolver el hilo seleccionado en el host." }, "CreatePullRequestDialog": { @@ -10857,7 +10857,7 @@ "f4c766f1ca": "Elija el agente y la plantilla de comando para esta ejecución.", "1a6a6e0bc5": "Generar detalles de reseña alojada", "6b122529d4": "Generar mensaje de commit", - "e48caaf0dd": "Inició un agente de IA para los conflictos.", + "e48caaf0dd": "Se envió a un agente de IA para los conflictos.", "901140f47d": "Revisa el prompt antes de iniciar un agente.", "19652ddd76": "Resolver conflictos con IA", "c9ad22888e": "Elige el destino de comparación de ramas para este repositorio.", @@ -11427,7 +11427,7 @@ "9bbd9077a2": "No hay agentes de AI habilitados. Configura agentes en Configuración.", "5540ff50cc": "No se pudo construir el comando de inicio del agente.", "216f762bd7": "No se pudo resolver la conexión del espacio de trabajo.", - "success": "Se inició un agente de AI para el fallo de {{value0}}." + "success": "Se envió a un agente de IA para el fallo de {{value0}}." } } }, @@ -13568,7 +13568,7 @@ "4f2d9a8c17": "Selecciona un repositorio antes de lanzar una acción de IA.", "7c3e1b5d42": "Abre un PR o MR antes de lanzar un fix con IA.", "9b2f6d4a81": "Este check no está fallando.", - "2ef90c9819": "Se inició un agente de IA para este check." + "2ef90c9819": "Se envió a un agente de IA para este check." } } } @@ -15006,7 +15006,8 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Es posible que {{agent}} siga en ejecución desde antes. Sus mensajes se enviarán cuando se detenga." + "previousExitUnverifiable": "Es posible que {{agent}} siga en ejecución desde antes. Sus mensajes se enviarán cuando se detenga.", + "reviewReplyFailed": "Orca no pudo marcar como en corrección los comentarios de revisión enviados con este mensaje. Resuélvelos o respóndelos tú." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 246b174c5824..c7ccd46514e0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1291,7 +1291,7 @@ "f4b1292569": "Démarrer l'agent IA par défaut sur ces vérifications", "9a1004fc76": "Actualiser les vérifications", "03e542fcfe": "Échec du démarrage d'un agent IA pour les vérifications cassées : {{value0}}", - "28986b3747": "Un agent IA a été démarré pour les vérifications cassées.", + "28986b3747": "Envoyé à un agent IA pour les vérifications cassées.", "1690fd7f4a": "Aucune vérification cassée à corriger.", "9e7c221b8d": "Échec de la relance des vérifications", "e463ec935f": "Relances des vérifications demandées", @@ -1795,7 +1795,7 @@ "a18d01cda3": "Aucune vérification signalée pour l'instant", "3912daf310": "Cette pull request n'a aucune vérification signalée pour l'instant.", "45877f5089": "Aucune vérification trouvée", - "85e62c5266": "Un agent IA a été démarré pour les vérifications cassées.", + "85e62c5266": "Envoyé à un agent IA pour les vérifications cassées.", "ddfd42f460": "Vérifiez le prompt avant de démarrer un agent.", "a053bdd082": "Corriger les vérifications en échec avec l'IA", "1b14d0a69c": "Ouvrir dans GitHub", @@ -12179,8 +12179,8 @@ "60ed678138": "sélectionnés" }, "ChecksPanel": { - "2ef90c9819": "Un agent IA a été démarré pour les vérifications cassées.", - "a0181a8d76": "Un agent IA a été lancé pour les conflits.", + "2ef90c9819": "Envoyé à un agent IA pour les vérifications cassées.", + "a0181a8d76": "Envoyé à un agent IA pour les conflits.", "34464d00b9": "mis à jour", "058039787c": "Annuler", "2ab7fd4b6d": "Enregistrer", @@ -12226,7 +12226,7 @@ "pending": "Synchronisation…", "branch": "Synchroniser la branche" }, - "7e4b2a19c0": "Impossible d'identifier la PR GitHub sur laquelle répondre.", + "7e4b2a19c0": "Impossible de trouver la PR GitHub sur laquelle répondre. Répondez vous-même à ces commentaires.", "430f1a62d4": "Impossible de résoudre le fil sélectionné côté hôte.", "updateReactionFailed": "Échec de la mise à jour de la réaction.", "gitlabMoreActions": "Plus d'actions MR", @@ -12534,7 +12534,7 @@ "f4c766f1ca": "Choisissez l'agent et le modèle de commande pour cette exécution.", "1a6a6e0bc5": "Générer les détails de la revue hébergée", "6b122529d4": "Générer le message de commit", - "e48caaf0dd": "Un agent IA a été lancé pour les conflits.", + "e48caaf0dd": "Envoyé à un agent IA pour les conflits.", "901140f47d": "Vérifiez le prompt avant de démarrer un agent.", "19652ddd76": "Résoudre les conflits avec l'IA", "c9ad22888e": "Choisissez la cible de comparaison de branches pour ce dépôt.", @@ -13130,7 +13130,7 @@ "9bbd9077a2": "Aucun agent IA activé. Configurez les agents dans les paramètres.", "5540ff50cc": "Impossible de construire la commande de lancement de l'agent.", "216f762bd7": "Impossible de résoudre la connexion de l'espace de travail.", - "success": "Un agent IA a été lancé pour l'échec de {{value0}}." + "success": "Envoyé à un agent IA pour l'échec de {{value0}}." } } }, @@ -15475,7 +15475,7 @@ "4f2d9a8c17": "Sélectionnez un dépôt avant de lancer une action IA.", "7c3e1b5d42": "Ouvrez une pull request ou une MR avant de lancer une correction IA.", "9b2f6d4a81": "Cette vérification n'est pas en échec.", - "2ef90c9819": "Un agent IA a été démarré pour cette vérification." + "2ef90c9819": "Envoyé à un agent IA pour cette vérification." } } } @@ -17475,7 +17475,8 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "L'exécution précédente de {{agent}} est peut-être toujours en cours. Vos messages seront envoyés dès qu'elle s'arrêtera." + "previousExitUnverifiable": "L'exécution précédente de {{agent}} est peut-être toujours en cours. Vos messages seront envoyés dès qu'elle s'arrêtera.", + "reviewReplyFailed": "Orca n'a pas pu marquer comme en cours de correction les commentaires de revue envoyés avec ce message. Résolvez-les ou répondez-y vous-même." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8ed31700ea1f..62d85d352a36 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1189,7 +1189,7 @@ "f4b1292569": "これらのチェックでデフォルトの AI Agent を開始します", "9a1004fc76": "リフレッシュチェック", "03e542fcfe": "失敗したチェックのため AI Agent を開始できませんでした: {{value0}}", - "28986b3747": "失敗したチェックに対して AI Agent を開始しました。", + "28986b3747": "失敗したチェックを AI Agent に送信しました。", "1690fd7f4a": "修正が必要なチェックはありません。", "9e7c221b8d": "チェックの再実行に失敗しました", "e463ec935f": "リクエストされた再実行をチェックする", @@ -1689,7 +1689,7 @@ "a18d01cda3": "まだチェックは報告されていません", "3912daf310": "この PR にはまだチェックが報告されていません。", "45877f5089": "チェックが見つかりませんでした", - "85e62c5266": "失敗したチェックに対して AI Agent を開始しました。", + "85e62c5266": "失敗したチェックを AI Agent に送信しました。", "ddfd42f460": "Agent を開始する前に、プロンプトを確認してください。", "a053bdd082": "AI で失敗したチェックを修正する", "1b14d0a69c": "GitHub で開く", @@ -12077,8 +12077,8 @@ "60ed678138": "選択中" }, "ChecksPanel": { - "2ef90c9819": "失敗したチェックに対して AI Agent を開始しました。", - "a0181a8d76": "競合の解決のために AI Agent を開始しました。", + "2ef90c9819": "失敗したチェックを AI Agent に送信しました。", + "a0181a8d76": "競合の解決を AI Agent に送信しました。", "34464d00b9": "更新されました", "058039787c": "キャンセル", "2ab7fd4b6d": "保存", @@ -12124,7 +12124,7 @@ "pending": "同期中…", "branch": "ブランチを同期" }, - "7e4b2a19c0": "返信先の GitHub PR を特定できませんでした。", + "7e4b2a19c0": "返信先の GitHub PR が見つかりませんでした。これらのコメントにはご自身で返信してください。", "430f1a62d4": "選択したスレッドをホスト上で解決できませんでした。", "updateReactionFailed": "反応を更新できませんでした。", "gitlabMoreActions": "その他の MR 操作", @@ -12432,7 +12432,7 @@ "f4c766f1ca": "この実行用の Agent とコマンドテンプレートを選択します。", "1a6a6e0bc5": "ホストされたレビューの詳細を生成する", "6b122529d4": "コミットメッセージの生成", - "e48caaf0dd": "競合の解決のために AI Agent を開始しました。", + "e48caaf0dd": "競合の解決を AI Agent に送信しました。", "901140f47d": "Agent を開始する前に、プロンプトを確認してください。", "19652ddd76": "AI で対立を解決する", "c9ad22888e": "このリポジトリのブランチ比較ターゲットを選択します。", @@ -13028,7 +13028,7 @@ "9bbd9077a2": "有効な AI Agent がありません。[設定] で Agent を構成してください。", "5540ff50cc": "Agent 起動コマンドを構築できませんでした。", "216f762bd7": "ワークスペースの接続を解決できません。", - "success": "{{value0}}の失敗に対してAIAgent を起動しました。" + "success": "{{value0}}の失敗を AI Agent に送信しました。" } } }, @@ -15379,7 +15379,7 @@ "4f2d9a8c17": "AI 操作を開始する前に、リポジトリを選択してください。", "7c3e1b5d42": "AI による修正を開始する前に、PR または MR を開いてください。", "9b2f6d4a81": "このチェックは失敗していません。", - "2ef90c9819": "このチェックに対して AI Agent を開始しました。" + "2ef90c9819": "このチェックを AI Agent に送信しました。" } } } @@ -17412,7 +17412,8 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "以前の {{agent}} がまだ実行中の可能性があります。停止するとメッセージが送信されます。" + "previousExitUnverifiable": "以前の {{agent}} がまだ実行中の可能性があります。停止するとメッセージが送信されます。", + "reviewReplyFailed": "このメッセージと一緒に送ったレビューコメントを、Orca は修正中としてマークできませんでした。ご自身で解決するか返信してください。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a0c7ab2e4e92..b100257ad87c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1189,7 +1189,7 @@ "f4b1292569": "이 체크에 기본 AI agent 시작", "9a1004fc76": "체크 새로고침", "03e542fcfe": "실패한 체크를 수정하기 위해 AI agent를 시작하지 못했습니다: {{value0}}", - "28986b3747": "실패한 체크를 위한 AI agent를 시작했습니다.", + "28986b3747": "실패한 체크를 AI agent에 보냈습니다.", "1690fd7f4a": "수정할 실패한 체크가 없습니다.", "9e7c221b8d": "체크를 다시 실행하지 못했습니다.", "e463ec935f": "체크 재실행 요청됨", @@ -1689,7 +1689,7 @@ "a18d01cda3": "아직 보고된 체크가 없습니다.", "3912daf310": "이 PR에는 아직 보고된 체크가 없습니다.", "45877f5089": "체크를 찾을 수 없습니다.", - "85e62c5266": "실패한 체크를 위한 AI agent를 시작했습니다.", + "85e62c5266": "실패한 체크를 AI agent에 보냈습니다.", "ddfd42f460": "agent를 시작하기 전에 프롬프트를 검토하세요.", "a053bdd082": "AI로 실패한 체크 수정", "1b14d0a69c": "GitHub에서 열기", @@ -12077,8 +12077,8 @@ "60ed678138": "선택됨" }, "ChecksPanel": { - "2ef90c9819": "실패한 검사에 대한 AI agent를 시작했습니다.", - "a0181a8d76": "충돌에 대한 AI agent를 시작했습니다.", + "2ef90c9819": "실패한 검사를 AI agent에 보냈습니다.", + "a0181a8d76": "충돌을 AI agent에 보냈습니다.", "34464d00b9": "업데이트됨", "058039787c": "취소", "2ab7fd4b6d": "저장", @@ -12124,7 +12124,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "답글을 게시할 GitHub PR을 확인하지 못했습니다.", + "7e4b2a19c0": "답글을 게시할 GitHub PR을 찾지 못했습니다. 해당 댓글에 직접 답글을 남겨 주세요.", "430f1a62d4": "선택한 스레드를 호스트에서 해결하지 못했습니다.", "updateReactionFailed": "반응을 업데이트하지 못했습니다.", "gitlabMoreActions": "더 많은 MR 작업", @@ -12432,7 +12432,7 @@ "f4c766f1ca": "이 실행을 위한 agent 및 명령 템플릿을 선택합니다.", "1a6a6e0bc5": "호스팅된 리뷰 세부 정보 생성", "6b122529d4": "Commit 메시지 생성", - "e48caaf0dd": "충돌에 대한 AI agent를 시작했습니다.", + "e48caaf0dd": "충돌을 AI agent에 보냈습니다.", "901140f47d": "agent를 시작하기 전에 전체 명령 입력을 리뷰하고 편집하세요.", "19652ddd76": "AI로 충돌 해결", "c9ad22888e": "이 repos의 브랜치 비교 대상을 선택하세요.", @@ -13028,7 +13028,7 @@ "9bbd9077a2": "활성화된 AI 에이전트가 없습니다. 설정에서 에이전트를 구성하세요.", "5540ff50cc": "에이전트 시작 명령을 구성할 수 없습니다.", "216f762bd7": "워크트리 연결을 확인할 수 없습니다.", - "success": "{{value0}} 실패에 대한 AI 에이전트가 시작되었습니다." + "success": "{{value0}} 실패를 AI 에이전트에 보냈습니다." } } }, @@ -15379,7 +15379,7 @@ "4f2d9a8c17": "AI 작업을 실행하기 전에 repos를 선택하세요.", "7c3e1b5d42": "AI 수정을 실행하기 전에 PR 또는 MR을 여세요.", "9b2f6d4a81": "이 체크는 실패 상태가 아닙니다.", - "2ef90c9819": "이 체크를 처리할 AI agent를 시작했습니다." + "2ef90c9819": "이 체크를 AI agent에 보냈습니다." } } } @@ -17412,7 +17412,8 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "이전에 실행된 {{agent}}이(가) 아직 실행 중일 수 있습니다. 중지되면 메시지가 전송됩니다." + "previousExitUnverifiable": "이전에 실행된 {{agent}}이(가) 아직 실행 중일 수 있습니다. 중지되면 메시지가 전송됩니다.", + "reviewReplyFailed": "Orca가 이 메시지와 함께 보낸 리뷰 댓글을 수정 중으로 표시하지 못했습니다. 직접 해결하거나 답글을 남기세요." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2592f1e882c0..bbb509205638 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1189,7 +1189,7 @@ "f4b1292569": "在这些检查中启动默认的 AI 智能体", "9a1004fc76": "刷新检查", "03e542fcfe": "无法为失败的检查启动 AI 智能体:{{value0}}", - "28986b3747": "已启动 AI 智能体处理失败的检查。", + "28986b3747": "已发送给 AI 智能体处理失败的检查。", "1690fd7f4a": "没有需要修复的失败检查。", "9e7c221b8d": "无法重新运行检查", "e463ec935f": "检查请求重新运行", @@ -1689,7 +1689,7 @@ "a18d01cda3": "尚未报告任何 CI 检查", "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", - "85e62c5266": "已启动 AI 智能体处理失败的检查。", + "85e62c5266": "已发送给 AI 智能体处理失败的检查。", "ddfd42f460": "启动智能体前请查看提示词。", "a053bdd082": "用 AI 修复失败的检查", "1b14d0a69c": "在 GitHub 中打开", @@ -12129,8 +12129,8 @@ "60ed678138": "已选择" }, "ChecksPanel": { - "2ef90c9819": "已启动 AI 智能体处理失败的检查。", - "a0181a8d76": "已启动 AI 智能体处理冲突。", + "2ef90c9819": "已发送给 AI 智能体处理失败的检查。", + "a0181a8d76": "已发送给 AI 智能体处理冲突。", "34464d00b9": "已更新", "058039787c": "取消", "2ab7fd4b6d": "保存", @@ -12176,7 +12176,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "无法确定要回复的 GitHub PR。", + "7e4b2a19c0": "找不到要回复的 GitHub PR。请自行回复这些评论。", "430f1a62d4": "无法在主机上解决选中的讨论串。", "updateReactionFailed": "无法更新反应。", "gitlabMoreActions": "更多 MR 动作", @@ -12484,7 +12484,7 @@ "f4c766f1ca": "为此运行选择智能体和提示词模板。", "1a6a6e0bc5": "生成托管评论详细信息", "6b122529d4": "生成提交信息", - "e48caaf0dd": "已启动 AI 智能体处理冲突。", + "e48caaf0dd": "已发送给 AI 智能体处理冲突。", "901140f47d": "启动智能体前请查看并编辑完整提示词。", "19652ddd76": "用 AI 解决冲突", "c9ad22888e": "选择此存储库的分支比较目标。", @@ -13080,7 +13080,7 @@ "9bbd9077a2": "没有已启用的 AI 智能体。请在设置中配置智能体。", "5540ff50cc": "无法构建智能体启动命令。", "216f762bd7": "无法解析工作区连接。", - "success": "已为 {{value0}} 失败启动 AI 智能体。" + "success": "已将 {{value0}} 失败发送给 AI 智能体。" } } }, @@ -15431,7 +15431,7 @@ "4f2d9a8c17": "启动 AI 操作前请选择仓库。", "7c3e1b5d42": "启动 AI 修复前请打开 PR 或 MR。", "9b2f6d4a81": "此检查没有失败。", - "2ef90c9819": "已为此检查启动 AI 智能体。" + "2ef90c9819": "已将此检查发送给 AI 智能体。" } } } @@ -17429,7 +17429,8 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "之前的 {{agent}} 可能仍在运行。它停止后,您的消息就会发送。" + "previousExitUnverifiable": "之前的 {{agent}} 可能仍在运行。它停止后,您的消息就会发送。", + "reviewReplyFailed": "Orca 无法将随此消息发送的评审评论标记为正在修复。请自行解决或回复它们。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index d4fa18551d45..df5d83f54b2e 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -23,11 +23,14 @@ import { type StructuredAgentLaunchHooks, type StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + /** See `StructuredAgentLaunchOptions.reviewReply`; a terminal launch ignores it. */ + reviewReply?: AgentSessionReviewReply } /** @@ -45,6 +48,7 @@ export type AgentSessionLaunchVerdict = { promptDelivery?: NativeChatLaunchPromptDelivery resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + reviewReply?: AgentSessionReviewReply } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -81,6 +85,7 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), + ...(verdict.reviewReply ? { reviewReply: verdict.reviewReply } : {}), ...(verdict.executionHostId ? { executionHostId: verdict.executionHostId } : {}) } } @@ -170,6 +175,7 @@ export function planAgentSessionLaunch( ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), - ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), + ...(request.reviewReply ? { reviewReply: request.reviewReply } : {}) }) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index c4f2a02a4fe0..f7f7ac0f052b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { adoptAgentSessionLaunchVerdict, type AgentSessionLaunchPlan @@ -16,6 +17,8 @@ type StructuredFromNewTab = { pasteDraftAfterLaunch: false structuredSettlement: Promise promptDeliveryResult?: Promise + /** The host this launch put the chat on: this client's own, or the paired server it asked. */ + structuredChatTarget: RuntimeClientTarget } /** @@ -55,6 +58,7 @@ export function launchStructuredAgentFromNewTab(args: { }, pasteDraftAfterLaunch: false, structuredSettlement: structured.structuredSettlement, + structuredChatTarget: { kind: 'local' }, ...(structured.promptDeliveryResult ? { promptDeliveryResult: structured.promptDeliveryResult } : {}) @@ -90,6 +94,7 @@ export function launchStructuredAgentFromNewTab(args: { surface: { kind: 'host-published' }, pasteDraftAfterLaunch: false, structuredSettlement: launch.settlement, + structuredChatTarget: paired.target, ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) } ) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index eff738dde7f3..75e42f359dc6 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,3 +1,5 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' @@ -26,6 +28,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-structured-route' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { planAgentSessionLaunch, @@ -52,6 +55,8 @@ export type LaunchAgentInNewTabArgs = { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void + /** Rides a structured chat's launch prompt (see `StructuredAgentLaunchOptions.reviewReply`). */ + reviewReply?: AgentSessionReviewReply /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -80,7 +85,10 @@ export type LaunchAgentInNewTabResult = { surface: AgentLaunchSurface startupPlan: AgentStartupPlan pasteDraftAfterLaunch: boolean - promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> + /** `heldByChat` comes only from a structured chat's launch prompt. */ + promptDeliveryResult?: Promise + /** Structured route only: the host the launch put the chat on, decided as it launched. */ + structuredChatTarget?: RuntimeClientTarget /** Structured route only: what the launch did once it settled. The call stays synchronous. */ structuredSettlement?: Promise } | null @@ -115,6 +123,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchPlatform, onPromptDelivered, onPromptDeliveryUnconfirmed, + reviewReply, agentSessionLaunchPlan, pendingActivationSpawn, beforeSurfaceOpen @@ -179,7 +188,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered + onPromptDelivered, + ...(reviewReply ? { reviewReply } : {}) }) if (plan?.route === 'structured-native-chat') { const structured = launchStructuredAgentFromNewTab({ diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 25f66d4af23d..ac33e09bdccf 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -578,9 +578,11 @@ describe('structured chat adoption guard on the launch path', () => { promptDelivery: 'submit-after-ready' }) + // The chat keeps it for that retry, so it says it holds it. await expect(result?.promptDeliveryResult).resolves.toEqual({ delivered: false, - failureNotified: false + failureNotified: false, + heldByChat: true }) expect(mockCreateTab).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index b3ce31f1a6b5..95e11de57cb6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -1,7 +1,9 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -9,6 +11,8 @@ export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void + /** What Orca does on the review once the agent takes the prompt; rides the prompt's message. */ + reviewReply?: AgentSessionReviewReply /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ resumeFrom?: StructuredAgentSessionResumeSource @@ -18,6 +22,17 @@ export type StructuredAgentLaunchOptions = { hostSeedOptions?: Readonly> } +/** Queues a launch's prompt on its chat's outbox, carrying any review reply onto its message. */ +export function stageStructuredLaunchPrompt( + sessionId: string, + text: string, + options: StructuredAgentLaunchOptions +): StructuredAgentSessionOutboxEntry | null { + return text + ? enqueueStructuredAgentSessionLaunchPrompt(sessionId, text, options.reviewReply) + : null +} + export type StructuredLaunchCaller = { promptDeliveryResult?: Promise } diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts index c597c5522625..6a7d9963eafc 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts @@ -178,3 +178,40 @@ describe('coalesced launch delivery mode', () => { ) }) }) + +describe('a launch prompt with a review reply', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.rendererTabs = {} + mocks.listeners.clear() + mocks.callStructuredAgentSession.mockResolvedValue({ ok: true, page: { fence: 1 } }) + }) + + it('carries it on the message the launch sends', async () => { + const reviewReply = { + provider: 'gitlab' as const, + repoId: 'repo-1', + iid: 8, + resolve: ['discussion-1'] + } + const intent = launchIntent('wt-review-reply', 'review-reply-session') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot('wt-review-reply', intent.sessionId) + ]) + + startStructuredAgentLaunch('wt-review-reply', 'codex', { + prompt: 'Resolve these', + promptDelivery: 'auto-submit', + reviewReply + }) + await flushLaunchSettlement() + + const send = mocks.callStructuredAgentSession.mock.calls.find( + (call) => call[1] === 'agentSession.send' + ) + expect(send?.[2]).toMatchObject({ reviewReply }) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 41732df5e69b..ea8753f9f92d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { enqueueStructuredAgentSessionLaunchPrompt, - mutateStructuredAgentSessionLaunchPrompt + mutateStructuredAgentSessionLaunchPrompt, + readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -91,3 +92,106 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) }) + +describe('a launch prompt the host did not record', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('is held by its chat when the chat keeps it to send again, and not when nothing is kept', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + mocks.call.mockResolvedValue({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', message: 'stale' } + }) + const settle = (launchResult: Promise<{ sessionId: string; fence: number }>) => + settleStructuredAgentLaunchPrompt({ + launchResult, + target: { kind: 'local' }, + options: { prompt: 'review this' }, + stagedEntry + }) + + await expect(settle(Promise.resolve({ sessionId: 'session-1', fence: 1 }))).resolves.toEqual({ + delivered: false, + failureNotified: false, + heldByChat: true + }) + mutateStructuredAgentSessionLaunchPrompt('session-1', stagedEntry!.clientMessageId, () => null) + await expect(settle(Promise.resolve({ sessionId: 'session-1', fence: 1 }))).resolves.toEqual({ + delivered: false, + failureNotified: false + }) + }) + + it('says the message carries the review reply only when the chat took a prompt that had one', async () => { + const reviewReply = { provider: 'gitlab' as const, repoId: 'repo-1', iid: 8, resolve: ['d-1'] } + const withReply = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'fix', reviewReply) + const plain = enqueueStructuredAgentSessionLaunchPrompt('session-2', 'fix') + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: string, + request: { envelope: { clientOperationId: string } } + ) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: request.envelope.clientOperationId, + submission: { + clientMessageId: request.envelope.clientOperationId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } + }) + ) + const settle = (stagedEntry: typeof plain, sessionId: string) => + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId, fence: 1 }), + target: { kind: 'local' }, + options: { prompt: 'fix' }, + stagedEntry + }) + + await expect(settle(withReply, 'session-1')).resolves.toEqual({ + delivered: true, + failureNotified: false, + reviewReplyCarried: true + }) + await expect(settle(plain, 'session-2')).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + }) + + it('keeps no review reply on a prompt whose create failed, as the source takes its comments back', async () => { + const reviewReply = { provider: 'gitlab' as const, repoId: 'repo-1', iid: 8, resolve: ['d-1'] } + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt( + 'session-1', + 'review this', + reviewReply + ) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.reject(new Error('create failed')), + target: { kind: 'local' }, + options: { prompt: 'review this' }, + stagedEntry + }) + ).rejects.toThrow('create failed') + + const [kept] = readOutbox('session-1') + expect(kept).toMatchObject({ clientMessageId: stagedEntry!.clientMessageId }) + expect(kept).not.toHaveProperty('reviewReply') + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 9540c844ab3b..aea6acb84351 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -11,6 +11,7 @@ import { import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' import { + getStructuredAgentSessionOutbox, mutateStructuredAgentSessionLaunchPrompt, type StructuredAgentSessionLaunchPromptMutation } from '@/components/native-chat/structured-agent-session-outbox-storage' @@ -21,6 +22,23 @@ import { createBrowserUuid } from '@/lib/browser-uuid' export type StructuredPromptDeliveryResult = { delivered: boolean failureNotified: boolean + /** Not delivered, but its chat still holds it and sends it again or offers its Retry: the chat + * owns it from here, so the caller does not offer it again. */ + heldByChat?: true + /** The chat's message carries the launch's review reply, which its host writes; set only by a + * structured chat that took the prompt, never by a terminal it fell back to. */ + reviewReplyCarried?: true +} + +/** Whether the chat still holds this launch prompt, under its first id or one a refusal rotated. */ +function chatHoldsLaunchPrompt(entry: StructuredAgentSessionOutboxEntry | null): boolean { + const body = entry && JSON.stringify(entry.body) + return Boolean( + entry && + getStructuredAgentSessionOutbox(entry.sessionId).some( + (held) => held.source === 'launch' && JSON.stringify(held.body) === body + ) + ) } export type StructuredLaunchPromptOptions = { @@ -163,7 +181,14 @@ export function settleStructuredAgentLaunchPrompt(args: { if (args.options.promptDelivery === 'draft' || !args.options.prompt?.trim()) { return undefined } - return args.launchResult.then(async (receipt) => { + const carried = args.stagedEntry?.reviewReply ? { reviewReplyCarried: true as const } : {} + const held = (): StructuredPromptDeliveryResult => ({ + delivered: false, + failureNotified: false, + heldByChat: true, + ...carried + }) + const settled = args.launchResult.then(async (receipt) => { if (!args.stagedEntry) { return { delivered: false, failureNotified: true } } @@ -177,7 +202,17 @@ export function settleStructuredAgentLaunchPrompt(args: { const delivered = await dispatch.promise if (delivered) { args.options.onPromptDelivered?.() + return { delivered, failureNotified: false, ...carried } + } + return chatHoldsLaunchPrompt(entry) ? held() : { delivered, failureNotified: false } + }) + // A create that failed hands the source back its comments, so a relaunch of the prompt the chat + // kept must not carry their writes too. + return settled.catch((error: unknown) => { + const entry = args.stagedEntry + if (entry?.reviewReply) { + mutateEntry(entry, ({ reviewReply: _handedBack, ...kept }) => kept) } - return { delivered, failureNotified: false } + throw error }) } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 5dd33c729ec2..5f2de4424668 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -7,10 +7,7 @@ import { retryStructuredAgentSessionLaunchIntent, StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { - discardStructuredAgentSessionLaunchOutbox, - enqueueStructuredAgentSessionLaunchPrompt -} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { launchAndReconcile, reconcileUnknownLaunch, @@ -21,6 +18,7 @@ import { addStructuredLaunchCaller, createStructuredLaunchCallerGroup, releaseStructuredLaunchCallerAfterUnknownOutcome, + stageStructuredLaunchPrompt, structuredLaunchCallersHavePendingWork, type StructuredAgentLaunchOptions, type StructuredLaunchCaller @@ -164,9 +162,7 @@ function structuredAgentLaunchState( const joined = joinLaunchDelivery(options, existing.promptDelivery) // Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice. const text = retrying ? '' : outboxPromptText(joined) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) - : null + const stagedPrompt = stageStructuredLaunchPrompt(existing.intent.sessionId, text, joined) if (!retrying) { launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) } @@ -192,9 +188,7 @@ function structuredAgentLaunchState( options.hostSeedOptions ) const text = outboxPromptText(options) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) - : null + const stagedPrompt = stageStructuredLaunchPrompt(intent.sessionId, text, options) launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup() const state: StructuredLaunchState = { diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts b/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts index 40f522bd2edf..4f43182307b0 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts @@ -27,6 +27,7 @@ import { useAppStore } from '@/store' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' import { beginDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' +import { launchStructuredAgentFromNewTab } from './launch-agent-in-new-tab-structured-route' import type { AiVaultSession } from '../../../shared/ai-vault-types' import { resumeAiVaultSessionInNewChat } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch' import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' @@ -218,4 +219,19 @@ describe('a structured chat launch on a paired server', () => { ) launch?.cancel() }) + + it('names the host it asked, so a later wait on the chat reads it there', () => { + mocks.createSupport.mockResolvedValue({ supported: true }) + + const launched = launchStructuredAgentFromNewTab({ + plan: pairedPlan(), + worktreeId: WORKTREE, + openTerminal: vi.fn() + }) + + expect(launched?.structuredChatTarget).toEqual({ + kind: 'environment', + environmentId: 'server-1' + }) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts b/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts new file mode 100644 index 000000000000..a57df0a8ae09 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../shared/agent-session-review-reply' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + dropStructuredReviewReplyWatchers, + noticeStructuredReviewReplyReceipt, + watchStructuredReviewReplySettled +} from './structured-agent-session-review-reply-settled' + +const RECEIPT = agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') +}) +const OTHER = agentJournalItemKey({ provider: 'orca', clientMessageId: 'message-1#delivery' }) + +function batch(change: { removed?: string[]; items?: string[] }): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'e', sequence: 2 }, + items: (change.items ?? []).map((itemId) => ({ + itemId, + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'status' as const, text: 'x' } + })), + removedItemIds: change.removed ?? [], + submissions: [] + } + } +} + +afterEach(() => { + dropStructuredReviewReplyWatchers('session-1') + vi.useRealTimers() +}) + +describe("a source's watch for its chat's review-reply receipt", () => { + it("holds the chat's read open while armed, and lets go however it ends", () => { + vi.useFakeTimers() + const holds = [vi.fn(), vi.fn(), vi.fn(), vi.fn()] + const hold = (index: number) => () => holds[index]! + watchStructuredReviewReplySettled('session-1', vi.fn(), { holdRead: hold(0) }) + const dispose = watchStructuredReviewReplySettled('session-2', vi.fn(), { holdRead: hold(1) }) + watchStructuredReviewReplySettled('session-3', vi.fn(), { holdRead: hold(2) }) + watchStructuredReviewReplySettled('session-4', vi.fn(), { + holdRead: hold(3), + lifetimeMs: 1000 + }) + expect(holds.some((release) => release.mock.calls.length > 0)).toBe(false) + + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) + dispose() + dropStructuredReviewReplyWatchers('session-3') + vi.advanceTimersByTime(1001) + + for (const release of holds) { + expect(release).toHaveBeenCalledOnce() + } + }) + + it('runs once, on a written line or a tombstone in a live batch, and only for that chat', () => { + const settled = vi.fn() + watchStructuredReviewReplySettled('session-1', settled) + + noticeStructuredReviewReplyReceipt('session-2', batch({ removed: [RECEIPT] })) + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [OTHER] })) + expect(settled).not.toHaveBeenCalled() + + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) + noticeStructuredReviewReplyReceipt('session-1', batch({ items: [RECEIPT] })) + expect(settled).toHaveBeenCalledOnce() + + const line = vi.fn() + watchStructuredReviewReplySettled('session-1', line) + noticeStructuredReviewReplyReceipt('session-1', batch({ items: [RECEIPT] })) + expect(line).toHaveBeenCalledOnce() + }) + + it('dies when disposed, when its chat closes, or when the window passes', () => { + vi.useFakeTimers() + const disposed = vi.fn() + const closed = vi.fn() + const expired = vi.fn() + watchStructuredReviewReplySettled('session-1', disposed)() + watchStructuredReviewReplySettled('session-1', closed) + dropStructuredReviewReplyWatchers('session-1') + watchStructuredReviewReplySettled('session-1', expired, { lifetimeMs: 1000 }) + vi.advanceTimersByTime(1001) + + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) + + expect(disposed).not.toHaveBeenCalled() + expect(closed).not.toHaveBeenCalled() + expect(expired).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts b/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts new file mode 100644 index 000000000000..14ff40e2641b --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts @@ -0,0 +1,80 @@ +// Tells a source that handed a chat its review reply when that chat's host has written it: the +// receipt (a failure line or a tombstone) reaching this client in the chat's live read. The checks panel +// refetches the PR then, which matters where the chat's host is a paired server whose own +// mutation notice never reaches this client. + +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + AGENT_SESSION_REVIEW_REPLY_WINDOW_MS, + isAgentSessionReviewReplyReceiptKey +} from '../../../shared/agent-session-review-reply' + +type Watcher = { run: () => void; dispose: () => void } + +const watchers = new Map>() + +function forget(sessionId: string, watcher: Watcher): void { + const set = watchers.get(sessionId) + set?.delete(watcher) + if (set?.size === 0) { + watchers.delete(sessionId) + } +} + +/** + * Runs `onSettled` once, the first time a receipt reaches this chat's live stream. `holdRead` keeps + * that stream open while the watch is armed, as owed work does, so a hidden chat still hears it. + * The watch, and its hold, die on its run, on the returned dispose, when the chat closes, or after + * the window in which the host still owes the reply. + */ +export function watchStructuredReviewReplySettled( + sessionId: string, + onSettled: () => void, + options: { holdRead?: () => () => void; lifetimeMs?: number } = {} +): () => void { + let timer: ReturnType | undefined + let releaseRead: (() => void) | undefined + const watcher: Watcher = { + run: () => { + watcher.dispose() + onSettled() + }, + dispose: () => { + clearTimeout(timer) + releaseRead?.() + releaseRead = undefined + forget(sessionId, watcher) + } + } + timer = setTimeout(watcher.dispose, options.lifetimeMs ?? AGENT_SESSION_REVIEW_REPLY_WINDOW_MS) + const set = watchers.get(sessionId) ?? new Set() + set.add(watcher) + watchers.set(sessionId, set) + releaseRead = options.holdRead?.() + return watcher.dispose +} + +/** The chat closed: nothing it would have said is coming. */ +export function dropStructuredReviewReplyWatchers(sessionId: string): void { + for (const watcher of Array.from(watchers.get(sessionId) ?? [])) { + watcher.dispose() + } +} + +/** Called with each live event of a chat's stream; a snapshot is history, not news. */ +export function noticeStructuredReviewReplyReceipt( + sessionId: string, + event: AgentSessionSubscribeEvent +): void { + if (!watchers.has(sessionId) || event.type !== 'batch') { + return + } + const touched = + event.batch.items.some((item) => isAgentSessionReviewReplyReceiptKey(item.itemId)) || + event.batch.removedItemIds.some(isAgentSessionReviewReplyReceiptKey) + if (touched) { + for (const watcher of Array.from(watchers.get(sessionId) ?? [])) { + watcher.run() + } + } +} diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts b/src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts new file mode 100644 index 000000000000..6a32de623af2 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + owner: vi.fn<() => string | null>(), + target: vi.fn(), + supports: vi.fn<() => Promise>() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/runtime/structured-agent-session-owner', () => ({ + resolveStructuredAgentSessionOwner: mocks.owner, + structuredAgentSessionTargetForHost: mocks.target +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supports +})) + +import { structuredChatHostRunsReviewReplies } from './structured-agent-session-review-reply-support' + +describe('whether a structured chat here can carry a review reply', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('can on this client own host, asking no one', async () => { + mocks.target.mockReturnValue({ kind: 'local' }) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(true) + expect(mocks.supports).not.toHaveBeenCalled() + }) + + it('can on a paired host only once it says so', async () => { + mocks.target.mockReturnValue({ kind: 'environment', environmentId: 'env-1' }) + mocks.supports.mockResolvedValueOnce(true) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(true) + expect(mocks.supports).toHaveBeenCalledWith('env-1', 'agent-session.review-reply.v1') + + mocks.supports.mockResolvedValueOnce(false) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(false) + mocks.supports.mockRejectedValueOnce(new Error('unreachable')) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-support.ts b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts new file mode 100644 index 000000000000..38c39ae5ee27 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts @@ -0,0 +1,34 @@ +// Whether a structured chat started in this workspace can carry a review reply on its launch +// prompt. Its host runs the reply, and an older paired server refuses a send that names one. + +import { AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { useAppStore } from '@/store' +import { + resolveStructuredAgentSessionOwner, + structuredAgentSessionTargetForHost +} from '@/runtime/structured-agent-session-owner' +import { + runtimeEnvironmentSupportsCapability, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' + +/** The host a structured chat in this workspace runs on; this client's own when none is named. */ +function structuredChatTargetForWorktree(worktreeId: string): RuntimeClientTarget { + return ( + structuredAgentSessionTargetForHost( + resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) + ) ?? { kind: 'local' } + ) +} + +/** This client's own host always can; a paired one only once it says so. */ +export async function structuredChatHostRunsReviewReplies(worktreeId: string): Promise { + const target = structuredChatTargetForWorktree(worktreeId) + if (target.kind !== 'environment') { + return true + } + return runtimeEnvironmentSupportsCapability( + target.environmentId, + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY + ).catch(() => false) +} diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts index e0c9c562b780..423843933c04 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ callRuntime: vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise>(), discardOutbox: vi.fn<(sessionId: string) => void>(), + dropReviewReplyWatchers: vi.fn<(sessionId: string) => void>(), hasTombstone: vi.fn<(worktreeId: string, sessionId: string) => boolean>(), markCancelled: vi.fn<(worktreeId: string, sessionId: string, executionHostId: string) => boolean>() @@ -20,6 +21,9 @@ vi.mock('@/lib/structured-agent-session-launch-registry', () => ({ vi.mock('@/components/native-chat/structured-agent-session-outbox-storage', () => ({ discardStructuredAgentSessionLaunchOutbox: mocks.discardOutbox })) +vi.mock('@/lib/structured-agent-session-review-reply-settled', () => ({ + dropStructuredReviewReplyWatchers: mocks.dropReviewReplyWatchers +})) vi.mock('./structured-agent-session-close', () => ({ closeStructuredAgentSession: mocks.closeSession })) @@ -90,6 +94,8 @@ describe('structured agent session tab retirement', () => { }) expect(mocks.markCancelled).toHaveBeenCalledWith('wt-1', 'session-1', 'local') expect(mocks.discardOutbox).toHaveBeenCalledWith('session-1') + // A source waiting on the chat's review-reply receipt hears nothing more from it. + expect(mocks.dropReviewReplyWatchers).toHaveBeenCalledWith('session-1') await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1') }) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 86e475684802..f085dc07a4d4 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -1,3 +1,4 @@ +import { dropStructuredReviewReplyWatchers } from '@/lib/structured-agent-session-review-reply-settled' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { markStructuredAgentSessionLaunchesPublished, @@ -77,6 +78,7 @@ export function beginStructuredAgentSessionTabClose(args: { ) } discardStructuredAgentSessionLaunchOutbox(args.sessionId) + dropStructuredReviewReplyWatchers(args.sessionId) retireStructuredAgentSessionTab(args) } diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts index 0c9e658cac14..a95b099e87f3 100644 --- a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../store-test-helpers' -const mocks = vi.hoisted(() => ({ beginClose: vi.fn() })) +const mocks = vi.hoisted(() => ({ beginClose: vi.fn(), dropWatchers: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -10,6 +10,9 @@ vi.mock('sonner', () => ({ vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: mocks.beginClose })) +vi.mock('@/lib/structured-agent-session-review-reply-settled', () => ({ + dropStructuredReviewReplyWatchers: mocks.dropWatchers +})) // `repoId::path` names both checkouts: this machine's and the paired server's. const WORKTREE = 'repo-1::/work/app' @@ -58,6 +61,7 @@ function storeWith(tab: Tab): ReturnType { beforeEach(() => { mocks.beginClose.mockReset() + mocks.dropWatchers.mockReset() }) describe('closing a structured chat from outside its workspace', () => { @@ -81,5 +85,7 @@ describe('closing a structured chat from outside its workspace', () => { expect(mocks.beginClose).not.toHaveBeenCalled() expect(store.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) + // No host to stop it on, but the chat is gone here, and so is a review reply's wait on it. + expect(mocks.dropWatchers).toHaveBeenCalledWith('chat-1') }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index f4cd23a01d78..5dcc3e0ddb42 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,3 +1,4 @@ +import { dropStructuredReviewReplyWatchers } from '@/lib/structured-agent-session-review-reply-settled' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from './tabs-layout' import { @@ -73,6 +74,8 @@ export function createTabsCloseActions( console.warn('[structured-agent-session] close found no owning host', tab.entityId) } get().clearNativeChatLaunchDraft(structuredAgentSessionTabId(tab.entityId)) + // With or without a host to name, the chat is gone here, and so is any wait on it. + dropStructuredReviewReplyWatchers(tab.entityId) } // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. const nextActiveTabId = diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts new file mode 100644 index 000000000000..c1ab6cbfeba6 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from 'vitest' +import type { Tab } from '../../../../../../shared/tab-types' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { createStoreCascadesMockApi } from '../../store-cascades-test-harness' +import { createTestStore, makeWorktree, seedStore } from '../../store-test-helpers' +import { buildWorktreePurgeState } from './worktree-purge-state' + +const WORKTREE_ID = 'repo1::/path/wt1' + +createStoreCascadesMockApi() + +const chat: Tab = { + id: 'agent-session:chat-1', + entityId: 'chat-1', + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +it("lets go of a review reply's wait on a chat when its worktree is purged", () => { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [chat] } + }) + const released = vi.fn() + const settled = vi.fn() + watchStructuredReviewReplySettled('chat-1', settled, { holdRead: () => released }) + + store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID])) + + expect(released).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index ffa3cb6076dd..26a6cc23e44f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -1,3 +1,4 @@ +import { dropStructuredReviewReplyWatchers } from '@/lib/structured-agent-session-review-reply-settled' import type { AppState } from '../../../types' import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' @@ -55,6 +56,9 @@ export function buildWorktreePurgeState( } for (const worktreeId of worktreeIdSet) { for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType === 'agent-session') { + dropStructuredReviewReplyWatchers(tab.entityId) + } // A retained launch here survived a reload, so its persisted record names its host. const owner = tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId) diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index c0733e8e699e..b4b422827da8 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -87,7 +87,9 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', previousExitUnverifiable: - '{{agent}} from before may still be running. Your messages will send once it stops.' + '{{agent}} from before may still be running. Your messages will send once it stops.', + reviewReplyFailed: + "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 0bef9eacd96c..7b755660ad3e 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -286,7 +286,8 @@ const FAILURE_SENTENCES = { retry?.cause ), previousExitUnverifiable: (context, _fact, _surface, say) => - say('previousExitUnverifiable', agent(say, context)) + say('previousExitUnverifiable', agent(say, context)), + reviewReplyFailed: (_context, _fact, _surface, say) => say('reviewReplyFailed') } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b04f0d883af6..a0451d9c4b65 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -51,7 +51,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', /** A message waits on a child a Stop could not prove gone: its exit is unverifiable. */ - 'previousExitUnverifiable' + 'previousExitUnverifiable', + /** The review replies and resolves a message carried did not all go through. */ + 'reviewReplyFailed' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -67,7 +69,8 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', - 'previousExitUnverifiable' + 'previousExitUnverifiable', + 'reviewReplyFailed' ] as const satisfies readonly AgentSessionFailureKind[] /** Why a message was not sent. A new failure kind is one of these until listed above. */ diff --git a/src/shared/agent-session-message-delivery-capabilities.ts b/src/shared/agent-session-message-delivery-capabilities.ts index 36f179bd009c..c05b372f0c4c 100644 --- a/src/shared/agent-session-message-delivery-capabilities.ts +++ b/src/shared/agent-session-message-delivery-capabilities.ts @@ -16,6 +16,10 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // as a new message. export const AGENT_SESSION_RETRY_MESSAGE_RUNTIME_CAPABILITY = 'agent-session.retry-message.v1' as const +// Why: `agentSession.send` takes a `reviewReply` the host runs once the agent takes the message; an +// older host's strict params refuse the whole send, so a client attaches one only where advertised. +export const AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY = + 'agent-session.review-reply.v1' as const // How a send is answered. The host and the Electron client both advertise these. export const AGENT_SESSION_SEND_ANSWER_RUNTIME_CAPABILITIES = [ @@ -27,5 +31,6 @@ export const AGENT_SESSION_SEND_ANSWER_RUNTIME_CAPABILITIES = [ // turn starts, so a client may gate on either. export const AGENT_SESSION_MESSAGE_DELIVERY_RUNTIME_CAPABILITIES = [ ...AGENT_SESSION_SEND_ANSWER_RUNTIME_CAPABILITIES, - AGENT_SESSION_RETRY_MESSAGE_RUNTIME_CAPABILITY + AGENT_SESSION_RETRY_MESSAGE_RUNTIME_CAPABILITY, + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/agent-session-review-reply.ts b/src/shared/agent-session-review-reply.ts new file mode 100644 index 000000000000..46415fcc42e5 --- /dev/null +++ b/src/shared/agent-session-review-reply.ts @@ -0,0 +1,89 @@ +// What "Resolve comments with AI" asks Orca to do on the review once the agent takes the launch +// prompt: resolve the threads the host can close, and post Orca's "fixing" reply on the ones it +// cannot (GitHub only). It rides the message, so whichever host records the message runs it. + +import { z } from 'zod' +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' + +/** A message accepted longer ago than this owes no review reply: a crash's leftover must not post + * days later on some reopen. Measured from acceptance, so a late Retry still posts. */ +export const AGENT_SESSION_REVIEW_REPLY_WINDOW_MS = 24 * 60 * 60 * 1000 + +/** The receipt's item rides the message's own id with this suffix: a line, or a tombstone. */ +const REVIEW_REPLY_RECEIPT_SUFFIX = '#review-reply' + +export function agentSessionReviewReplyReceiptMessageId(clientMessageId: string): string { + return `${clientMessageId}${REVIEW_REPLY_RECEIPT_SUFFIX}` +} + +/** Whether a journal item id is a review reply's receipt, written or tombstoned. */ +export function isAgentSessionReviewReplyReceiptKey(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return ( + identity?.provider === 'orca' && identity.clientMessageId.endsWith(REVIEW_REPLY_RECEIPT_SUFFIX) + ) +} + +/** Bounds a spec so a message cannot carry an unbounded side effect. */ +export const MAX_REVIEW_REPLY_TARGETS = 500 +export const MAX_REVIEW_REPLY_BYTES = 64 * 1024 + +const Id = z.string().min(1).max(512) + +const GitHubThreadReply = z + .object({ + commentId: z.number().int().positive(), + threadId: Id.optional(), + path: z.string().min(1).max(4096).optional(), + line: z.number().int().positive().optional() + }) + .strict() + +const GitHubReviewReply = z + .object({ + provider: z.literal('github'), + repoId: Id, + prNumber: z.number().int().positive(), + prRepo: z.object({ owner: Id, repo: Id, host: Id.optional() }).strict().optional(), + /** Review threads to resolve. */ + resolve: z.array(Id).max(MAX_REVIEW_REPLY_TARGETS), + /** In-thread replies, each posting `replyBody` under its comment. */ + replies: z.array(GitHubThreadReply).max(MAX_REVIEW_REPLY_TARGETS), + replyBody: z.string().min(1).max(4096), + /** One PR conversation comment for every selected comment with no reply endpoint. */ + conversationReply: z.string().min(1).max(MAX_REVIEW_REPLY_BYTES).optional() + }) + .strict() + +const GitLabReviewReply = z + .object({ + provider: z.literal('gitlab'), + repoId: Id, + iid: z.number().int().positive(), + /** Merge request discussions to resolve. */ + resolve: z.array(Id).max(MAX_REVIEW_REPLY_TARGETS) + }) + .strict() + +export const AgentSessionReviewReplySchema = z + .discriminatedUnion('provider', [GitHubReviewReply, GitLabReviewReply]) + .refine( + (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength <= MAX_REVIEW_REPLY_BYTES, + 'Review reply is too large' + ) + +export type AgentSessionReviewReply = z.infer + +/** `{ reviewReply }` for a well-formed stored spec, else nothing: for spreading into a copy. */ +export function agentSessionReviewReplyField(value: unknown): { + reviewReply?: AgentSessionReviewReply +} { + const reviewReply = readAgentSessionReviewReply(value) + return reviewReply ? { reviewReply } : {} +} + +/** A stored spec as a reader meets it; undefined for anything malformed, which then runs nothing. */ +export function readAgentSessionReviewReply(value: unknown): AgentSessionReviewReply | undefined { + const parsed = AgentSessionReviewReplySchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} diff --git a/src/shared/orchestration-timing-budgets.ts b/src/shared/orchestration-timing-budgets.ts index 26e3e7956001..acd464ce6f50 100644 --- a/src/shared/orchestration-timing-budgets.ts +++ b/src/shared/orchestration-timing-budgets.ts @@ -13,6 +13,20 @@ export function resolveWorkerStartReadinessTimeoutMs(timeoutMs: number | undefin : ORCHESTRATION_READINESS_TIMEOUT_MS } +/** + * How long a structured worker's preamble may wait for its agent: up to the readiness wait, but + * no longer than the worker start's own timeout leaves plus the allowance a terminal worker's turn + * observation gets, so the host answers inside the CLI's worker-start grace. + */ +export function resolveStructuredWorkerPreambleBudgetMs(args: { + startedAtMs: number + timeoutMs: number + nowMs: number +}): number { + const left = args.startedAtMs + args.timeoutMs + AGENT_PROMPT_EFFECT_TIMEOUT_MS - args.nowMs + return Math.min(ORCHESTRATION_READINESS_TIMEOUT_MS, Math.max(0, left)) +} + export function resolveFederationAttachTimeoutMs( readinessTimeoutMs = ORCHESTRATION_READINESS_TIMEOUT_MS ): number { diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ae09a9cd8221..04d2dd48b307 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -145,7 +145,7 @@ import { TypeText } from './computer-schemas-params' import { - AttachParams as AttachParamsOfEmulatorParams, + AttachParams, AxParams, ButtonParams, EmulatorAvailabilityParams, @@ -472,7 +472,6 @@ import { } from './speech-params' import { SshTarget } from './ssh-params' import { - AttachParams, CancelParams, ConversationCommandParams, CreateParams, @@ -593,7 +592,6 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.conversationOutline': OptionsParams, 'agentSession.create': CreateParams, 'agentSession.createSupport': CreateSupportParams, - 'agentSession.ensure': AttachParams, 'agentSession.handoffStatus': HandoffStatusParams, 'agentSession.history': HistoryParams, 'agentSession.hold': HoldParams, @@ -753,7 +751,7 @@ export const RPC_PARAMS_BY_METHOD = { 'computer.setValue': SetValue, 'computer.typeText': TypeText, 'diagnostics.memory': null, - 'emulator.attach': AttachParamsOfEmulatorParams, + 'emulator.attach': AttachParams, 'emulator.availability': EmulatorAvailabilityParams, 'emulator.ax': AxParams, 'emulator.button': ButtonParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index fe8939ae03b7..fcde427c5491 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { AgentSessionReviewReplySchema } from '../agent-session-review-reply' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' @@ -172,6 +173,10 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), + /** What Orca does on the review once the agent takes this message. Sent only by the desktop to + * its own host, with a launch prompt; strict, so an older host refuses it. Joins the + * operation fingerprint, never the body's. A send carrying one is never held as a draft. */ + reviewReply: AgentSessionReviewReplySchema.optional(), body: z .object({ kind: z.literal('message'), diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index a879918be6bb..45a6714809bb 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -92,6 +92,24 @@ describe('structured agent session options', () => { expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown') }) + // A Codex chat at rest before any listing (first chat on an account, WSL, a failed read) still + // gets its picker: an unknown listing keeps the static seed, as the host's catalog contract says. + it('keeps the static seed models when the host lists none and names no model', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { models: [], current: { model: '' } } + ) + const model = structuredAgentSessionOptionSnapshot(state).find((row) => row.id === 'model') + + expect( + model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] + ).toEqual(CODEX_SESSION_OPTION_CATALOG.models.map((entry) => entry.id)) + expect(state.catalog?.defaultModelIsCliDefault).toBe( + CODEX_SESSION_OPTION_CATALOG.defaultModelIsCliDefault + ) + }) + it('projects live options as directly settable descriptors', () => { const state = applyStructuredAgentSessionOptions( createStructuredAgentSessionOptionState('codex'), diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index c397ce4e5fe5..20c8b5341826 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -76,9 +76,16 @@ export function structuredAgentSessionOptionCatalog( seed: AgentSessionOptionCatalog, result: AgentSessionOptionsResult ): AgentSessionOptionCatalog { - const models: CatalogModel[] = result.models.map((model) => + const listed: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) ) + // No listing is an unknown one, not an empty one: the client keeps its static seed's models. + const models = listed.length > 0 ? listed : [...seed.models] + // An unnamed model is unknown, not the listing's default: the picker names none until it is. + const { defaultModelIsCliDefault: _seedNamesDefault, ...unnamed } = seed + if (!result.current.model) { + return listed.length > 0 ? { ...unnamed, models } : { ...seed, models } + } if (!models.some((model) => model.id === result.current.model)) { models.push({ id: result.current.model, @@ -86,7 +93,7 @@ export function structuredAgentSessionOptionCatalog( options: seed.unknownModelOptions ?? [] }) } - return { ...seed, models, defaultModelIsCliDefault: true } + return { ...unnamed, models, defaultModelIsCliDefault: true } } export type StructuredAgentSessionOptionState = { diff --git a/src/shared/structured-agent-session-outbox-review-reply.test.ts b/src/shared/structured-agent-session-outbox-review-reply.test.ts new file mode 100644 index 000000000000..642de5667386 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-review-reply.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionReviewReply } from './agent-session-review-reply' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { + createStructuredAgentSessionOutboxEntry, + parseStructuredAgentSessionOutboxEntry, + requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionSendMutation, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +const REVIEW_REPLY: AgentSessionReviewReply = { + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + resolve: ['thread-1'], + replies: [{ commentId: 7, threadId: 'thread-7' }], + replyBody: 'Fixing. Will be in the next commit' +} + +function launchPrompt(): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: '1800000000000-00000000000000000000000000000001', + sessionId: 'session-1', + text: 'Resolve these', + attachments: [], + queuedAt: 1 + }), + source: 'launch', + reviewReply: REVIEW_REPLY + } +} + +describe("a launch prompt's review reply on the outbox", () => { + it('survives the stored copy, and a malformed one is dropped, never the message', () => { + const stored: unknown = JSON.parse(JSON.stringify(launchPrompt())) + expect(parseStructuredAgentSessionOutboxEntry(stored, 'session-1')).toMatchObject({ + reviewReply: REVIEW_REPLY + }) + const malformed: unknown = { ...launchPrompt(), reviewReply: { provider: 'github' } } + const parsed = parseStructuredAgentSessionOutboxEntry(malformed, 'session-1') + expect(parsed).not.toBeNull() + expect(parsed).not.toHaveProperty('reviewReply') + }) + + it('rides every send of the entry, in the fingerprint the host digests', () => { + const mutation = structuredAgentSessionSendMutation(launchPrompt(), 2) + expect(mutation.reviewReply).toEqual(REVIEW_REPLY) + expect(mutation.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: mutation.body, reviewReply: REVIEW_REPLY } + }) + ) + }) + + it('stays on the entry when a refused send goes out again under a new id', () => { + const requeued = requeueStructuredAgentSessionSendRefusal( + launchPrompt(), + { kind: 'refused', code: 'agent_session_operation_expired' }, + () => '1800000000000-00000000000000000000000000000002' + ) + expect(requeued).toMatchObject({ + clientMessageId: '1800000000000-00000000000000000000000000000002', + reviewReply: REVIEW_REPLY + }) + }) +}) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index e6c4e26f4bcb..3cb5f8718a0f 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -13,6 +13,8 @@ import { import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import type { AgentSessionReviewReply } from './agent-session-review-reply' +import { agentSessionReviewReplyField } from './agent-session-review-reply' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `rejected`: the host settled the send as not delivered. The drain never sends it again on its @@ -34,6 +36,9 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' + /** What Orca does on the review once the agent takes this launch prompt; every send of the + * entry carries it, so no retried or rotated id drops it. */ + reviewReply?: AgentSessionReviewReply /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -297,6 +302,7 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...agentSessionReviewReplyField(entry.reviewReply), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } @@ -306,6 +312,7 @@ export type StructuredAgentSessionSendMutation = { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem delivery?: 'queue-if-active' + reviewReply?: AgentSessionReviewReply } /** The `agentSession.send` arguments an entry stands for. Typed rather than wire-shaped so a host @@ -314,9 +321,10 @@ export function structuredAgentSessionSendMutation( entry: StructuredAgentSessionOutboxEntry, expectedRuntimeFence: number ): StructuredAgentSessionSendMutation { - // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. + // `delivery` and `reviewReply` join the OPERATION fingerprint exactly as the host digests them. const delivery = entry.sentDelivery ?? undefined - const fields = { body: entry.body, ...(delivery ? { delivery } : {}) } + const plain = { body: entry.body, ...(delivery ? { delivery } : {}) } + const fields = entry.reviewReply ? { ...plain, reviewReply: entry.reviewReply } : plain return { envelope: { sessionId: entry.sessionId, diff --git a/tests/e2e/cross-version-wire/agent-session-wire-calls.ts b/tests/e2e/cross-version-wire/agent-session-wire-calls.ts new file mode 100644 index 000000000000..791759253b60 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-wire-calls.ts @@ -0,0 +1,55 @@ +// How the cross-version suites call one build's agent-session dispatcher: a runtime stub that has +// what the structured methods read, and every reply a call produced. + +import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' +import { attachParams } from './structured-agent-session-surface-manifest' +import type { + AgentSessionWireBuild, + RpcClientIdentity, + RpcReply +} from './versioned-agent-session-wire' + +export function runtimeStub(overrides: Record = {}): unknown { + const subscriptions = new RuntimeSubscriptionRegistry() + return { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + ensureStructuredAgentSessionHost: async () => undefined, + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), + registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions), + cleanupSubscription: subscriptions.cleanup.bind(subscriptions), + cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions), + ...overrides + } +} + +/** Every reply one call produced. Streaming methods answer more than once, and a + * refusal has to arrive as a reply rather than as silence. */ +export async function callBuild( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime: unknown = runtimeStub() +): Promise { + const replies: RpcReply[] = [] + await build + .createDispatcher(runtime) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + client + ) + return replies +} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 1980ed95c55a..f072b3f00fae 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -18,15 +18,16 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { startAgentForTests } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' -import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, @@ -34,6 +35,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { resolveBaselineReleaseRef } from './release-checkout' +import { callBuild, runtimeStub } from './agent-session-wire-calls' import { installableHost, structuredHostStub, @@ -80,31 +82,6 @@ beforeAll(async () => { baseline = await loadAgentSessionWireBuild(baselineRef) }, SUITE_TIMEOUT_MS) -function runtimeStub(overrides: Record = {}): unknown { - const subscriptions = new RuntimeSubscriptionRegistry() - return { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - ensureStructuredAgentSessionHost: async () => undefined, - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - structuredAgentSessionLaunchSeedOptions: () => undefined, - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {}, - registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), - registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions), - cleanupSubscription: subscriptions.cleanup.bind(subscriptions), - cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions), - ...overrides - } -} - /** * What a client too old to know the structured surface advertises: the baseline's * own list, minus the capability. Derived rather than assumed to be the baseline's @@ -123,26 +100,6 @@ function baselineStructuredMethods(): string[] { return baseline.methodNames.filter((name) => name.startsWith('agentSession.')) } -/** Every reply one call produced. Streaming methods answer more than once, and a - * refusal has to arrive as a reply rather than as silence. */ -async function callBuild( - build: AgentSessionWireBuild, - method: string, - params: unknown, - client: RpcClientIdentity, - runtime: unknown = runtimeStub() -): Promise { - const replies: RpcReply[] = [] - await build - .createDispatcher(runtime) - .dispatchStreaming( - { id: `request-${method}`, authToken: 'cross-version-token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcReply), - client - ) - return replies -} - /** * The one thing this suite exists to guarantee, written once and applied per * build: every method the manifest declares is not merely registered but reaches @@ -462,6 +419,38 @@ describe('cross-version structured agent sessions', () => { } }) + it('takes a review reply on a send exactly where the host advertises running it', async () => { + // The desktop attaches `reviewReply` only on this capability: an older host's strict send + // params refuse the field, and with it the whole launch prompt. + const method = 'agentSession.send' + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix these' }] } + const reviewReply = { provider: 'gitlab', repoId: 'repo-1', iid: 8, resolve: ['d-1'] } + const fields = { body, reviewReply } + const params = { envelope: envelope({ method, fields, fence: 1 }), ...fields } + expect(current.capabilities).toContain(AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY) + for (const build of [current, baseline]) { + if (!build.methodNames.includes(method)) { + continue + } + const advertised = build.capabilities.includes( + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY + ) + const hostCalls = structuredHostStub(SESSION, WORKSPACE) + await build.installStructuredHost(installableHost(hostCalls)) + try { + const replies = await callBuild(build, method, params, { + clientKind: 'runtime', + clientCapabilities: current.capabilities + }) + expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]?.ok, `${build.label}: ${JSON.stringify(replies[0])}`).toBe(advertised) + expect(hostCalls.send).toHaveBeenCalledTimes(advertised ? 1 : 0) + } finally { + await build.installStructuredHost(null) + } + } + }) + it( 'executes every method a release-shaped checkout registers', async () => { @@ -578,8 +567,11 @@ describe('cross-version structured agent sessions', () => { now: () => NOW }) setStructuredAgentSessionHost(host) - const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never) - expect(attached.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attachParams builds the wire shape the suite feeds every build; the host takes its typed form. + const created = await host.create({ callerKey: 'test' }, attachParams(null) as never) + expect(created.ok).toBe(true) + // The row is owned once the chat's agent has run its thread, which its first start does. + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) createMobileSessionTerminal = vi.fn() runtime = { ...(runtimeStub() as Record), @@ -798,21 +790,6 @@ describe('cross-version structured agent sessions', () => { refusal?: { code: string; currentFence?: number } } - /** Reattaching after a restart: the client's fence died with the previous - * host, and the refusal that says so is what hands it the live one. */ - async function reattach(staleFence: number): Promise { - const refused = await answer('agentSession.ensure', attachParams(staleFence)) - expect(refused).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - const currentFence = refused.refusal?.currentFence - expect(currentFence).toBeGreaterThan(staleFence) - const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0)) - expect(reattached).toMatchObject({ ok: true }) - return reattached - } - async function call( method: string, params: unknown, @@ -885,16 +862,20 @@ describe('cross-version structured agent sessions', () => { }) // Every released client still sends the fence it last saw; this host names a write by its - // target and ignores that fence. Only the attach keeps comparing one, which `reattach` pins. + // target and ignores that fence. The first send's start moved it past the one create answered. it('delivers a write still fenced to the host generation that died', async () => { const created = await answer('agentSession.create', createIntentParams()) + expect(await answer('agentSession.send', sendParams('first', created.fence))).toMatchObject({ + ok: true + }) await bootHost('b') - const reattached = await reattach(created.fence) - expect(reattached.fence).toBeGreaterThan(created.fence) + // Read before the stale send, so the answer is checked against the restarted record's own. + const restarted = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(restarted).toBeGreaterThan(created.fence) expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ ok: true, - fence: reattached.fence + fence: restarted }) }) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 128be2cbd6f2..998e7b35f31d 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -23,10 +23,10 @@ export function structuredHostStub( restartResumableDismiss: vi.fn(async () => 0), restartResumeAll: vi.fn(async () => []), restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), - attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), - // Attach-shaped entries take a client-supplied location, so the host is asked whether it - // supports creating there. A real host always answers; leaving it unstubbed made every - // `ensure` refuse for the harness's own reason rather than the location's. + create: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), + // A create takes a client-supplied location, so the host is asked whether it supports + // creating there. A real host always answers; leaving it unstubbed made every create refuse + // for the harness's own reason rather than the location's. supportsCreate: vi.fn(() => true), conversationCommand: vi.fn(async () => ({ ok: true, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index f3feb08c6cd9..64e572486b78 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -49,12 +49,7 @@ export const STRUCTURED_CALLS: { { method: 'agentSession.createSupport', hostMethod: null, result: { supported: true } }, { method: 'agentSession.create', - hostMethod: 'attach', - result: { ok: true, replayed: false, value: { sessionId: SESSION } } - }, - { - method: 'agentSession.ensure', - hostMethod: 'attach', + hostMethod: 'create', result: { ok: true, replayed: false, value: { sessionId: SESSION } } }, { @@ -255,8 +250,6 @@ export function paramsFor(method: string): unknown { return { worktree: `id:${WORKSPACE}`, agent: 'codex' } case 'agentSession.create': return createIntentParams() - case 'agentSession.ensure': - return attachParams(fence) case 'agentSession.conversationCommand': { const fields = { command: 'compact' } return { envelope: envelope({ method, fields, fence }), ...fields } diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 4968af98a934..d2e8ae937538 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -25,6 +25,7 @@ import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/workt import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' const WORKTREE = 'repo-1::/workspace/repo' @@ -119,7 +120,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) openHost() - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) serveDesktopRuntime()