feat: render thinking process UI for intermediate agent messages

Wire message-reclassified sidecar events through the main process to
the renderer, where reclassified messages are filtered out of the main
transcript and collected into a collapsible ThinkingProcess component.

Main process changes:
- Route message-reclassified via dedicated onMessageReclassified callback
- Add applyMessageReclassified handler that sets messageKind and emits
  the session event for the renderer
- Forward assistant-intent and reasoning-delta as turn-scoped events

Renderer changes:
- Split session.messages into visibleMessages and thinkingMessages
- Render ThinkingProcess above the last assistant message
- ThinkingProcess auto-expands during active turns, collapses on finish
- Update messagePhase to skip thinking-kind messages for final detection

Tests:
- 3 new sessionWorkspace tests for reclassification apply/dedup/ignore
- 2 new messagePhase tests for thinking-kind handling
- Updated runTurnPending test fixtures for new callback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-30 16:16:37 +01:00
co-authored by Copilot
parent 56de8b7bd6
commit deb5c96d58
11 changed files with 464 additions and 90 deletions
+29
View File
@@ -10,6 +10,7 @@ import type {
ExitPlanModeRequestedEvent,
MessageMode,
McpOauthRequiredEvent,
MessageReclassifiedEvent,
RunTurnCustomAgentConfig,
RunTurnToolingConfig,
SidecarCapabilities,
@@ -1364,6 +1365,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
async (event) => {
await this.handleExitPlanModeRequested(workspace, session.id, event);
},
async (event) => {
await this.applyMessageReclassified(workspace, session.id, event);
},
async (event) => {
await this.handleTurnScopedEvent(workspace, session.id, event);
},
@@ -1715,6 +1719,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
}
private async applyMessageReclassified(
workspace: WorkspaceState,
sessionId: string,
event: MessageReclassifiedEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const message = session.messages.find((m) => m.id === event.messageId);
if (!message || message.messageKind === 'thinking') {
return;
}
message.messageKind = 'thinking';
const occurredAt = nowIso();
session.updatedAt = occurredAt;
await this.workspaceRepository.save(workspace);
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: event.messageId,
messageKind: 'thinking',
});
}
private async applyAgentActivity(
workspace: WorkspaceState,
sessionId: string,
+7 -1
View File
@@ -3,6 +3,7 @@ import type {
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
MessageReclassifiedEvent,
TurnDeltaEvent,
UserInputRequestedEvent,
SubagentEvent,
@@ -12,6 +13,8 @@ import type {
SessionCompactionEvent,
PendingMessagesModifiedEvent,
AssistantUsageEvent,
AssistantIntentEvent,
ReasoningDeltaEvent,
} from '@shared/contracts/sidecar';
import type { ChatMessageRecord } from '@shared/domain/session';
@@ -22,7 +25,9 @@ export type TurnScopedEvent =
| SessionUsageEvent
| SessionCompactionEvent
| PendingMessagesModifiedEvent
| AssistantUsageEvent;
| AssistantUsageEvent
| AssistantIntentEvent
| ReasoningDeltaEvent;
export interface RunTurnPendingCommand {
kind: 'run-turn';
@@ -34,6 +39,7 @@ export interface RunTurnPendingCommand {
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>;
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
errored: boolean;
}
+12 -1
View File
@@ -9,6 +9,7 @@ import type {
SidecarCapabilities,
SidecarEvent,
TurnDeltaEvent,
MessageReclassifiedEvent,
UserInputRequestedEvent,
McpOauthRequiredEvent,
ExitPlanModeRequestedEvent,
@@ -134,9 +135,10 @@ export class SidecarClient {
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent);
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onMessageReclassified, onTurnScopedEvent);
}
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
@@ -286,6 +288,7 @@ export class SidecarClient {
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified?: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<TResult> {
const state = await this.ensureProcess();
@@ -303,6 +306,7 @@ export class SidecarClient {
onUserInput: onUserInput ?? (() => undefined),
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
onExitPlanMode: onExitPlanMode ?? (() => undefined),
onMessageReclassified: onMessageReclassified ?? (() => undefined),
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
errored: false,
});
@@ -439,6 +443,11 @@ export class SidecarClient {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
}
return;
case 'message-reclassified':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMessageReclassified(event));
}
return;
case 'subagent-event':
case 'skill-invoked':
case 'hook-lifecycle':
@@ -446,6 +455,8 @@ export class SidecarClient {
case 'session-compaction':
case 'pending-messages-modified':
case 'assistant-usage':
case 'assistant-intent':
case 'reasoning-delta':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
}
+124 -86
View File
@@ -12,6 +12,7 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { ApprovalDecision } from '@shared/domain/approval';
@@ -115,12 +116,28 @@ export function ChatPane({
const composerRef = useRef<MarkdownComposerHandle>(null);
const isSessionBusy = session.status === 'running';
const { visibleMessages, thinkingMessages } = useMemo(() => {
const visible: typeof session.messages = [];
const thinking: typeof session.messages = [];
for (const message of session.messages) {
if (message.messageKind === 'thinking') {
thinking.push(message);
} else {
visible.push(message);
}
}
return { visibleMessages: visible, thinkingMessages: thinking };
}, [session.messages]);
const lastAssistantIndex = useMemo(() => {
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].role === 'assistant') return i;
for (let i = visibleMessages.length - 1; i >= 0; i--) {
if (visibleMessages[i].role === 'assistant') return i;
}
return -1;
}, [session.messages]);
}, [visibleMessages]);
const turnStartedAt = useMemo(() => {
if (session.runs.length === 0) return undefined;
return session.runs[0].startedAt;
}, [session.runs]);
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
@@ -358,7 +375,7 @@ export function ChatPane({
/>
)}
<div className="space-y-1">
{session.messages.map((message, index) => {
{visibleMessages.map((message, index) => {
const isUser = message.role === 'user';
const isEditing = editingMessageId === message.id;
const isLastAssistant = index === lastAssistantIndex;
@@ -376,101 +393,122 @@ export function ChatPane({
const phaseLabel =
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
const showActions = !isSessionBusy && !message.pending;
const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0;
return (
<div className="message-enter group py-3" data-message-id={message.id} key={message.id}>
<div className="flex gap-3">
<div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
isUser ? 'brand-gradient-bg text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]'
}`}
>
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
<div key={message.id}>
{showThinkingBefore && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
<span>{message.authorName}</span>
{message.isPinned && (
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
)}
{!isUser && phaseLabel && (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
>
{phaseLabel}
</span>
)}
{showActions && (
<div className="ml-auto">
<MessageActions
message={message}
isLastAssistant={isLastAssistant}
onCopy={() => handleCopyMessage(message.content)}
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
onBranch={() => onBranchFromMessage?.(message.id)}
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
/>
</div>
)}
)}
<div className="message-enter group py-3" data-message-id={message.id}>
<div className="flex gap-3">
<div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
isUser ? 'brand-gradient-bg text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]'
}`}
>
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
</div>
{/* Edit mode */}
{isEditing ? (
<MessageEditComposer
initialContent={message.content}
onSave={(content) => handleEditSave(message.id, content)}
onCancel={() => setEditingMessageId(undefined)}
/>
) : (
<div
className={
isUser
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
}
>
{/* Attachment thumbnails */}
{isUser && message.attachments && message.attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, attIdx) =>
isImageAttachment(att) ? (
<img
key={attIdx}
alt={getAttachmentDisplayName(att)}
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
src={`data:${att.mimeType};base64,${att.data}`}
/>
) : (
<div
key={attIdx}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
<Paperclip className="size-3" />
{getAttachmentDisplayName(att)}
</div>
),
)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
<span>{message.authorName}</span>
{message.isPinned && (
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
)}
{!isUser && message.pending ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
{message.content}
</div>
) : (
<MarkdownContent content={message.content} />
{!isUser && phaseLabel && (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
>
{phaseLabel}
</span>
)}
{message.pending && message.content && (
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
{showActions && (
<div className="ml-auto">
<MessageActions
message={message}
isLastAssistant={isLastAssistant}
onCopy={() => handleCopyMessage(message.content)}
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
onBranch={() => onBranchFromMessage?.(message.id)}
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
/>
</div>
)}
</div>
)}
{message.pending && !message.content && <ThinkingDots />}
{/* Edit mode */}
{isEditing ? (
<MessageEditComposer
initialContent={message.content}
onSave={(content) => handleEditSave(message.id, content)}
onCancel={() => setEditingMessageId(undefined)}
/>
) : (
<div
className={
isUser
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
}
>
{/* Attachment thumbnails */}
{isUser && message.attachments && message.attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, attIdx) =>
isImageAttachment(att) ? (
<img
key={attIdx}
alt={getAttachmentDisplayName(att)}
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
src={`data:${att.mimeType};base64,${att.data}`}
/>
) : (
<div
key={attIdx}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
<Paperclip className="size-3" />
{getAttachmentDisplayName(att)}
</div>
),
)}
</div>
)}
{!isUser && message.pending ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
{message.content}
</div>
) : (
<MarkdownContent content={message.content} />
)}
{message.pending && message.content && (
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
)}
</div>
)}
{message.pending && !message.content && <ThinkingDots />}
</div>
</div>
</div>
</div>
);
})}
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
)}
</div>
{activeSubagents && activeSubagents.length > 0 && (
<div className="px-6 py-1">
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import type { ChatMessageRecord } from '@shared/domain/session';
interface ThinkingProcessProps {
messages: ChatMessageRecord[];
isActive: boolean;
turnStartedAt?: string;
}
export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingProcessProps) {
const [expanded, setExpanded] = useState(false);
const wasActiveRef = useRef(isActive);
// Auto-expand when the turn is active and thinking messages appear.
// Auto-collapse once the turn finishes.
useEffect(() => {
if (isActive && messages.length > 0) {
setExpanded(true);
} else if (wasActiveRef.current && !isActive) {
setExpanded(false);
}
wasActiveRef.current = isActive;
}, [isActive, messages.length]);
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
const elapsed = useMemo(() => {
if (!turnStartedAt || messages.length === 0) return undefined;
const start = new Date(turnStartedAt).getTime();
const lastMessage = messages[messages.length - 1];
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 2) return undefined;
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
}, [turnStartedAt, messages, isActive]);
if (messages.length === 0) {
return null;
}
const stepCount = messages.length;
const summaryParts: string[] = [];
if (elapsed) summaryParts.push(`${elapsed}`);
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
return (
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
<button
type="button"
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Brain className="size-3.5 shrink-0 text-[var(--color-accent-purple)]" />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className="text-[var(--color-text-secondary)]">Thinking</span>
<ThinkingPulse />
</span>
) : (
<span className="text-[var(--color-text-secondary)]">
Thought for {summaryParts.join(' · ')}
</span>
)}
<span className="ml-auto shrink-0">
{expanded
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
</span>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-1.5">
{messages.map((message) => (
<ThinkingStep key={message.id} message={message} />
))}
</div>
</div>
)}
</div>
);
}
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
return (
<div className="flex gap-2 text-[12px] leading-relaxed">
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]"></span>
<div className="min-w-0">
{message.authorName && (
<span className="mr-1.5 font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[var(--color-text-muted)]">{preview}</span>
</div>
</div>
);
}
function ThinkingPulse() {
return (
<span className="inline-flex items-center gap-0.5">
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
</span>
);
}
function truncatePreview(text: string, maxLength: number): string {
const firstLine = text.split('\n')[0] ?? '';
const cleaned = firstLine.trim();
if (cleaned.length <= maxLength) return cleaned;
return `${cleaned.slice(0, maxLength)}`;
}
+5 -1
View File
@@ -11,6 +11,10 @@ export function getAssistantMessagePhase(
return 'default';
}
if (message.messageKind === 'thinking') {
return 'default';
}
if (message.pending) {
return 'thinking';
}
@@ -26,7 +30,7 @@ export function getAssistantMessagePhase(
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role === 'assistant' && !message.pending) {
if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') {
return index;
}
}
+26
View File
@@ -41,6 +41,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
return applyMessageDeltaEvent(session, event);
case 'message-complete':
return applyMessageCompleteEvent(session, event);
case 'message-reclassified':
return applyMessageReclassifiedEvent(session, event);
case 'run-updated':
return applyRunUpdatedEvent(session, event);
default:
@@ -172,6 +174,30 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe
};
}
function applyMessageReclassifiedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
if (!event.messageId || !event.messageKind) {
return session;
}
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
if (messageIndex < 0) {
return session;
}
const existing = session.messages[messageIndex];
if (existing.messageKind === event.messageKind) {
return session;
}
const nextMessages = session.messages.slice();
nextMessages[messageIndex] = { ...existing, messageKind: event.messageKind };
return {
...session,
messages: nextMessages,
updatedAt: event.occurredAt,
};
}
function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
if (!event.run) {
return session;
+15 -1
View File
@@ -626,6 +626,19 @@ body {
animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── Thinking process section ────────────────────────────────── */
@keyframes thinking-process-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.thinking-process-enter {
animation: thinking-process-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* ── Respect reduced motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
@@ -637,7 +650,8 @@ body {
.message-enter,
.msg-actions-enter,
.session-item-enter,
.banner-slide-enter {
.banner-slide-enter,
.thinking-process-enter {
animation: none;
}
}
+2
View File
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined,
onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined,
onMessageReclassified: () => undefined,
onTurnScopedEvent: () => undefined,
errored: false,
};
@@ -45,6 +46,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined,
onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined,
onMessageReclassified: () => undefined,
onTurnScopedEvent: () => undefined,
errored: false,
};
+55
View File
@@ -85,4 +85,59 @@ describe('assistant message phase', () => {
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
});
test('returns default for thinking-kind messages regardless of other state', () => {
const session = createSession([
{
id: 'msg-1',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Let me search...',
createdAt: '2026-03-23T00:00:00.000Z',
messageKind: 'thinking',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Here is the result.',
createdAt: '2026-03-23T00:00:01.000Z',
},
]);
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final');
});
test('skips thinking messages when determining the last completed assistant', () => {
const session = createSession([
{
id: 'msg-1',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Let me search...',
createdAt: '2026-03-23T00:00:00.000Z',
messageKind: 'thinking',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Primary Agent',
content: 'More searching...',
createdAt: '2026-03-23T00:00:01.000Z',
messageKind: 'thinking',
},
{
id: 'msg-3',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Final answer.',
createdAt: '2026-03-23T00:00:02.000Z',
},
]);
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('default');
expect(getAssistantMessagePhase(session, session.messages[2], 2)).toBe('final');
});
});
+69
View File
@@ -281,4 +281,73 @@ describe('session workspace helpers', () => {
} satisfies SessionEventRecord),
).toBe(workspace);
});
test('reclassifies a message as thinking when message-reclassified event arrives', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Let me search...',
content: 'Let me search...',
} satisfies SessionEventRecord);
const reclassified = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(reclassified?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
messageKind: 'thinking',
});
});
test('ignores message-reclassified for an already reclassified message', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Let me search...',
content: 'Let me search...',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
const duplicate = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
// Should return the same reference (no change)
expect(duplicate).toBe(workspace);
});
test('ignores message-reclassified for unknown message ids', () => {
const workspace = createWorkspace();
const result = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'nonexistent',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(result).toBe(workspace);
});
});