From c6e20da8db0e2fbcd3d58c454f326eb5a88c874e Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 19:56:21 +0100 Subject: [PATCH] fix: queue session approvals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- HANDOVER.md | 308 ++++++++------------ src/main/EryxAppService.ts | 86 ++++-- src/main/persistence/workspaceRepository.ts | 7 +- src/shared/domain/approval.ts | 73 +++++ src/shared/domain/session.ts | 1 + src/shared/domain/sessionLibrary.ts | 1 + tests/shared/approval.test.ts | 94 ++++++ tests/shared/sessionLibrary.test.ts | 10 + 8 files changed, 365 insertions(+), 215 deletions(-) diff --git a/HANDOVER.md b/HANDOVER.md index 0e3408f..2a0c490 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -1,43 +1,40 @@ -# Approval checkpoints frontend handover +# Approval checkpoints UX handover -## What is implemented in the backend +## What changed in the backend -The backend now supports approval checkpoints in two places: +Approval checkpoints are now **queued per session** instead of failing when a second approval request arrives before the first one is resolved. -1. `tool-call` -2. `final-response` +This fixes the previous backend limitation where multi-agent patterns such as `Sequential Trio Review` could throw: -`tool-call` approvals are enforced inside the .NET sidecar through the Copilot permission callback. +```text +Session "" already has a pending approval. +``` -`final-response` approvals are enforced in the Electron main process after the sidecar finishes generating assistant messages but before those messages are published into the session transcript. +The queue is backend-only in this change. The current frontend can keep working because the active approval is still exposed through the existing `session.pendingApproval` field. -The roadmap item **"pause before handing off outside the original working set"** is **not implemented** in this change. There is still no working-set model in the backend that can support that rule correctly. +## Queue semantics -## Important behavioral semantics +- A session still exposes **one active approval** through `session.pendingApproval`. +- Additional approvals waiting behind it are exposed through `session.pendingApprovalQueue`. +- `session.pendingApprovalQueue` does **not** include the active approval. +- Queue order is the order in which approvals were requested. +- `resolveSessionApproval(...)` still resolves **only the active approval**. +- When the active approval is resolved, the next queued approval automatically becomes the new `session.pendingApproval`. +- If the run fails while approvals remain queued, the backend rejects and clears the remaining queued approvals. +- If Eryx restarts while approvals are pending or queued, the backend rejects all of them and marks the session/run as errored. -- A session **stays** `status: 'running'` while waiting for approval. -- The backend exposes the paused state through `session.pendingApproval`. -- The frontend should treat `session.pendingApproval` as "awaiting approval" even though `session.status` is still `running`. -- Resolving an approval clears `session.pendingApproval`. -- Approving a checkpoint resumes the run. -- Rejecting a `final-response` checkpoint causes the run to fail with an explicit error after the backend resumes the waiting flow. -- Rejecting a `tool-call` checkpoint sends a denial back to the Copilot runtime. The eventual run outcome depends on the runtime response, but it should be treated as a rejected risky action. -- If Eryx restarts while an approval is pending, the backend marks that session as errored and converts the pending approval into a rejected/interrupted run event. - -## New shared model shapes +## Current shared model ### Pattern approval policy -File: `src/shared/domain/approval.ts` - -Patterns can now carry an optional `approvalPolicy`: +This is unchanged from the earlier backend work: ```ts type ApprovalCheckpointKind = 'tool-call' | 'final-response'; interface ApprovalCheckpointRule { kind: ApprovalCheckpointKind; - agentIds?: string[]; // omitted or empty = all agents in the pattern + agentIds?: string[]; // omitted or empty = all agents } interface ApprovalPolicy { @@ -45,19 +42,9 @@ interface ApprovalPolicy { } ``` -This is attached to `PatternDefinition` as: +### Session approval state -```ts -approvalPolicy?: ApprovalPolicy; -``` - -Backend validation already rejects agent IDs that do not exist in the pattern. - -### Session pending approval - -File: `src/shared/domain/approval.ts` - -Sessions can now expose a single active pending approval: +The active approval + queued approvals are now represented as: ```ts interface PendingApprovalMessageRecord { @@ -82,56 +69,27 @@ interface PendingApprovalRecord { } ``` -This is attached to `SessionRecord` as: +Attached to `SessionRecord` as: ```ts pendingApproval?: PendingApprovalRecord; -``` - -For `final-response`, `pendingApproval.messages` contains the unpublished assistant output that the reviewer should inspect before publication. - -For `tool-call`, `pendingApproval.messages` is typically absent. - -### Run timeline event - -File: `src/shared/domain/runTimeline.ts` - -There is a new run timeline event kind: - -```ts -kind: 'approval' -``` - -The event is updated in place over time rather than creating separate requested/approved/rejected entries. - -Relevant fields: - -```ts -approvalId?: string; -approvalKind?: 'tool-call' | 'final-response'; -approvalTitle?: string; -approvalDetail?: string; -permissionKind?: string; -decision?: 'approved' | 'rejected'; -status: 'running' | 'completed' | 'error'; +pendingApprovalQueue?: PendingApprovalRecord[]; ``` Interpretation: -- `status: 'running'` => approval requested and still pending -- `status: 'completed'` + `decision: 'approved'` => approval granted -- `status: 'error'` + `decision: 'rejected'` => approval rejected +- `pendingApproval` = the approval the user can act on **right now** +- `pendingApprovalQueue` = additional pending approvals waiting behind it -## New IPC surface +For `final-response`, `pendingApproval.messages` contains the unpublished assistant output preview. -Files: +For `tool-call`, `messages` is usually absent and the useful context is `agentName`, `toolName`, and `permissionKind`. -- `src/shared/contracts/ipc.ts` -- `src/shared/contracts/channels.ts` -- `src/preload/index.ts` -- `src/main/ipc/registerIpcHandlers.ts` +## IPC surface -New preload/API call: +No new IPC was added for queueing. + +The renderer still resolves approvals through: ```ts resolveSessionApproval({ @@ -141,166 +99,130 @@ resolveSessionApproval({ }): Promise ``` -This is the frontend action to approve or reject the active checkpoint. +Important behavior: -## Internal sidecar protocol additions +- this resolves the **active** approval only +- if the user somehow attempts to resolve a queued approval directly, the backend rejects it +- after a successful resolution, the next queued approval may immediately become active in the returned workspace state -These are backend-only, but useful context for debugging: +## Timeline behavior -Files: +The run timeline still uses `approval` events. -- `src/shared/contracts/sidecar.ts` -- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs` -- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs` -- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs` +Each approval request gets its own event keyed by `approvalId`. -New sidecar event: +That means queued approvals now show up as multiple `approval` events in the same run when applicable. The active/queued distinction is in session state, not in the timeline event type. -```ts -type: 'approval-requested' -``` - -New sidecar command: - -```ts -type: 'resolve-approval' -``` - -The Electron main process already handles this handshake. The frontend should not talk to the sidecar directly. - -## Files that already understand approval data - -Backend and shared code: +## Backend files changed for queue support - `src/shared/domain/approval.ts` -- `src/shared/domain/pattern.ts` + - added queue-state helpers and normalization - `src/shared/domain/session.ts` -- `src/shared/domain/runTimeline.ts` + - added `pendingApprovalQueue` +- `src/shared/domain/sessionLibrary.ts` + - duplicated sessions now clear queued approvals too +- `src/main/persistence/workspaceRepository.ts` + - normalizes legacy single-approval data into active + queue state - `src/main/EryxAppService.ts` -- `src/main/sidecar/sidecarProcess.ts` + - enqueues approval requests instead of throwing + - advances the queue on resolution + - rejects all queued approvals on restart / failure cleanup -Minimal renderer compile-support only: +## What the current frontend already does -- `src/renderer/lib/runTimelineFormatting.ts` -- `src/renderer/components/RunTimeline.tsx` +The current UI should remain functionally compatible because it already reads `session.pendingApproval` and renders a single active approval banner/card. -Those renderer changes are intentionally minimal. They only keep the current app build/typecheck working with the new timeline event kind. +That means: -## Frontend work still needed +- users can still resolve approvals one at a time +- the next queued approval should appear automatically after resolving the current one +- no urgent UI fix is required just to keep the app working -### 1. Pattern editor support for approval policy +## Next UX steps for the frontend agent -Files to inspect: +These are the next improvements the UX agent should make on top of the new backend queue. -- `src/renderer/components/PatternEditor.tsx` -- `src/renderer/App.tsx` - -Needed UI: - -- Allow enabling `tool-call` approvals. -- Allow enabling `final-response` approvals. -- Allow scoping each checkpoint to: - - all agents - - selected agents - -Recommended representation: - -- one section named "Approval checkpoints" -- one row per checkpoint kind -- a toggle -- an "all agents / selected agents" selector -- multi-select agent chips when scoped - -### 2. Pending approval UI in chat/session surfaces +### 1. Show queue size next to the active approval Files to inspect: - `src/renderer/components/ChatPane.tsx` - `src/renderer/components/ActivityPanel.tsx` - `src/renderer/components/Sidebar.tsx` -- `src/renderer/lib/sessionWorkspace.ts` - -Needed behavior: - -- Show a visible checkpoint card/banner whenever `session.pendingApproval` exists. -- Present: - - title - - detail - - agent name - - permission kind (for tool-call) - - preview messages (for final-response) -- Add **Approve** and **Reject** actions that call `api.resolveSessionApproval(...)`. - -### 3. Final-response review UI - -Use `session.pendingApproval.messages` when `kind === 'final-response'`. Recommended UX: -- Render the candidate assistant messages exactly as they would appear in chat. -- Make it clear they are pending publication, not yet committed to transcript history. -- On approval, let the backend publish them. -- On rejection, expect the run to fail and show an error state after the backend resumes the blocked flow. +- if `session.pendingApprovalQueue?.length > 0`, show a compact badge like: + - `1 queued` + - `2 queued` +- keep the active approval clearly distinguished from queued approvals -### 4. Run timeline UI +### 2. Clarify that the visible approval is only the active one + +In the active approval card/banner, add copy such as: + +- `Approval 1 of 3` +- `Next approval will appear after this one is resolved` + +This will make automatic queue advancement feel intentional instead of surprising. + +### 3. Add an optional queued-approvals preview Files to inspect: -- `src/renderer/components/RunTimeline.tsx` -- `src/renderer/lib/runTimelineFormatting.ts` - -Needed improvements: - -- Add a dedicated approval event treatment instead of the current minimal fallback. -- Show distinct visuals for: - - requested - - approved - - rejected -- Include checkpoint kind and detail text. -- Include message preview affordances for final-response if desired. - -### 5. Session activity / status treatment - -Files to inspect: - -- `src/renderer/lib/sessionActivity.ts` -- `src/renderer/components/ActivityPanel.tsx` - `src/renderer/components/ChatPane.tsx` - -Important rule: - -- Do **not** rely on `session.status` alone to decide whether the session is actively streaming versus blocked on approval. -- Use `session.pendingApproval` as the signal for "paused awaiting human action." - -### 6. Error / stale checkpoint handling - -The frontend should handle two backend-produced edge cases cleanly: - -1. `resolveSessionApproval(...)` can fail if the approval is no longer active. -2. A pending approval can disappear on reload because the backend converted it into an interrupted error after restart. +- `src/renderer/components/ActivityPanel.tsx` Recommended UX: -- show a toast or inline error if approval resolution fails -- refresh from workspace state afterward -- show the session error banner when the run has been interrupted +- an accordion or compact list for `session.pendingApprovalQueue` +- each queued item can show: + - title + - kind + - agent name + - permission kind / tool name when available -## Suggested frontend implementation order +Important: -1. Add read-only pending approval banner/card in `ChatPane`. -2. Wire approve/reject buttons to `resolveSessionApproval`. -3. Render final-response preview from `pendingApproval.messages`. -4. Add timeline rendering for `approval` events. -5. Add pattern editor controls for configuring `approvalPolicy`. -6. Refine sidebar/activity badges for "awaiting approval" state. +- queued approvals are **read-only** +- only the active `pendingApproval` should show Approve / Reject buttons + +### 4. Handle queue transitions smoothly + +When the active approval is resolved and the next one becomes active: + +- avoid jarring layout jumps +- keep scroll position stable +- consider a subtle transition so the user understands the queue advanced + +### 5. Improve error copy for queued-approval edge cases + +Possible backend outcomes the UI should explain well: + +- active approval was resolved, then the run failed before the queue fully drained +- app restarted and the whole queue was rejected/interrupted +- user tried to resolve an approval that is no longer active + +Recommended UX: + +- inline error or toast +- refresh from returned workspace state +- preserve visibility into which approval is now active + +### 6. Optionally surface queue state in the timeline + +The timeline already records all approval events, but the UI could add: + +- an “active” vs “queued” distinction when an approval event is still pending +- badges or copy that explain multiple approval checkpoints exist in the same run + +This is optional because the core queue behavior is already represented in backend state. ## Validation commands -Backend changes already pass: +Backend queue changes should be validated with: - `bun run typecheck` - `bun test` - `bun run sidecar:test` - `bun run build` - -After the frontend agent finishes, rerun the same four commands. diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 7a525c0..36436e0 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -26,6 +26,9 @@ import { } from '@shared/domain/pattern'; import { approvalPolicyRequiresCheckpoint, + dequeuePendingApprovalState, + enqueuePendingApprovalState, + listPendingApprovals, normalizeApprovalPolicy, resolvePendingApproval, type ApprovalDecision, @@ -512,6 +515,15 @@ export class EryxAppService extends EventEmitter { const session = this.requireSession(workspace, sessionId); const approval = session.pendingApproval; if (!approval || approval.id !== approvalId) { + const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId); + if (queuedApproval) { + throw new Error( + approval + ? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.` + : `Approval "${approvalId}" is queued but not active for session "${sessionId}".`, + ); + } + throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`); } @@ -522,7 +534,7 @@ export class EryxAppService extends EventEmitter { const resolvedAt = nowIso(); const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt); - session.pendingApproval = undefined; + this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId)); session.updatedAt = resolvedAt; const updatedRun = this.updateSessionRun(session, handle.requestId, (run) => @@ -539,6 +551,11 @@ export class EryxAppService extends EventEmitter { await Promise.resolve(handle.resolve(decision)); } catch (error) { const failedAt = nowIso(); + this.rejectPendingApprovals( + session, + failedAt, + 'Queued approval was cancelled because the run failed before it could resume.', + ); session.status = 'error'; session.lastError = error instanceof Error ? error.message : String(error); session.updatedAt = failedAt; @@ -931,14 +948,10 @@ export class EryxAppService extends EventEmitter { resolve: (decision: ApprovalDecision) => void | Promise, ): Promise { const session = this.requireSession(workspace, sessionId); - if (session.pendingApproval) { - throw new Error(`Session "${sessionId}" already has a pending approval.`); - } - const pendingApproval = 'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval; - session.pendingApproval = pendingApproval; + this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval)); session.updatedAt = pendingApproval.requestedAt; const updatedRun = this.updateSessionRun(session, requestId, (run) => @@ -971,6 +984,41 @@ export class EryxAppService extends EventEmitter { }; } + private setSessionPendingApprovalState( + session: SessionRecord, + state: { + pendingApproval?: PendingApprovalRecord; + pendingApprovalQueue?: PendingApprovalRecord[]; + }, + ): void { + session.pendingApproval = state.pendingApproval; + session.pendingApprovalQueue = state.pendingApprovalQueue; + } + + private rejectPendingApprovals( + session: SessionRecord, + failedAt: string, + error: string, + ): string[] { + const requestIds = new Set(); + + for (const pendingApproval of listPendingApprovals(session)) { + const requestId = this.findApprovalRequestId(session, pendingApproval.id); + const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error); + + if (requestId) { + requestIds.add(requestId); + this.updateSessionRun(session, requestId, (run) => + upsertRunApprovalEvent(run, rejectedApproval)); + } + + this.pendingApprovalHandles.delete(pendingApproval.id); + } + + this.setSessionPendingApprovalState(session, {}); + return [...requestIds]; + } + private async awaitFinalResponseApproval( workspace: WorkspaceState, sessionId: string, @@ -1192,32 +1240,30 @@ export class EryxAppService extends EventEmitter { let changed = false; for (const session of workspace.sessions) { - const pendingApproval = session.pendingApproval; - if (!pendingApproval) { + const pendingApprovals = listPendingApprovals(session); + if (pendingApprovals.length === 0) { continue; } changed = true; const failedAt = nowIso(); const error = 'Pending approval was interrupted because Eryx restarted before a decision was recorded.'; - const requestId = this.findApprovalRequestId(session, pendingApproval.id); - const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error); - - session.pendingApproval = undefined; + const requestIds = this.rejectPendingApprovals(session, failedAt, error); session.status = 'error'; session.lastError = error; session.updatedAt = failedAt; - if (!requestId) { - continue; + if (requestIds.length === 0) { + const fallbackRequestId = session.runs.find((run) => run.status === 'running')?.requestId; + if (fallbackRequestId) { + requestIds.push(fallbackRequestId); + } } - this.updateSessionRun(session, requestId, (run) => - failSessionRunRecord( - upsertRunApprovalEvent(run, rejectedApproval), - failedAt, - error, - )); + for (const requestId of requestIds) { + this.updateSessionRun(session, requestId, (run) => + failSessionRunRecord(run, failedAt, error)); + } } return changed; diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index ae43430..7422574 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -7,7 +7,7 @@ import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling'; import { normalizeApprovalPolicy, - normalizePendingApproval, + normalizePendingApprovalState, } from '@shared/domain/approval'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { nowIso } from '@shared/utils/ids'; @@ -72,7 +72,10 @@ export class WorkspaceRepository { ...session, runs: normalizeSessionRunRecords(session.runs), tooling: normalizeSessionToolingSelection(session.tooling), - pendingApproval: normalizePendingApproval(session.pendingApproval), + ...normalizePendingApprovalState({ + pendingApproval: session.pendingApproval, + pendingApprovalQueue: session.pendingApprovalQueue, + }), })), settings: normalizeWorkspaceSettings(stored.settings), selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId) diff --git a/src/shared/domain/approval.ts b/src/shared/domain/approval.ts index 1be43f0..868032d 100644 --- a/src/shared/domain/approval.ts +++ b/src/shared/domain/approval.ts @@ -32,6 +32,11 @@ export interface PendingApprovalRecord { messages?: PendingApprovalMessageRecord[]; } +export interface PendingApprovalState { + pendingApproval?: PendingApprovalRecord; + pendingApprovalQueue?: PendingApprovalRecord[]; +} + const approvalCheckpointKinds: ApprovalCheckpointKind[] = ['tool-call', 'final-response']; const approvalCheckpointKindSet = new Set(approvalCheckpointKinds); const approvalStatusSet = new Set(['pending', 'approved', 'rejected']); @@ -174,6 +179,58 @@ export function resolvePendingApproval( }; } +export function listPendingApprovals( + state: Partial, +): PendingApprovalRecord[] { + const pendingApprovals: PendingApprovalRecord[] = []; + const seenApprovalIds = new Set(); + + function appendApproval(approval?: Partial) { + const normalized = normalizePendingApproval(approval); + if (!normalized || normalized.status !== 'pending' || seenApprovalIds.has(normalized.id)) { + return; + } + + seenApprovalIds.add(normalized.id); + pendingApprovals.push(normalized); + } + + appendApproval(state.pendingApproval); + for (const queuedApproval of state.pendingApprovalQueue ?? []) { + appendApproval(queuedApproval); + } + + return pendingApprovals; +} + +export function normalizePendingApprovalState( + state: Partial, +): PendingApprovalState { + return splitPendingApprovalState(listPendingApprovals(state)); +} + +export function enqueuePendingApprovalState( + state: Partial, + approval: PendingApprovalRecord, +): PendingApprovalState { + return normalizePendingApprovalState({ + pendingApproval: state.pendingApproval, + pendingApprovalQueue: [ + ...(state.pendingApprovalQueue ?? []), + approval, + ], + }); +} + +export function dequeuePendingApprovalState( + state: Partial, + approvalId: string, +): PendingApprovalState { + return splitPendingApprovalState( + listPendingApprovals(state).filter((approval) => approval.id !== approvalId), + ); +} + function normalizePendingApprovalMessages( messages?: ReadonlyArray>, ): PendingApprovalMessageRecord[] | undefined { @@ -198,6 +255,22 @@ function normalizePendingApprovalMessages( return normalized.length > 0 ? normalized : undefined; } +function splitPendingApprovalState( + approvals: readonly PendingApprovalRecord[], +): PendingApprovalState { + if (approvals.length === 0) { + return { + pendingApproval: undefined, + pendingApprovalQueue: undefined, + }; + } + + return { + pendingApproval: approvals[0], + pendingApprovalQueue: approvals.length > 1 ? approvals.slice(1) : undefined, + }; +} + function normalizeOptionalString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 61483ab..d1ef80a 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -41,6 +41,7 @@ export interface SessionRecord { scratchpadConfig?: ScratchpadSessionConfig; tooling?: SessionToolingSelection; pendingApproval?: PendingApprovalRecord; + pendingApprovalQueue?: PendingApprovalRecord[]; runs: SessionRunRecord[]; } diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index 2714cff..b76d8f3 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -182,6 +182,7 @@ export function duplicateSessionRecord( } : undefined, pendingApproval: undefined, + pendingApprovalQueue: undefined, runs: [], messages: session.messages.map((message): ChatMessageRecord => ({ ...message, diff --git a/tests/shared/approval.test.ts b/tests/shared/approval.test.ts index 6bbd19a..81f124f 100644 --- a/tests/shared/approval.test.ts +++ b/tests/shared/approval.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { + dequeuePendingApprovalState, + enqueuePendingApprovalState, + listPendingApprovals, approvalPolicyRequiresCheckpoint, + normalizePendingApprovalState, normalizeApprovalPolicy, normalizePendingApproval, } from '@shared/domain/approval'; @@ -64,4 +68,94 @@ describe('approval helpers', () => { ], }); }); + + test('normalizes legacy active approval plus queued approvals into a stable pending state', () => { + expect(normalizePendingApprovalState({ + pendingApproval: { + id: 'approval-1', + kind: 'tool-call', + status: 'pending', + requestedAt: '2026-03-24T10:00:00.000Z', + title: 'Approve tool access', + }, + pendingApprovalQueue: [ + { + id: 'approval-1', + kind: 'tool-call', + status: 'pending', + requestedAt: '2026-03-24T10:00:00.000Z', + title: 'Approve tool access', + }, + { + id: 'approval-2', + kind: 'final-response', + status: 'pending', + requestedAt: '2026-03-24T10:01:00.000Z', + title: 'Approve final response', + }, + { + id: 'approval-3', + kind: 'tool-call', + status: 'approved', + requestedAt: '2026-03-24T10:02:00.000Z', + resolvedAt: '2026-03-24T10:03:00.000Z', + title: 'Already resolved', + }, + ], + })).toEqual({ + pendingApproval: { + id: 'approval-1', + kind: 'tool-call', + status: 'pending', + requestedAt: '2026-03-24T10:00:00.000Z', + title: 'Approve tool access', + }, + pendingApprovalQueue: [ + { + id: 'approval-2', + kind: 'final-response', + status: 'pending', + requestedAt: '2026-03-24T10:01:00.000Z', + title: 'Approve final response', + }, + ], + }); + }); + + test('enqueues and dequeues pending approvals while keeping the first approval active', () => { + const state = enqueuePendingApprovalState( + { + pendingApproval: { + id: 'approval-1', + kind: 'tool-call', + status: 'pending', + requestedAt: '2026-03-24T10:00:00.000Z', + title: 'Approve tool access', + }, + }, + { + id: 'approval-2', + kind: 'final-response', + status: 'pending', + requestedAt: '2026-03-24T10:01:00.000Z', + title: 'Approve final response', + }, + ); + + expect(listPendingApprovals(state).map((approval) => approval.id)).toEqual([ + 'approval-1', + 'approval-2', + ]); + + expect(dequeuePendingApprovalState(state, 'approval-1')).toEqual({ + pendingApproval: { + id: 'approval-2', + kind: 'final-response', + status: 'pending', + requestedAt: '2026-03-24T10:01:00.000Z', + title: 'Approve final response', + }, + pendingApprovalQueue: undefined, + }); + }); }); diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts index b4fc26c..4bfd9ed 100644 --- a/tests/shared/sessionLibrary.test.ts +++ b/tests/shared/sessionLibrary.test.ts @@ -129,6 +129,15 @@ describe('session library helpers', () => { requestedAt: '2026-03-23T00:01:00.000Z', title: 'Approve tool access', }, + pendingApprovalQueue: [ + { + id: 'approval-2', + kind: 'final-response', + status: 'pending', + requestedAt: '2026-03-23T00:02:00.000Z', + title: 'Approve final response', + }, + ], messages: [ { id: 'msg-1', @@ -157,6 +166,7 @@ describe('session library helpers', () => { }); expect(session.messages[0]?.pending).toBe(false); expect(session.pendingApproval).toBeUndefined(); + expect(session.pendingApprovalQueue).toBeUndefined(); expect(session.runs).toEqual([]); });