Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/common/state/protocol/reducers.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
// allow-any-unicode-comment-file
7
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
8
9
import { ActionType } from './actions.js';
10
import { 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';
11
import { IS_CLIENT_DISPATCHABLE, type RootAction, type ClientRootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction } from './action-origin.generated.js';
12
13
// ─── Helpers ─────────────────────────────────────────────────────────────────
14
15
/**
16
* Soft assertion for exhaustiveness checking. Place in the `default` branch of
17
* a switch on a discriminated union so the compiler errors when a new variant
18
* is added but not handled.
19
*
20
* At runtime, logs a warning instead of throwing so that forward-compatible
21
* clients receiving unknown actions from a newer server degrade gracefully.
22
*/
23
export function softAssertNever(value: never, log?: (msg: string) => void): void {
24
const msg = `Unhandled action type: ${JSON.stringify(value)}`;
25
(log ?? console.warn)(msg);
26
}
27
28
/** Extracts the common base fields shared by all tool call lifecycle states. */
29
function tcBase(tc: ToolCallState) {
30
return {
31
toolCallId: tc.toolCallId,
32
toolName: tc.toolName,
33
displayName: tc.displayName,
34
toolClientId: tc.toolClientId,
35
_meta: tc._meta,
36
};
37
}
38
39
/** Resolves a selected option from the confirmation options array by ID. */
40
function resolveSelectedOption(options: ConfirmationOption[] | undefined, id: string | undefined): ConfirmationOption | undefined {
41
if (!id || !options) {
42
return undefined;
43
}
44
return options.find(o => o.id === id);
45
}
46
47
/** Returns `true` if the active turn has any tool call awaiting user confirmation. */
48
function hasPendingToolCallConfirmation(state: SessionState): boolean {
49
if (!state.activeTurn) {
50
return false;
51
}
52
return state.activeTurn.responseParts.some(part =>
53
part.kind === ResponsePartKind.ToolCall
54
&& (part.toolCall.status === ToolCallStatus.PendingConfirmation
55
|| part.toolCall.status === ToolCallStatus.PendingResultConfirmation),
56
);
57
}
58
59
/** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */
60
const STATUS_ACTIVITY_MASK = (1 << 5) - 1;
61
62
/** Sets or clears a metadata flag on a status value. */
63
function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus {
64
return set ? status | flag : status & ~flag;
65
}
66
67
/** Derives the summary status from live session work, preserving orthogonal flags. */
68
function summaryStatus(state: SessionState, terminalStatus?: SessionStatus.Error): SessionStatus {
69
let activity: SessionStatus;
70
if (terminalStatus) {
71
activity = terminalStatus;
72
} else if ((state.inputRequests?.length ?? 0) > 0 || hasPendingToolCallConfirmation(state)) {
73
activity = SessionStatus.InputNeeded;
74
} else if (state.activeTurn) {
75
activity = SessionStatus.InProgress;
76
} else {
77
activity = SessionStatus.Idle;
78
}
79
80
return state.summary.status & ~STATUS_ACTIVITY_MASK | activity;
81
}
82
83
/**
84
* Returns a state with `summary.status` recomputed. Use this after reducers
85
* that change data which feeds into {@link summaryStatus} (e.g. tool call
86
* lifecycle transitions that may enter or leave a pending-confirmation state).
87
*/
88
function refreshSummaryStatus(state: SessionState): SessionState {
89
const status = summaryStatus(state);
90
if (status === state.summary.status) {
91
return state;
92
}
93
return { ...state, summary: { ...state.summary, status } };
94
}
95
96
/**
97
* Ends the active turn, finalizing it into a completed turn record.
98
*
99
* Tool call parts with non-terminal states are forced to cancelled.
100
* Pending permissions are stripped from tool call parts.
101
*/
102
function endTurn(
103
state: SessionState,
104
turnId: string,
105
turnState: TurnState,
106
terminalStatus?: SessionStatus.Error,
107
error?: { errorType: string; message: string; stack?: string },
108
): SessionState {
109
if (!state.activeTurn || state.activeTurn.id !== turnId) {
110
return state;
111
}
112
const active = state.activeTurn;
113
114
const responseParts: ResponsePart[] = active.responseParts.map(part => {
115
if (part.kind !== ResponsePartKind.ToolCall) {
116
return part;
117
}
118
const tc = part.toolCall;
119
if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) {
120
return part;
121
}
122
// Force non-terminal tool calls into cancelled state
123
return {
124
kind: ResponsePartKind.ToolCall,
125
toolCall: {
126
status: ToolCallStatus.Cancelled as const,
127
...tcBase(tc),
128
invocationMessage: tc.status === ToolCallStatus.Streaming ? (tc.invocationMessage ?? '') : tc.invocationMessage,
129
toolInput: tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput,
130
reason: ToolCallCancellationReason.Skipped,
131
},
132
};
133
});
134
135
const turn: Turn = {
136
id: active.id,
137
userMessage: active.userMessage,
138
responseParts,
139
usage: active.usage,
140
state: turnState,
141
error,
142
};
143
144
const next: SessionState = {
145
...state,
146
turns: [...state.turns, turn],
147
activeTurn: undefined,
148
summary: { ...state.summary, modifiedAt: Date.now() },
149
};
150
delete next.inputRequests;
151
return {
152
...next,
153
summary: { ...next.summary, status: summaryStatus(next, terminalStatus) },
154
};
155
}
156
157
function upsertInputRequest(state: SessionState, request: SessionInputRequest): SessionState {
158
const existing = state.inputRequests ?? [];
159
const idx = existing.findIndex(r => r.id === request.id);
160
const inputRequests = [...existing];
161
if (idx >= 0) {
162
const answers = request.answers ?? inputRequests[idx].answers;
163
inputRequests[idx] = { ...request, answers };
164
} else {
165
inputRequests.push(request);
166
}
167
const next = { ...state, inputRequests };
168
return { ...next, summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() } };
169
}
170
171
/**
172
* Immutably updates the tool call inside a `ToolCall` response part in the
173
* active turn's `responseParts` array. Returns `state` unchanged if the
174
* active turn or tool call doesn't match.
175
*/
176
function updateToolCallInParts(
177
state: SessionState,
178
turnId: string,
179
toolCallId: string,
180
updater: (tc: ToolCallState) => ToolCallState,
181
): SessionState {
182
const activeTurn = state.activeTurn;
183
if (!activeTurn || activeTurn.id !== turnId) {
184
return state;
185
}
186
187
let found = false;
188
const responseParts = activeTurn.responseParts.map(part => {
189
if (part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === toolCallId) {
190
const updated = updater(part.toolCall);
191
if (updated === part.toolCall) {
192
return part;
193
}
194
found = true;
195
return { ...part, toolCall: updated };
196
}
197
return part;
198
});
199
200
if (!found) {
201
return state;
202
}
203
204
return {
205
...state,
206
activeTurn: { ...activeTurn, responseParts },
207
};
208
}
209
210
/**
211
* Immutably updates a response part by `partId` in the active turn.
212
* For markdown/reasoning parts, matches on `id`. For tool call parts,
213
* matches on `toolCall.toolCallId`.
214
*/
215
function updateResponsePart(
216
state: SessionState,
217
turnId: string,
218
partId: string,
219
updater: (part: ResponsePart) => ResponsePart,
220
): SessionState {
221
const activeTurn = state.activeTurn;
222
if (!activeTurn || activeTurn.id !== turnId) {
223
return state;
224
}
225
226
let found = false;
227
const responseParts = activeTurn.responseParts.map(part => {
228
if (!found) {
229
const id = part.kind === ResponsePartKind.ToolCall
230
? part.toolCall.toolCallId
231
: 'id' in part ? part.id : undefined;
232
if (id === partId) {
233
found = true;
234
return updater(part);
235
}
236
}
237
return part;
238
});
239
240
if (!found) {
241
return state;
242
}
243
244
return {
245
...state,
246
activeTurn: { ...activeTurn, responseParts },
247
};
248
}
249
250
// ─── Root Reducer ────────────────────────────────────────────────────────────
251
252
/**
253
* Pure reducer for root state. Handles all {@link RootAction} variants.
254
*/
255
export function rootReducer(state: RootState, action: RootAction, log?: (msg: string) => void): RootState {
256
switch (action.type) {
257
case ActionType.RootAgentsChanged:
258
return { ...state, agents: action.agents };
259
260
case ActionType.RootActiveSessionsChanged:
261
return { ...state, activeSessions: action.activeSessions };
262
263
case ActionType.RootTerminalsChanged:
264
return { ...state, terminals: action.terminals };
265
266
case ActionType.RootConfigChanged:
267
if (!state.config) {
268
return state;
269
}
270
return {
271
...state,
272
config: {
273
...state.config,
274
values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config },
275
},
276
};
277
278
default:
279
softAssertNever(action, log);
280
return state;
281
}
282
}
283
284
// ─── Session Reducer ─────────────────────────────────────────────────────────
285
286
/**
287
* Pure reducer for session state. Handles all {@link SessionAction} variants.
288
*/
289
export function sessionReducer(state: SessionState, action: SessionAction, log?: (msg: string) => void): SessionState {
290
switch (action.type) {
291
// ── Lifecycle ──────────────────────────────────────────────────────────
292
293
case ActionType.SessionReady:
294
return {
295
...state,
296
lifecycle: SessionLifecycle.Ready,
297
summary: { ...state.summary, status: SessionStatus.Idle },
298
};
299
300
case ActionType.SessionCreationFailed:
301
return {
302
...state,
303
lifecycle: SessionLifecycle.CreationFailed,
304
creationError: action.error,
305
};
306
307
// ── Turn Lifecycle ────────────────────────────────────────────────────
308
309
case ActionType.SessionTurnStarted: {
310
let next: SessionState = {
311
...state,
312
activeTurn: {
313
id: action.turnId,
314
userMessage: action.userMessage,
315
responseParts: [],
316
usage: undefined,
317
},
318
};
319
next = {
320
...next,
321
summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() },
322
};
323
324
// If this turn was auto-started from a pending message, remove it
325
if (action.queuedMessageId) {
326
if (next.steeringMessage?.id === action.queuedMessageId) {
327
next = { ...next, steeringMessage: undefined };
328
}
329
if (next.queuedMessages) {
330
const filtered = next.queuedMessages.filter(m => m.id !== action.queuedMessageId);
331
next = { ...next, queuedMessages: filtered.length > 0 ? filtered : undefined };
332
}
333
}
334
335
return next;
336
}
337
338
case ActionType.SessionDelta:
339
return updateResponsePart(state, action.turnId, action.partId, part => {
340
if (part.kind === ResponsePartKind.Markdown) {
341
return { ...part, content: part.content + action.content };
342
}
343
return part;
344
});
345
346
case ActionType.SessionResponsePart:
347
if (!state.activeTurn || state.activeTurn.id !== action.turnId) {
348
return state;
349
}
350
return {
351
...state,
352
activeTurn: {
353
...state.activeTurn,
354
responseParts: [...state.activeTurn.responseParts, action.part],
355
},
356
};
357
358
case ActionType.SessionTurnComplete:
359
return endTurn(state, action.turnId, TurnState.Complete);
360
361
case ActionType.SessionTurnCancelled:
362
return endTurn(state, action.turnId, TurnState.Cancelled);
363
364
case ActionType.SessionError:
365
return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error);
366
367
// ── Tool Call State Machine ───────────────────────────────────────────
368
369
case ActionType.SessionToolCallStart:
370
if (!state.activeTurn || state.activeTurn.id !== action.turnId) {
371
return state;
372
}
373
return {
374
...state,
375
activeTurn: {
376
...state.activeTurn,
377
responseParts: [
378
...state.activeTurn.responseParts,
379
{
380
kind: ResponsePartKind.ToolCall,
381
toolCall: {
382
toolCallId: action.toolCallId,
383
toolName: action.toolName,
384
displayName: action.displayName,
385
toolClientId: action.toolClientId,
386
_meta: action._meta,
387
status: ToolCallStatus.Streaming,
388
},
389
} satisfies ToolCallResponsePart,
390
],
391
},
392
};
393
394
case ActionType.SessionToolCallDelta:
395
return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
396
if (tc.status !== ToolCallStatus.Streaming) {
397
return tc;
398
}
399
return {
400
...tc,
401
partialInput: (tc.partialInput ?? '') + action.content,
402
invocationMessage: action.invocationMessage ?? tc.invocationMessage,
403
};
404
});
405
406
case ActionType.SessionToolCallReady:
407
return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
408
if (tc.status !== ToolCallStatus.Streaming && tc.status !== ToolCallStatus.Running) {
409
return tc;
410
}
411
const base = tcBase(tc);
412
if (action.confirmed) {
413
return {
414
status: ToolCallStatus.Running,
415
...base,
416
invocationMessage: action.invocationMessage,
417
toolInput: action.toolInput,
418
confirmed: action.confirmed,
419
};
420
}
421
return {
422
status: ToolCallStatus.PendingConfirmation,
423
...base,
424
invocationMessage: action.invocationMessage,
425
toolInput: action.toolInput,
426
confirmationTitle: action.confirmationTitle,
427
edits: action.edits,
428
editable: action.editable,
429
...(action.options ? { options: action.options } : {}),
430
};
431
}));
432
433
case ActionType.SessionToolCallConfirmed:
434
return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
435
if (tc.status !== ToolCallStatus.PendingConfirmation) {
436
return tc;
437
}
438
const base = tcBase(tc);
439
const selectedOption = resolveSelectedOption(tc.options, action.selectedOptionId);
440
if (action.approved) {
441
return {
442
status: ToolCallStatus.Running,
443
...base,
444
invocationMessage: tc.invocationMessage,
445
toolInput: action.editedToolInput ?? tc.toolInput,
446
confirmed: action.confirmed,
447
...(selectedOption ? { selectedOption } : {}),
448
};
449
}
450
return {
451
status: ToolCallStatus.Cancelled,
452
...base,
453
invocationMessage: tc.invocationMessage,
454
toolInput: tc.toolInput,
455
reason: action.reason,
456
reasonMessage: action.reasonMessage,
457
userSuggestion: action.userSuggestion,
458
...(selectedOption ? { selectedOption } : {}),
459
};
460
}));
461
462
case ActionType.SessionToolCallComplete:
463
return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
464
if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation) {
465
return tc;
466
}
467
const base = tcBase(tc);
468
const confirmed = tc.status === ToolCallStatus.Running
469
? tc.confirmed
470
: ToolCallConfirmationReason.NotNeeded;
471
const selectedOption = tc.status === ToolCallStatus.Running
472
? tc.selectedOption
473
: undefined;
474
if (action.requiresResultConfirmation) {
475
return {
476
status: ToolCallStatus.PendingResultConfirmation,
477
...base,
478
invocationMessage: tc.invocationMessage,
479
toolInput: tc.toolInput,
480
confirmed,
481
...(selectedOption ? { selectedOption } : {}),
482
...action.result,
483
};
484
}
485
return {
486
status: ToolCallStatus.Completed,
487
...base,
488
invocationMessage: tc.invocationMessage,
489
toolInput: tc.toolInput,
490
confirmed,
491
...(selectedOption ? { selectedOption } : {}),
492
...action.result,
493
};
494
}));
495
496
case ActionType.SessionToolCallResultConfirmed:
497
return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
498
if (tc.status !== ToolCallStatus.PendingResultConfirmation) {
499
return tc;
500
}
501
const base = tcBase(tc);
502
if (action.approved) {
503
return {
504
status: ToolCallStatus.Completed,
505
...base,
506
invocationMessage: tc.invocationMessage,
507
toolInput: tc.toolInput,
508
confirmed: tc.confirmed,
509
...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}),
510
success: tc.success,
511
pastTenseMessage: tc.pastTenseMessage,
512
content: tc.content,
513
structuredContent: tc.structuredContent,
514
error: tc.error,
515
};
516
}
517
return {
518
status: ToolCallStatus.Cancelled,
519
...base,
520
invocationMessage: tc.invocationMessage,
521
toolInput: tc.toolInput,
522
reason: ToolCallCancellationReason.ResultDenied,
523
...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}),
524
};
525
}));
526
527
case ActionType.SessionToolCallContentChanged:
528
return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
529
if (tc.status !== ToolCallStatus.Running) {
530
return tc;
531
}
532
return {
533
...tc,
534
content: action.content,
535
};
536
});
537
538
// ── Metadata ──────────────────────────────────────────────────────────
539
540
case ActionType.SessionTitleChanged:
541
return {
542
...state,
543
summary: { ...state.summary, title: action.title, modifiedAt: Date.now() },
544
};
545
546
case ActionType.SessionUsage:
547
if (!state.activeTurn || state.activeTurn.id !== action.turnId) {
548
return state;
549
}
550
return {
551
...state,
552
activeTurn: { ...state.activeTurn, usage: action.usage },
553
};
554
555
case ActionType.SessionReasoning:
556
return updateResponsePart(state, action.turnId, action.partId, part => {
557
if (part.kind === ResponsePartKind.Reasoning) {
558
return { ...part, content: part.content + action.content };
559
}
560
return part;
561
});
562
563
case ActionType.SessionModelChanged:
564
return {
565
...state,
566
summary: { ...state.summary, model: action.model, modifiedAt: Date.now() },
567
};
568
569
case ActionType.SessionIsReadChanged:
570
return {
571
...state,
572
summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsRead, action.isRead) },
573
};
574
575
case ActionType.SessionIsArchivedChanged:
576
return {
577
...state,
578
summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsArchived, action.isArchived) },
579
};
580
581
case ActionType.SessionActivityChanged:
582
return {
583
...state,
584
summary: { ...state.summary, activity: action.activity },
585
};
586
587
case ActionType.SessionDiffsChanged:
588
return {
589
...state,
590
summary: { ...state.summary, diffs: action.diffs },
591
};
592
593
case ActionType.SessionConfigChanged:
594
if (!state.config) {
595
return state;
596
}
597
return {
598
...state,
599
config: {
600
...state.config,
601
values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config },
602
},
603
summary: {
604
...state.summary,
605
modifiedAt: Date.now(),
606
},
607
};
608
609
case ActionType.SessionMetaChanged:
610
return { ...state, _meta: action._meta };
611
612
case ActionType.SessionServerToolsChanged:
613
return { ...state, serverTools: action.tools };
614
615
case ActionType.SessionActiveClientChanged:
616
return {
617
...state,
618
activeClient: action.activeClient ?? undefined,
619
};
620
621
case ActionType.SessionActiveClientToolsChanged:
622
if (!state.activeClient) {
623
return state;
624
}
625
return {
626
...state,
627
activeClient: { ...state.activeClient, tools: action.tools },
628
};
629
630
// ── Customizations ──────────────────────────────────────────────────
631
632
case ActionType.SessionCustomizationsChanged:
633
return { ...state, customizations: action.customizations };
634
635
case ActionType.SessionCustomizationToggled: {
636
const list = state.customizations;
637
if (!list) {
638
return state;
639
}
640
const idx = list.findIndex(c => c.customization.uri === action.uri);
641
if (idx < 0) {
642
return state;
643
}
644
const updated = [...list];
645
updated[idx] = { ...list[idx], enabled: action.enabled };
646
return { ...state, customizations: updated };
647
}
648
649
// ── Truncation ────────────────────────────────────────────────────────
650
651
case ActionType.SessionTruncated: {
652
let turns: typeof state.turns;
653
if (action.turnId === undefined) {
654
turns = [];
655
} else {
656
const idx = state.turns.findIndex(t => t.id === action.turnId);
657
if (idx < 0) {
658
return state;
659
}
660
turns = state.turns.slice(0, idx + 1);
661
}
662
const next: SessionState = {
663
...state,
664
turns,
665
activeTurn: undefined,
666
summary: { ...state.summary, modifiedAt: Date.now() },
667
};
668
delete next.inputRequests;
669
return {
670
...next,
671
summary: { ...next.summary, status: summaryStatus(next) },
672
};
673
}
674
675
// ── Session Input Requests ─────────────────────────────────────────────
676
677
case ActionType.SessionInputRequested:
678
return upsertInputRequest(state, action.request);
679
680
case ActionType.SessionInputAnswerChanged: {
681
const existing = state.inputRequests;
682
const idx = existing?.findIndex(request => request.id === action.requestId) ?? -1;
683
if (!existing || idx < 0) {
684
return state;
685
}
686
const request = existing[idx];
687
const answers = { ...(request.answers ?? {}) };
688
if (action.answer === undefined) {
689
delete answers[action.questionId];
690
} else {
691
answers[action.questionId] = action.answer;
692
}
693
const updated = [...existing];
694
updated[idx] = {
695
...request,
696
answers: Object.keys(answers).length > 0 ? answers : undefined,
697
};
698
return {
699
...state,
700
inputRequests: updated,
701
summary: { ...state.summary, modifiedAt: Date.now() },
702
};
703
}
704
705
case ActionType.SessionInputCompleted: {
706
const existing = state.inputRequests;
707
if (!existing?.some(request => request.id === action.requestId)) {
708
return state;
709
}
710
const inputRequests = existing.filter(request => request.id !== action.requestId);
711
const next: SessionState = {
712
...state,
713
};
714
if (inputRequests.length > 0) {
715
next.inputRequests = inputRequests;
716
} else {
717
delete next.inputRequests;
718
}
719
return {
720
...next,
721
summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now() },
722
};
723
}
724
725
// ── Pending Messages ──────────────────────────────────────────────────
726
727
case ActionType.SessionPendingMessageSet: {
728
const entry: PendingMessage = { id: action.id, userMessage: action.userMessage };
729
if (action.kind === PendingMessageKind.Steering) {
730
return { ...state, steeringMessage: entry };
731
}
732
const existing = state.queuedMessages ?? [];
733
const idx = existing.findIndex(m => m.id === action.id);
734
if (idx >= 0) {
735
const updated = [...existing];
736
updated[idx] = entry;
737
return { ...state, queuedMessages: updated };
738
}
739
return { ...state, queuedMessages: [...existing, entry] };
740
}
741
742
case ActionType.SessionPendingMessageRemoved: {
743
if (action.kind === PendingMessageKind.Steering) {
744
if (!state.steeringMessage || state.steeringMessage.id !== action.id) {
745
return state;
746
}
747
return { ...state, steeringMessage: undefined };
748
}
749
const existing = state.queuedMessages;
750
if (!existing) {
751
return state;
752
}
753
const filtered = existing.filter(m => m.id !== action.id);
754
return filtered.length === existing.length
755
? state
756
: { ...state, queuedMessages: filtered.length > 0 ? filtered : undefined };
757
}
758
759
case ActionType.SessionQueuedMessagesReordered: {
760
const existing = state.queuedMessages;
761
if (!existing) {
762
return state;
763
}
764
const byId = new Map(existing.map(m => [m.id, m]));
765
const ordered = new Set<string>();
766
const reordered = action.order
767
.filter(id => {
768
if (byId.has(id) && !ordered.has(id)) {
769
ordered.add(id);
770
return true;
771
}
772
return false;
773
})
774
.map(id => byId.get(id)!);
775
// Append any messages not mentioned in order, preserving original order
776
for (const m of existing) {
777
if (!ordered.has(m.id)) {
778
reordered.push(m);
779
}
780
}
781
return { ...state, queuedMessages: reordered };
782
}
783
784
default:
785
softAssertNever(action, log);
786
return state;
787
}
788
}
789
790
// ─── Terminal Reducer ────────────────────────────────────────────────────────
791
792
/**
793
* Pure reducer for terminal state. Handles all {@link TerminalAction} variants.
794
*/
795
export function terminalReducer(state: TerminalState, action: TerminalAction, log?: (msg: string) => void): TerminalState {
796
switch (action.type) {
797
case ActionType.TerminalData: {
798
const content = [...state.content];
799
const tail = content.length > 0 ? content[content.length - 1] : undefined;
800
if (tail && tail.type === 'command' && !tail.isComplete) {
801
content[content.length - 1] = { ...tail, output: tail.output + action.data };
802
} else if (tail && tail.type === 'unclassified') {
803
content[content.length - 1] = { ...tail, value: tail.value + action.data };
804
} else {
805
content.push({ type: 'unclassified', value: action.data });
806
}
807
return { ...state, content };
808
}
809
810
case ActionType.TerminalInput:
811
// Side-effect-only: the server forwards to the pty.
812
// No state change in the reducer.
813
return state;
814
815
case ActionType.TerminalResized:
816
return { ...state, cols: action.cols, rows: action.rows };
817
818
case ActionType.TerminalClaimed:
819
return { ...state, claim: action.claim };
820
821
case ActionType.TerminalTitleChanged:
822
return { ...state, title: action.title };
823
824
case ActionType.TerminalCwdChanged:
825
return { ...state, cwd: action.cwd };
826
827
case ActionType.TerminalExited:
828
return { ...state, exitCode: action.exitCode };
829
830
case ActionType.TerminalCleared:
831
return { ...state, content: [] };
832
833
case ActionType.TerminalCommandDetectionAvailable:
834
return { ...state, supportsCommandDetection: true };
835
836
case ActionType.TerminalCommandExecuted: {
837
const part: TerminalContentPart = {
838
type: 'command',
839
commandId: action.commandId,
840
commandLine: action.commandLine,
841
output: '',
842
timestamp: action.timestamp,
843
isComplete: false,
844
};
845
return {
846
...state,
847
content: [...state.content, part],
848
supportsCommandDetection: true,
849
};
850
}
851
852
case ActionType.TerminalCommandFinished: {
853
const content = state.content.map(p => {
854
if (p.type === 'command' && p.commandId === action.commandId) {
855
return {
856
...p,
857
isComplete: true as const,
858
exitCode: action.exitCode,
859
durationMs: action.durationMs,
860
};
861
}
862
return p;
863
});
864
return { ...state, content };
865
}
866
867
default:
868
softAssertNever(action, log);
869
return state;
870
}
871
}
872
873
// ─── Dispatch Validation ─────────────────────────────────────────────────────
874
875
/**
876
* Type guard that checks whether an action may be dispatched by a client.
877
*
878
* Servers SHOULD call this to validate incoming `dispatchAction` requests
879
* and reject any action the client is not allowed to originate.
880
*/
881
export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction {
882
return IS_CLIENT_DISPATCHABLE[action.type];
883
}
884
885