Path: blob/main/src/vs/platform/agentHost/common/state/protocol/reducers.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45// allow-any-unicode-comment-file6// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts78import { ActionType } from './actions.js';9import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type RootState, type SessionInputRequest, type SessionState, type TerminalState, type TerminalContentPart, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js';10import { IS_CLIENT_DISPATCHABLE, type RootAction, type ClientRootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction } from './action-origin.generated.js';1112// ─── Helpers ─────────────────────────────────────────────────────────────────1314/**15* Soft assertion for exhaustiveness checking. Place in the `default` branch of16* a switch on a discriminated union so the compiler errors when a new variant17* is added but not handled.18*19* At runtime, logs a warning instead of throwing so that forward-compatible20* clients receiving unknown actions from a newer server degrade gracefully.21*/22export function softAssertNever(value: never, log?: (msg: string) => void): void {23const msg = `Unhandled action type: ${JSON.stringify(value)}`;24(log ?? console.warn)(msg);25}2627/** Extracts the common base fields shared by all tool call lifecycle states. */28function tcBase(tc: ToolCallState) {29return {30toolCallId: tc.toolCallId,31toolName: tc.toolName,32displayName: tc.displayName,33toolClientId: tc.toolClientId,34_meta: tc._meta,35};36}3738/** Resolves a selected option from the confirmation options array by ID. */39function resolveSelectedOption(options: ConfirmationOption[] | undefined, id: string | undefined): ConfirmationOption | undefined {40if (!id || !options) {41return undefined;42}43return options.find(o => o.id === id);44}4546/** Returns `true` if the active turn has any tool call awaiting user confirmation. */47function hasPendingToolCallConfirmation(state: SessionState): boolean {48if (!state.activeTurn) {49return false;50}51return state.activeTurn.responseParts.some(part =>52part.kind === ResponsePartKind.ToolCall53&& (part.toolCall.status === ToolCallStatus.PendingConfirmation54|| part.toolCall.status === ToolCallStatus.PendingResultConfirmation),55);56}5758/** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */59const STATUS_ACTIVITY_MASK = (1 << 5) - 1;6061/** Sets or clears a metadata flag on a status value. */62function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus {63return set ? status | flag : status & ~flag;64}6566/** Derives the summary status from live session work, preserving orthogonal flags. */67function summaryStatus(state: SessionState, terminalStatus?: SessionStatus.Error): SessionStatus {68let activity: SessionStatus;69if (terminalStatus) {70activity = terminalStatus;71} else if ((state.inputRequests?.length ?? 0) > 0 || hasPendingToolCallConfirmation(state)) {72activity = SessionStatus.InputNeeded;73} else if (state.activeTurn) {74activity = SessionStatus.InProgress;75} else {76activity = SessionStatus.Idle;77}7879return state.summary.status & ~STATUS_ACTIVITY_MASK | activity;80}8182/**83* Returns a state with `summary.status` recomputed. Use this after reducers84* that change data which feeds into {@link summaryStatus} (e.g. tool call85* lifecycle transitions that may enter or leave a pending-confirmation state).86*/87function refreshSummaryStatus(state: SessionState): SessionState {88const status = summaryStatus(state);89if (status === state.summary.status) {90return state;91}92return { ...state, summary: { ...state.summary, status } };93}9495/**96* Ends the active turn, finalizing it into a completed turn record.97*98* Tool call parts with non-terminal states are forced to cancelled.99* Pending permissions are stripped from tool call parts.100*/101function endTurn(102state: SessionState,103turnId: string,104turnState: TurnState,105terminalStatus?: SessionStatus.Error,106error?: { errorType: string; message: string; stack?: string },107): SessionState {108if (!state.activeTurn || state.activeTurn.id !== turnId) {109return state;110}111const active = state.activeTurn;112113const responseParts: ResponsePart[] = active.responseParts.map(part => {114if (part.kind !== ResponsePartKind.ToolCall) {115return part;116}117const tc = part.toolCall;118if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) {119return part;120}121// Force non-terminal tool calls into cancelled state122return {123kind: ResponsePartKind.ToolCall,124toolCall: {125status: ToolCallStatus.Cancelled as const,126...tcBase(tc),127invocationMessage: tc.status === ToolCallStatus.Streaming ? (tc.invocationMessage ?? '') : tc.invocationMessage,128toolInput: tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput,129reason: ToolCallCancellationReason.Skipped,130},131};132});133134const turn: Turn = {135id: active.id,136userMessage: active.userMessage,137responseParts,138usage: active.usage,139state: turnState,140error,141};142143const next: SessionState = {144...state,145turns: [...state.turns, turn],146activeTurn: undefined,147summary: { ...state.summary, modifiedAt: Date.now() },148};149delete next.inputRequests;150return {151...next,152summary: { ...next.summary, status: summaryStatus(next, terminalStatus) },153};154}155156function upsertInputRequest(state: SessionState, request: SessionInputRequest): SessionState {157const existing = state.inputRequests ?? [];158const idx = existing.findIndex(r => r.id === request.id);159const inputRequests = [...existing];160if (idx >= 0) {161const answers = request.answers ?? inputRequests[idx].answers;162inputRequests[idx] = { ...request, answers };163} else {164inputRequests.push(request);165}166const next = { ...state, inputRequests };167return { ...next, summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() } };168}169170/**171* Immutably updates the tool call inside a `ToolCall` response part in the172* active turn's `responseParts` array. Returns `state` unchanged if the173* active turn or tool call doesn't match.174*/175function updateToolCallInParts(176state: SessionState,177turnId: string,178toolCallId: string,179updater: (tc: ToolCallState) => ToolCallState,180): SessionState {181const activeTurn = state.activeTurn;182if (!activeTurn || activeTurn.id !== turnId) {183return state;184}185186let found = false;187const responseParts = activeTurn.responseParts.map(part => {188if (part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === toolCallId) {189const updated = updater(part.toolCall);190if (updated === part.toolCall) {191return part;192}193found = true;194return { ...part, toolCall: updated };195}196return part;197});198199if (!found) {200return state;201}202203return {204...state,205activeTurn: { ...activeTurn, responseParts },206};207}208209/**210* Immutably updates a response part by `partId` in the active turn.211* For markdown/reasoning parts, matches on `id`. For tool call parts,212* matches on `toolCall.toolCallId`.213*/214function updateResponsePart(215state: SessionState,216turnId: string,217partId: string,218updater: (part: ResponsePart) => ResponsePart,219): SessionState {220const activeTurn = state.activeTurn;221if (!activeTurn || activeTurn.id !== turnId) {222return state;223}224225let found = false;226const responseParts = activeTurn.responseParts.map(part => {227if (!found) {228const id = part.kind === ResponsePartKind.ToolCall229? part.toolCall.toolCallId230: 'id' in part ? part.id : undefined;231if (id === partId) {232found = true;233return updater(part);234}235}236return part;237});238239if (!found) {240return state;241}242243return {244...state,245activeTurn: { ...activeTurn, responseParts },246};247}248249// ─── Root Reducer ────────────────────────────────────────────────────────────250251/**252* Pure reducer for root state. Handles all {@link RootAction} variants.253*/254export function rootReducer(state: RootState, action: RootAction, log?: (msg: string) => void): RootState {255switch (action.type) {256case ActionType.RootAgentsChanged:257return { ...state, agents: action.agents };258259case ActionType.RootActiveSessionsChanged:260return { ...state, activeSessions: action.activeSessions };261262case ActionType.RootTerminalsChanged:263return { ...state, terminals: action.terminals };264265case ActionType.RootConfigChanged:266if (!state.config) {267return state;268}269return {270...state,271config: {272...state.config,273values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config },274},275};276277default:278softAssertNever(action, log);279return state;280}281}282283// ─── Session Reducer ─────────────────────────────────────────────────────────284285/**286* Pure reducer for session state. Handles all {@link SessionAction} variants.287*/288export function sessionReducer(state: SessionState, action: SessionAction, log?: (msg: string) => void): SessionState {289switch (action.type) {290// ── Lifecycle ──────────────────────────────────────────────────────────291292case ActionType.SessionReady:293return {294...state,295lifecycle: SessionLifecycle.Ready,296summary: { ...state.summary, status: SessionStatus.Idle },297};298299case ActionType.SessionCreationFailed:300return {301...state,302lifecycle: SessionLifecycle.CreationFailed,303creationError: action.error,304};305306// ── Turn Lifecycle ────────────────────────────────────────────────────307308case ActionType.SessionTurnStarted: {309let next: SessionState = {310...state,311activeTurn: {312id: action.turnId,313userMessage: action.userMessage,314responseParts: [],315usage: undefined,316},317};318next = {319...next,320summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() },321};322323// If this turn was auto-started from a pending message, remove it324if (action.queuedMessageId) {325if (next.steeringMessage?.id === action.queuedMessageId) {326next = { ...next, steeringMessage: undefined };327}328if (next.queuedMessages) {329const filtered = next.queuedMessages.filter(m => m.id !== action.queuedMessageId);330next = { ...next, queuedMessages: filtered.length > 0 ? filtered : undefined };331}332}333334return next;335}336337case ActionType.SessionDelta:338return updateResponsePart(state, action.turnId, action.partId, part => {339if (part.kind === ResponsePartKind.Markdown) {340return { ...part, content: part.content + action.content };341}342return part;343});344345case ActionType.SessionResponsePart:346if (!state.activeTurn || state.activeTurn.id !== action.turnId) {347return state;348}349return {350...state,351activeTurn: {352...state.activeTurn,353responseParts: [...state.activeTurn.responseParts, action.part],354},355};356357case ActionType.SessionTurnComplete:358return endTurn(state, action.turnId, TurnState.Complete);359360case ActionType.SessionTurnCancelled:361return endTurn(state, action.turnId, TurnState.Cancelled);362363case ActionType.SessionError:364return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error);365366// ── Tool Call State Machine ───────────────────────────────────────────367368case ActionType.SessionToolCallStart:369if (!state.activeTurn || state.activeTurn.id !== action.turnId) {370return state;371}372return {373...state,374activeTurn: {375...state.activeTurn,376responseParts: [377...state.activeTurn.responseParts,378{379kind: ResponsePartKind.ToolCall,380toolCall: {381toolCallId: action.toolCallId,382toolName: action.toolName,383displayName: action.displayName,384toolClientId: action.toolClientId,385_meta: action._meta,386status: ToolCallStatus.Streaming,387},388} satisfies ToolCallResponsePart,389],390},391};392393case ActionType.SessionToolCallDelta:394return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {395if (tc.status !== ToolCallStatus.Streaming) {396return tc;397}398return {399...tc,400partialInput: (tc.partialInput ?? '') + action.content,401invocationMessage: action.invocationMessage ?? tc.invocationMessage,402};403});404405case ActionType.SessionToolCallReady:406return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {407if (tc.status !== ToolCallStatus.Streaming && tc.status !== ToolCallStatus.Running) {408return tc;409}410const base = tcBase(tc);411if (action.confirmed) {412return {413status: ToolCallStatus.Running,414...base,415invocationMessage: action.invocationMessage,416toolInput: action.toolInput,417confirmed: action.confirmed,418};419}420return {421status: ToolCallStatus.PendingConfirmation,422...base,423invocationMessage: action.invocationMessage,424toolInput: action.toolInput,425confirmationTitle: action.confirmationTitle,426edits: action.edits,427editable: action.editable,428...(action.options ? { options: action.options } : {}),429};430}));431432case ActionType.SessionToolCallConfirmed:433return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {434if (tc.status !== ToolCallStatus.PendingConfirmation) {435return tc;436}437const base = tcBase(tc);438const selectedOption = resolveSelectedOption(tc.options, action.selectedOptionId);439if (action.approved) {440return {441status: ToolCallStatus.Running,442...base,443invocationMessage: tc.invocationMessage,444toolInput: action.editedToolInput ?? tc.toolInput,445confirmed: action.confirmed,446...(selectedOption ? { selectedOption } : {}),447};448}449return {450status: ToolCallStatus.Cancelled,451...base,452invocationMessage: tc.invocationMessage,453toolInput: tc.toolInput,454reason: action.reason,455reasonMessage: action.reasonMessage,456userSuggestion: action.userSuggestion,457...(selectedOption ? { selectedOption } : {}),458};459}));460461case ActionType.SessionToolCallComplete:462return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {463if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation) {464return tc;465}466const base = tcBase(tc);467const confirmed = tc.status === ToolCallStatus.Running468? tc.confirmed469: ToolCallConfirmationReason.NotNeeded;470const selectedOption = tc.status === ToolCallStatus.Running471? tc.selectedOption472: undefined;473if (action.requiresResultConfirmation) {474return {475status: ToolCallStatus.PendingResultConfirmation,476...base,477invocationMessage: tc.invocationMessage,478toolInput: tc.toolInput,479confirmed,480...(selectedOption ? { selectedOption } : {}),481...action.result,482};483}484return {485status: ToolCallStatus.Completed,486...base,487invocationMessage: tc.invocationMessage,488toolInput: tc.toolInput,489confirmed,490...(selectedOption ? { selectedOption } : {}),491...action.result,492};493}));494495case ActionType.SessionToolCallResultConfirmed:496return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {497if (tc.status !== ToolCallStatus.PendingResultConfirmation) {498return tc;499}500const base = tcBase(tc);501if (action.approved) {502return {503status: ToolCallStatus.Completed,504...base,505invocationMessage: tc.invocationMessage,506toolInput: tc.toolInput,507confirmed: tc.confirmed,508...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}),509success: tc.success,510pastTenseMessage: tc.pastTenseMessage,511content: tc.content,512structuredContent: tc.structuredContent,513error: tc.error,514};515}516return {517status: ToolCallStatus.Cancelled,518...base,519invocationMessage: tc.invocationMessage,520toolInput: tc.toolInput,521reason: ToolCallCancellationReason.ResultDenied,522...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}),523};524}));525526case ActionType.SessionToolCallContentChanged:527return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {528if (tc.status !== ToolCallStatus.Running) {529return tc;530}531return {532...tc,533content: action.content,534};535});536537// ── Metadata ──────────────────────────────────────────────────────────538539case ActionType.SessionTitleChanged:540return {541...state,542summary: { ...state.summary, title: action.title, modifiedAt: Date.now() },543};544545case ActionType.SessionUsage:546if (!state.activeTurn || state.activeTurn.id !== action.turnId) {547return state;548}549return {550...state,551activeTurn: { ...state.activeTurn, usage: action.usage },552};553554case ActionType.SessionReasoning:555return updateResponsePart(state, action.turnId, action.partId, part => {556if (part.kind === ResponsePartKind.Reasoning) {557return { ...part, content: part.content + action.content };558}559return part;560});561562case ActionType.SessionModelChanged:563return {564...state,565summary: { ...state.summary, model: action.model, modifiedAt: Date.now() },566};567568case ActionType.SessionIsReadChanged:569return {570...state,571summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsRead, action.isRead) },572};573574case ActionType.SessionIsArchivedChanged:575return {576...state,577summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsArchived, action.isArchived) },578};579580case ActionType.SessionActivityChanged:581return {582...state,583summary: { ...state.summary, activity: action.activity },584};585586case ActionType.SessionDiffsChanged:587return {588...state,589summary: { ...state.summary, diffs: action.diffs },590};591592case ActionType.SessionConfigChanged:593if (!state.config) {594return state;595}596return {597...state,598config: {599...state.config,600values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config },601},602summary: {603...state.summary,604modifiedAt: Date.now(),605},606};607608case ActionType.SessionMetaChanged:609return { ...state, _meta: action._meta };610611case ActionType.SessionServerToolsChanged:612return { ...state, serverTools: action.tools };613614case ActionType.SessionActiveClientChanged:615return {616...state,617activeClient: action.activeClient ?? undefined,618};619620case ActionType.SessionActiveClientToolsChanged:621if (!state.activeClient) {622return state;623}624return {625...state,626activeClient: { ...state.activeClient, tools: action.tools },627};628629// ── Customizations ──────────────────────────────────────────────────630631case ActionType.SessionCustomizationsChanged:632return { ...state, customizations: action.customizations };633634case ActionType.SessionCustomizationToggled: {635const list = state.customizations;636if (!list) {637return state;638}639const idx = list.findIndex(c => c.customization.uri === action.uri);640if (idx < 0) {641return state;642}643const updated = [...list];644updated[idx] = { ...list[idx], enabled: action.enabled };645return { ...state, customizations: updated };646}647648// ── Truncation ────────────────────────────────────────────────────────649650case ActionType.SessionTruncated: {651let turns: typeof state.turns;652if (action.turnId === undefined) {653turns = [];654} else {655const idx = state.turns.findIndex(t => t.id === action.turnId);656if (idx < 0) {657return state;658}659turns = state.turns.slice(0, idx + 1);660}661const next: SessionState = {662...state,663turns,664activeTurn: undefined,665summary: { ...state.summary, modifiedAt: Date.now() },666};667delete next.inputRequests;668return {669...next,670summary: { ...next.summary, status: summaryStatus(next) },671};672}673674// ── Session Input Requests ─────────────────────────────────────────────675676case ActionType.SessionInputRequested:677return upsertInputRequest(state, action.request);678679case ActionType.SessionInputAnswerChanged: {680const existing = state.inputRequests;681const idx = existing?.findIndex(request => request.id === action.requestId) ?? -1;682if (!existing || idx < 0) {683return state;684}685const request = existing[idx];686const answers = { ...(request.answers ?? {}) };687if (action.answer === undefined) {688delete answers[action.questionId];689} else {690answers[action.questionId] = action.answer;691}692const updated = [...existing];693updated[idx] = {694...request,695answers: Object.keys(answers).length > 0 ? answers : undefined,696};697return {698...state,699inputRequests: updated,700summary: { ...state.summary, modifiedAt: Date.now() },701};702}703704case ActionType.SessionInputCompleted: {705const existing = state.inputRequests;706if (!existing?.some(request => request.id === action.requestId)) {707return state;708}709const inputRequests = existing.filter(request => request.id !== action.requestId);710const next: SessionState = {711...state,712};713if (inputRequests.length > 0) {714next.inputRequests = inputRequests;715} else {716delete next.inputRequests;717}718return {719...next,720summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now() },721};722}723724// ── Pending Messages ──────────────────────────────────────────────────725726case ActionType.SessionPendingMessageSet: {727const entry: PendingMessage = { id: action.id, userMessage: action.userMessage };728if (action.kind === PendingMessageKind.Steering) {729return { ...state, steeringMessage: entry };730}731const existing = state.queuedMessages ?? [];732const idx = existing.findIndex(m => m.id === action.id);733if (idx >= 0) {734const updated = [...existing];735updated[idx] = entry;736return { ...state, queuedMessages: updated };737}738return { ...state, queuedMessages: [...existing, entry] };739}740741case ActionType.SessionPendingMessageRemoved: {742if (action.kind === PendingMessageKind.Steering) {743if (!state.steeringMessage || state.steeringMessage.id !== action.id) {744return state;745}746return { ...state, steeringMessage: undefined };747}748const existing = state.queuedMessages;749if (!existing) {750return state;751}752const filtered = existing.filter(m => m.id !== action.id);753return filtered.length === existing.length754? state755: { ...state, queuedMessages: filtered.length > 0 ? filtered : undefined };756}757758case ActionType.SessionQueuedMessagesReordered: {759const existing = state.queuedMessages;760if (!existing) {761return state;762}763const byId = new Map(existing.map(m => [m.id, m]));764const ordered = new Set<string>();765const reordered = action.order766.filter(id => {767if (byId.has(id) && !ordered.has(id)) {768ordered.add(id);769return true;770}771return false;772})773.map(id => byId.get(id)!);774// Append any messages not mentioned in order, preserving original order775for (const m of existing) {776if (!ordered.has(m.id)) {777reordered.push(m);778}779}780return { ...state, queuedMessages: reordered };781}782783default:784softAssertNever(action, log);785return state;786}787}788789// ─── Terminal Reducer ────────────────────────────────────────────────────────790791/**792* Pure reducer for terminal state. Handles all {@link TerminalAction} variants.793*/794export function terminalReducer(state: TerminalState, action: TerminalAction, log?: (msg: string) => void): TerminalState {795switch (action.type) {796case ActionType.TerminalData: {797const content = [...state.content];798const tail = content.length > 0 ? content[content.length - 1] : undefined;799if (tail && tail.type === 'command' && !tail.isComplete) {800content[content.length - 1] = { ...tail, output: tail.output + action.data };801} else if (tail && tail.type === 'unclassified') {802content[content.length - 1] = { ...tail, value: tail.value + action.data };803} else {804content.push({ type: 'unclassified', value: action.data });805}806return { ...state, content };807}808809case ActionType.TerminalInput:810// Side-effect-only: the server forwards to the pty.811// No state change in the reducer.812return state;813814case ActionType.TerminalResized:815return { ...state, cols: action.cols, rows: action.rows };816817case ActionType.TerminalClaimed:818return { ...state, claim: action.claim };819820case ActionType.TerminalTitleChanged:821return { ...state, title: action.title };822823case ActionType.TerminalCwdChanged:824return { ...state, cwd: action.cwd };825826case ActionType.TerminalExited:827return { ...state, exitCode: action.exitCode };828829case ActionType.TerminalCleared:830return { ...state, content: [] };831832case ActionType.TerminalCommandDetectionAvailable:833return { ...state, supportsCommandDetection: true };834835case ActionType.TerminalCommandExecuted: {836const part: TerminalContentPart = {837type: 'command',838commandId: action.commandId,839commandLine: action.commandLine,840output: '',841timestamp: action.timestamp,842isComplete: false,843};844return {845...state,846content: [...state.content, part],847supportsCommandDetection: true,848};849}850851case ActionType.TerminalCommandFinished: {852const content = state.content.map(p => {853if (p.type === 'command' && p.commandId === action.commandId) {854return {855...p,856isComplete: true as const,857exitCode: action.exitCode,858durationMs: action.durationMs,859};860}861return p;862});863return { ...state, content };864}865866default:867softAssertNever(action, log);868return state;869}870}871872// ─── Dispatch Validation ─────────────────────────────────────────────────────873874/**875* Type guard that checks whether an action may be dispatched by a client.876*877* Servers SHOULD call this to validate incoming `dispatchAction` requests878* and reject any action the client is not allowed to originate.879*/880export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction {881return IS_CLIENT_DISPATCHABLE[action.type];882}883884885