// Generated from types/*.ts — do not edit. // Regenerate with: npm run generate:typescript /** * Chat Channel Reducer — Pure reducer for `ChatState`, including turn * lifecycle, tool call transitions, pending messages, and input requests. * * @module channels-chat/reducer */ import { ActionType } from '../common/actions.js'; import type { ChatState, ToolCallState, ResponsePart, ToolCallResponsePart, InputRequestResponsePart, ErrorResponsePart, Turn, PendingMessage, ConfirmationOption, ToolCallContributor, } from './state.js'; import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, } from './state.js'; import { SessionStatus } from '../channels-session/state.js'; import type { ChatAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; import { addMillisecondsToTimestamp } from '../common/timestamps.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── /** Extracts the common base fields shared by all tool call lifecycle states. */ function tcBase(tc: ToolCallState) { return { toolCallId: tc.toolCallId, toolName: tc.toolName, displayName: tc.displayName, intention: tc.intention, contributor: tc.contributor, _meta: tc._meta, }; } function tcBaseWithMeta(tc: ToolCallState, meta: Record | undefined) { return { ...tcBase(tc), _meta: meta ?? tc._meta, }; } function refineToolCallContributor( current: ToolCallContributor | undefined, next: ToolCallContributor | undefined, log?: (msg: string) => void, ): ToolCallContributor | undefined { if (!next) { return current; } if (current?.kind === ToolCallContributorKind.Client) { if (next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId) { return next; } log?.(`Ignoring contributor change for client tool call from '${current.clientId}'`); return current; } if (next.kind === ToolCallContributorKind.Client) { log?.(`Ignoring late client contributor '${next.clientId}' because client execution ownership must be established at tool call start`); return current; } return next; } /** Resolves a selected option from the confirmation options array by ID. */ function resolveSelectedOption(options: ConfirmationOption[] | undefined, id: string | undefined): ConfirmationOption | undefined { if (!id || !options) { return undefined; } return options.find(o => o.id === id); } /** * Returns `true` if the active turn has any tool call blocking on something * external to the turn itself — a pending confirmation/result-confirmation, * or a tool call paused on MCP authentication. */ function hasBlockingToolCall(state: ChatState): boolean { if (!state.activeTurn) { return false; } return state.activeTurn.responseParts.some(part => part.kind === ResponsePartKind.ToolCall && (part.toolCall.status === ToolCallStatus.PendingConfirmation || part.toolCall.status === ToolCallStatus.PendingResultConfirmation || part.toolCall.status === ToolCallStatus.AuthRequired), ); } /** Returns whether the active turn contains an input request awaiting submission. */ function hasOpenInputRequest(state: ChatState): boolean { return state.activeTurn?.responseParts.some(part => part.kind === ResponsePartKind.InputRequest && part.response === undefined, ) ?? false; } function findOpenInputRequestPart( responseParts: readonly ResponsePart[], requestId: string, ): { index: number; part: InputRequestResponsePart } | undefined { const index = responseParts.findIndex(part => part.kind === ResponsePartKind.InputRequest && part.response === undefined && part.request.id === requestId, ); if (index < 0) { return undefined; } const part = responseParts[index]; return part.kind === ResponsePartKind.InputRequest ? { index, part } : undefined; } function hasResumableError(turn: Turn): boolean { const part = turn.responseParts[turn.responseParts.length - 1]; return part?.kind === ResponsePartKind.Error && part.resumable === true; } function isErrorResponsePart(part: ResponsePart): part is ErrorResponsePart { return part.kind === ResponsePartKind.Error; } /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ const STATUS_ACTIVITY_MASK = (1 << 5) - 1; /** Sets or clears a metadata flag on a status value. */ function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus { return set ? status | flag : status & ~flag; } /** Derives the summary status from live session work, preserving orthogonal flags. */ function summaryStatus(state: ChatState, terminalStatus?: SessionStatus.Error): SessionStatus { let activity: SessionStatus; if (terminalStatus) { activity = terminalStatus; } else if (hasOpenInputRequest(state) || hasBlockingToolCall(state)) { activity = SessionStatus.InputNeeded; } else if (state.activeTurn) { activity = SessionStatus.InProgress; } else { activity = SessionStatus.Idle; } return state.status & ~STATUS_ACTIVITY_MASK | activity; } /** * Returns a state with `status` recomputed. Use this after reducers * that change data which feeds into {@link summaryStatus} (e.g. tool call * lifecycle transitions that may enter or leave a pending-confirmation state). */ function refreshSummaryStatus(state: ChatState): ChatState { const status = summaryStatus(state); if (status === state.status) { return state; } return { ...state, status }; } /** * Ends the active turn, finalizing it into a completed turn record. * * Tool call parts with non-terminal states are forced to cancelled. * Pending permissions are stripped from tool call parts. */ function endTurn( state: ChatState, turnId: string, turnState: TurnState, duration: number, terminalStatus?: SessionStatus.Error, errorPart?: ErrorResponsePart, ): ChatState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; } const active = state.activeTurn; const responseParts: ResponsePart[] = active.responseParts.map(part => { if (part.kind !== ResponsePartKind.ToolCall) { return part; } const tc = part.toolCall; if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { return part; } // Force non-terminal tool calls into cancelled state return { kind: ResponsePartKind.ToolCall, toolCall: { status: ToolCallStatus.Cancelled as const, ...tcBase(tc), invocationMessage: tc.status === ToolCallStatus.Streaming ? (tc.invocationMessage ?? '') : tc.invocationMessage, toolInput: tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput, reason: ToolCallCancellationReason.Skipped, }, }; }); if (errorPart) { responseParts.push(errorPart); } const turn: Turn = { id: active.id, startedAt: active.startedAt, // Defensive clamp: the duration is producer-supplied and opaque to this // reducer, but a negative value would be nonsensical to display. duration: Math.max(0, duration), message: active.message, responseParts, usage: active.usage, state: turnState, }; const next: ChatState = { ...state, turns: [...state.turns, turn], activeTurn: undefined, modifiedAt: addMillisecondsToTimestamp(active.startedAt, turn.duration ?? 0), }; return { ...next, status: summaryStatus(next, terminalStatus), }; } function upsertInputRequestPart(state: ChatState, request: InputRequestResponsePart['request']): ChatState { const activeTurn = state.activeTurn; if (!activeTurn) { return state; } const existing = findOpenInputRequestPart(activeTurn.responseParts, request.id); const responseParts = [...activeTurn.responseParts]; const part: InputRequestResponsePart = { kind: ResponsePartKind.InputRequest, request, }; if (existing) { part.request = { ...request, answers: request.answers ?? existing.part.request.answers, }; responseParts[existing.index] = part; } else { responseParts.push(part); } const next: ChatState = { ...state, activeTurn: { ...activeTurn, responseParts, }, }; return { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false) }; } /** * Immutably updates the tool call inside a `ToolCall` response part in the * active turn's `responseParts` array. Returns `state` unchanged if the * active turn or tool call doesn't match. */ function updateToolCallInParts( state: ChatState, turnId: string, toolCallId: string, updater: (tc: ToolCallState) => ToolCallState, ): ChatState { const activeTurn = state.activeTurn; if (!activeTurn || activeTurn.id !== turnId) { return state; } let found = false; const responseParts = activeTurn.responseParts.map(part => { if (part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === toolCallId) { const updated = updater(part.toolCall); if (updated === part.toolCall) { return part; } found = true; return { ...part, toolCall: updated }; } return part; }); if (!found) { return state; } return { ...state, activeTurn: { ...activeTurn, responseParts }, }; } /** * Immutably updates a response part by `partId` in the active turn. * For markdown/reasoning parts, matches on `id`. For tool call parts, * matches on `toolCall.toolCallId`. */ function updateResponsePart( state: ChatState, turnId: string, partId: string, updater: (part: ResponsePart) => ResponsePart, ): ChatState { const activeTurn = state.activeTurn; if (!activeTurn || activeTurn.id !== turnId) { return state; } let found = false; const responseParts = activeTurn.responseParts.map(part => { if (!found) { const id = part.kind === ResponsePartKind.ToolCall ? part.toolCall.toolCallId : 'id' in part ? part.id : undefined; if (id === partId) { found = true; return updater(part); } } return part; }); if (!found) { return state; } return { ...state, activeTurn: { ...activeTurn, responseParts }, }; } // ─── Chat Reducer ──────────────────────────────────────────────────────────── /** * Pure reducer for chat state. Handles all {@link ChatAction} variants. */ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: string) => void): ChatState { switch (action.type) { // ── Turn Lifecycle ──────────────────────────────────────────────────── case ActionType.ChatTurnStarted: { let next: ChatState = { ...state, activeTurn: { id: action.turnId, startedAt: action.startedAt, message: action.message, responseParts: [], usage: undefined, }, }; next = { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: action.startedAt, }; // If this turn was auto-started from a pending message, remove it if (action.queuedMessageId) { if (next.steeringMessage?.id === action.queuedMessageId) { next = { ...next, steeringMessage: undefined }; } if (next.queuedMessages) { const filtered = next.queuedMessages.filter(m => m.id !== action.queuedMessageId); next = { ...next, queuedMessages: filtered.length > 0 ? filtered : undefined }; } } return next; } case ActionType.ChatDelta: return updateResponsePart(state, action.turnId, action.partId, part => { if (part.kind === ResponsePartKind.Markdown) { return { ...part, content: part.content + action.content }; } return part; }); case ActionType.ChatResponsePart: if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } if (isErrorResponsePart(action.part)) { return state; } return { ...state, activeTurn: { ...state.activeTurn, responseParts: [...state.activeTurn.responseParts, action.part], }, }; case ActionType.ChatTurnComplete: return endTurn(state, action.turnId, TurnState.Complete, action.duration); case ActionType.ChatTurnCancelled: return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.part); case ActionType.ChatTurnResume: { if (state.activeTurn) { return state; } const turnIndex = state.turns.length - 1; const turn = state.turns[turnIndex]; if (!turn || turn.id !== action.turnId || turn.state !== TurnState.Error || !hasResumableError(turn)) { return state; } const turns = state.turns.slice(); turns.splice(turnIndex, 1); const next: ChatState = { ...state, turns, activeTurn: { id: turn.id, startedAt: turn.startedAt ?? state.modifiedAt, message: turn.message, responseParts: turn.responseParts, usage: turn.usage, }, }; return { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), }; } case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; // ── Working Directories ─────────────────────────────────────────────── case ActionType.ChatWorkingDirectorySet: { const list = state.workingDirectories ?? []; if (list.includes(action.directory)) { return state; } return { ...state, workingDirectories: [...list, action.directory] }; } case ActionType.ChatWorkingDirectoryRemoved: { const list = state.workingDirectories; if (!list) { return state; } const idx = list.indexOf(action.directory); if (idx < 0) { return state; } const updated = list.slice(); updated.splice(idx, 1); return { ...state, workingDirectories: updated }; } // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } return { ...state, activeTurn: { ...state.activeTurn, responseParts: [ ...state.activeTurn.responseParts, { kind: ResponsePartKind.ToolCall, toolCall: { toolCallId: action.toolCallId, toolName: action.toolName, displayName: action.displayName, intention: action.intention, contributor: action.contributor, _meta: action._meta, status: ToolCallStatus.Streaming, }, } satisfies ToolCallResponsePart, ], }, }; case ActionType.ChatToolCallDelta: return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.Streaming) { return tc; } return { ...tc, ...(action._meta !== undefined ? { _meta: action._meta } : {}), ...(action.content !== undefined ? { partialInput: (tc.partialInput ?? '') + action.content } : {}), invocationMessage: action.invocationMessage ?? tc.invocationMessage, }; }); case ActionType.ChatToolCallReady: return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if ( tc.status !== ToolCallStatus.Streaming && tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation ) { return tc; } const base = { ...tcBaseWithMeta(tc, action._meta), contributor: refineToolCallContributor(tc.contributor, action.contributor, log), intention: action.intention ?? tc.intention, }; const toolInput = action.toolInput ?? (tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput); if (action.confirmed) { return { status: ToolCallStatus.Running, ...base, invocationMessage: action.invocationMessage, toolInput, confirmed: action.confirmed, }; } const pending = tc.status === ToolCallStatus.PendingConfirmation ? tc : undefined; const options = action.options ?? pending?.options; return { status: ToolCallStatus.PendingConfirmation, ...base, invocationMessage: action.invocationMessage, toolInput, confirmationTitle: action.confirmationTitle ?? pending?.confirmationTitle, riskAssessment: action.riskAssessment ?? pending?.riskAssessment, edits: action.edits ?? pending?.edits, editable: action.editable ?? pending?.editable, ...(options ? { options } : {}), }; })); case ActionType.ChatToolCallConfirmed: return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.PendingConfirmation) { return tc; } const base = tcBaseWithMeta(tc, action._meta); const selectedOption = resolveSelectedOption(tc.options, action.selectedOptionId); if (action.approved) { const toolInput = action.editedToolInput !== undefined && typeof tc.toolInput === 'string' ? action.editedToolInput : tc.toolInput; return { status: ToolCallStatus.Running, ...base, invocationMessage: tc.invocationMessage, toolInput, confirmed: action.confirmed, ...(selectedOption ? { selectedOption } : {}), }; } return { status: ToolCallStatus.Cancelled, ...base, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, reason: action.reason, reasonMessage: action.reasonMessage, userSuggestion: action.userSuggestion, ...(selectedOption ? { selectedOption } : {}), }; })); case ActionType.ChatToolCallComplete: return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation && tc.status !== ToolCallStatus.AuthRequired) { return tc; } // A tool call in `auth-required` can only be completed with a failed // result — that's the client cancelling the invocation instead of // resolving the pending MCP authentication challenge. A *successful* // completion from `auth-required` is invalid: execution never // resumed after the challenge, so there's nothing that could have // produced a real result. The reducer ignores it, leaving the tool // call in `auth-required`; the client must resolve the auth // challenge (`chat/toolCallAuthResolved`) before completing // successfully. if (tc.status === ToolCallStatus.AuthRequired && action.result.success) { return tc; } const base = tcBaseWithMeta(tc, action._meta); const confirmed = tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.AuthRequired ? tc.confirmed : ToolCallConfirmationReason.NotNeeded; const selectedOption = tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.AuthRequired ? tc.selectedOption : undefined; // Preserve any partial content produced before the call paused for // auth — a client cancelling from `auth-required` without // authenticating never resumes execution, so this is the only // content the tool ever produced unless `action.result` overrides it. const preAuthContent = tc.status === ToolCallStatus.AuthRequired ? tc.content : undefined; // Cancelling from `auth-required` always completes terminally: the // pending auth challenge isn't a "pending result" the client can // review, so `requiresResultConfirmation` is ignored for this path — // it must never enter `pending-result-confirmation`. if (action.requiresResultConfirmation && tc.status !== ToolCallStatus.AuthRequired) { return { status: ToolCallStatus.PendingResultConfirmation, ...base, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed, ...(selectedOption ? { selectedOption } : {}), ...(preAuthContent ? { content: preAuthContent } : {}), ...action.result, }; } return { status: ToolCallStatus.Completed, ...base, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed, ...(selectedOption ? { selectedOption } : {}), ...(preAuthContent ? { content: preAuthContent } : {}), ...action.result, }; })); case ActionType.ChatToolCallResultConfirmed: return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.PendingResultConfirmation) { return tc; } const base = tcBaseWithMeta(tc, action._meta); if (action.approved) { return { status: ToolCallStatus.Completed, ...base, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed: tc.confirmed, ...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}), success: tc.success, pastTenseMessage: tc.pastTenseMessage, content: tc.content, structuredContent: tc.structuredContent, error: tc.error, }; } return { status: ToolCallStatus.Cancelled, ...base, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, reason: ToolCallCancellationReason.ResultDenied, ...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}), }; })); case ActionType.ChatToolCallContentChanged: return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.Running) { return tc; } return { ...tc, ...(action._meta !== undefined ? { _meta: action._meta } : {}), content: action.content, }; }); case ActionType.ChatToolCallAuthRequired: return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.Running) { return tc; } // Invariant: auth-required only applies to MCP-contributed tool calls. if (!tc.contributor || tc.contributor.kind !== ToolCallContributorKind.MCP) { return tc; } const base = tcBaseWithMeta(tc, action._meta); return { status: ToolCallStatus.AuthRequired, ...base, contributor: tc.contributor, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed: tc.confirmed, ...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}), ...(tc.content ? { content: tc.content } : {}), auth: action.auth, }; })); case ActionType.ChatToolCallAuthResolved: return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.AuthRequired) { return tc; } const base = tcBaseWithMeta(tc, action._meta); return { status: ToolCallStatus.Running, ...base, invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed: tc.confirmed, ...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}), ...(tc.content ? { content: tc.content } : {}), }; })); case ActionType.ChatUsage: if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } return { ...state, activeTurn: { ...state.activeTurn, usage: action.usage }, }; case ActionType.ChatReasoning: return updateResponsePart(state, action.turnId, action.partId, part => { if (part.kind === ResponsePartKind.Reasoning) { return { ...part, content: part.content + action.content }; } return part; }); // ── Truncation ──────────────────────────────────────────────────────── case ActionType.ChatTruncated: { let turns: typeof state.turns; if (action.turnId === undefined) { turns = []; } else { const idx = state.turns.findIndex(t => t.id === action.turnId); if (idx < 0) { return state; } turns = state.turns.slice(0, idx + 1); } const next: ChatState = { ...state, turns, activeTurn: undefined, }; if (action.turnId === undefined) { delete next.turnsNextCursor; } return { ...next, status: summaryStatus(next), }; } case ActionType.ChatTurnsLoaded: { const existingIds = new Set(state.turns.map(turn => turn.id)); const olderTurns = action.turns.filter(turn => !existingIds.has(turn.id)); return { ...state, turns: [...olderTurns, ...state.turns], turnsNextCursor: action.turnsNextCursor, }; } // ── Session Input Requests ───────────────────────────────────────────── case ActionType.ChatInputRequested: return upsertInputRequestPart(state, action.request); case ActionType.ChatInputAnswerChanged: { const activeTurn = state.activeTurn; const existing = activeTurn ? findOpenInputRequestPart(activeTurn.responseParts, action.requestId) : undefined; if (!activeTurn || !existing) { return state; } const { index, part } = existing; const request = part.request; const answers = { ...(request.answers ?? {}) }; if (action.answer === undefined) { delete answers[action.questionId]; } else { answers[action.questionId] = action.answer; } const responseParts = [...activeTurn.responseParts]; responseParts[index] = { ...part, request: { ...request, answers: Object.keys(answers).length > 0 ? answers : undefined, }, }; return { ...state, activeTurn: { ...activeTurn, responseParts, }, }; } case ActionType.ChatInputCompleted: { const activeTurn = state.activeTurn; const existing = activeTurn ? findOpenInputRequestPart(activeTurn.responseParts, action.requestId) : undefined; if (!activeTurn || !existing) { return state; } const { index, part } = existing; const finalAnswers = { ...(part.request.answers ?? {}), ...(action.answers ?? {}) }; const responseParts = [...activeTurn.responseParts]; responseParts[index] = { ...part, request: { ...part.request, answers: Object.keys(finalAnswers).length > 0 ? finalAnswers : undefined, }, response: action.response, }; const next: ChatState = { ...state, activeTurn: { ...activeTurn, responseParts, }, }; return { ...next, status: summaryStatus(next), }; } // ── Pending Messages ────────────────────────────────────────────────── case ActionType.ChatPendingMessageSet: { const entry: PendingMessage = { id: action.id, message: action.message }; if (action.kind === PendingMessageKind.Steering) { return { ...state, steeringMessage: entry }; } const existing = state.queuedMessages ?? []; const idx = existing.findIndex(m => m.id === action.id); if (idx >= 0) { const updated = [...existing]; updated[idx] = entry; return { ...state, queuedMessages: updated }; } return { ...state, queuedMessages: [...existing, entry] }; } case ActionType.ChatPendingMessageRemoved: { if (action.kind === PendingMessageKind.Steering) { if (!state.steeringMessage || state.steeringMessage.id !== action.id) { return state; } return { ...state, steeringMessage: undefined }; } const existing = state.queuedMessages; if (!existing) { return state; } const filtered = existing.filter(m => m.id !== action.id); return filtered.length === existing.length ? state : { ...state, queuedMessages: filtered.length > 0 ? filtered : undefined }; } case ActionType.ChatQueuedMessagesReordered: { const existing = state.queuedMessages; if (!existing) { return state; } const byId = new Map(existing.map(m => [m.id, m])); const ordered = new Set(); const reordered = action.order .filter(id => { if (byId.has(id) && !ordered.has(id)) { ordered.add(id); return true; } return false; }) .map(id => byId.get(id)!); // Append any messages not mentioned in order, preserving original order for (const m of existing) { if (!ordered.has(m.id)) { reordered.push(m); } } return { ...state, queuedMessages: reordered }; } // ── Draft ───────────────────────────────────────────────────────────── case ActionType.ChatDraftChanged: return { ...state, draft: action.draft }; default: softAssertNever(action, log); return state; } }