diff --git a/biome.jsonc b/biome.jsonc index 7afbd8a7..53431934 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -60,11 +60,11 @@ }, { "path": "./scripts/grit/import-boundaries/no-app-server-protocol-boundary-imports.grit", - "includes": ["**/src/**/*.{ts,tsx}", "!**/src/app-server/**", "!**/src/features/chat/app-server/mappers/message-stream/turn-items.ts"] + "includes": ["**/src/**/*.{ts,tsx}", "!**/src/app-server/**", "!**/src/features/chat/app-server/mappers/thread-stream/turn-items.ts"] }, { "path": "./scripts/grit/import-boundaries/no-chat-turn-item-non-turn-protocol-imports.grit", - "includes": ["**/src/features/chat/app-server/mappers/message-stream/turn-items.ts"] + "includes": ["**/src/features/chat/app-server/mappers/thread-stream/turn-items.ts"] }, { "path": "./scripts/grit/import-boundaries/no-generated-app-server-boundary-imports.grit", diff --git a/docs/design.md b/docs/design.md index 7707c33f..f82a9b2c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -36,7 +36,7 @@ Do not hide complexity behind forwarding layers. Add an abstraction only when it ## UI Ownership -Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, message stream, composer, and request controls. +Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, thread stream, composer, and request controls. Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules. @@ -56,7 +56,7 @@ Selection rewrite is intentionally scoped to a focused edit-and-review workflow. Server requests should become panel UI only when the user can naturally answer them in context. Unknown or unsupported requests should stay diagnostic instead of pretending to be normal conversation text. -Message stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history, streaming, and rendering updates. +Thread stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history, streaming, and rendering updates. Codex Panel UI should feel native inside Obsidian. Prefer Obsidian variables, standard classes, and side-panel patterns. Add custom visual treatment only when Codex-specific state would otherwise be hard to read. diff --git a/docs/development.md b/docs/development.md index dac7f7eb..4c452500 100644 --- a/docs/development.md +++ b/docs/development.md @@ -70,7 +70,7 @@ Prefer functions and factory-created objects over classes. Use a class only when - Build before Obsidian validation. Obsidian loads ignored root assets, not the TypeScript or authored CSS sources directly. - Preserve last-known-good app-server state on refresh failure. Do not turn disconnected reads into authoritative empty thread lists, settings snapshots, hook inventories, or diagnostics. - Normalize optional and nullable app-server values before display. Users should not see raw `undefined`, `null`, protocol enum gaps, or fallback labels that imply Panel owns a Codex runtime setting. -- Do not use DOM order as message history state. Message stream DOM is a presentation surface, and delayed Markdown rendering can change heights after initial render. +- Do not use DOM order as thread history state. Thread stream DOM is a presentation surface, and delayed Markdown rendering can change heights after initial render. ## API Baselines diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 7f699a3d..fda64fce 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -16,17 +16,17 @@ import { import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; -import { activeTurnId } from "../../application/conversation/turn-state"; import type { LocalIdSource } from "../../application/local-id-source"; import type { ChatAction, ChatState } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; -import { createStructuredSystemItem, createSystemItem } from "../../domain/message-stream/factories/system-items"; -import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; +import { activeTurnId } from "../../application/turns/turn-state"; import { createApprovalResultItem, createMcpElicitationResultItem, createUserInputResultItem, } from "../../domain/pending-requests/result-items"; +import { createStructuredSystemItem, createSystemItem } from "../../domain/thread-stream/factories/system-items"; +import type { ThreadStreamNoticeSection } from "../../domain/thread-stream/items"; import { classifyAppServerLog } from "./app-server-logs"; import { type ChatNotificationEffect, planChatNotification } from "./notification-plan"; @@ -49,7 +49,7 @@ export interface ChatInboundHandler { cancelUserInput(requestId: PendingRequestId): void; resolveMcpElicitation(requestId: PendingRequestId, action: McpElicitationAction): void; addSystemMessage(text: string): void; - addStructuredSystemMessage(text: string, details: MessageStreamNoticeSection[]): void; + addStructuredSystemMessage(text: string, details: ThreadStreamNoticeSection[]): void; addDedupedSystemMessage(text: string): void; } @@ -213,18 +213,18 @@ function pendingUserInput(context: ChatInboundHandlerContext, requestId: Pending } function addSystemMessage(context: ChatInboundHandlerContext, text: string): void { - dispatch(context, { type: "message-stream/system-item-added", item: createSystemItem(localItemId(context, "system"), text) }); + dispatch(context, { type: "thread-stream/system-item-added", item: createSystemItem(localItemId(context, "system"), text) }); } -function addStructuredSystemMessage(context: ChatInboundHandlerContext, text: string, details: MessageStreamNoticeSection[]): void { +function addStructuredSystemMessage(context: ChatInboundHandlerContext, text: string, details: ThreadStreamNoticeSection[]): void { dispatch(context, { - type: "message-stream/system-item-added", + type: "thread-stream/system-item-added", item: createStructuredSystemItem(localItemId(context, "system"), text, details), }); } function addDedupedSystemMessage(context: ChatInboundHandlerContext, text: string): void { - dispatch(context, { type: "message-stream/deduped-log-added", text, item: createSystemItem(localItemId(context, "system"), text) }); + dispatch(context, { type: "thread-stream/deduped-log-added", text, item: createSystemItem(localItemId(context, "system"), text) }); } function rejectServerRequest(context: ChatInboundHandlerContext, request: ServerRequest, message: string): void { diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 5bbd2642..18d00cae 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -4,21 +4,21 @@ import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/met import { normalizeExplicitThreadName } from "../../../../domain/threads/model"; import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; -import { type ConversationRuntimeOutcome, planConversationRuntimeEvents } from "../../application/conversation/runtime-event-plan"; import { activeThreadSettingsAppliedAction } from "../../application/state/actions"; import type { ChatAction, ChatState } from "../../application/state/root-reducer"; -import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; +import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan"; +import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items"; import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing"; -import { conversationRuntimeEventsFromNotification } from "./runtime-events"; +import { turnRuntimeEventsFromNotification } from "./runtime-events"; export type ChatNotificationEffect = | { type: "refresh-threads" } - | { type: "maybe-name-thread"; threadId: string; turnId: string; completedSummary: ConversationRuntimeCompletedSummary } + | { type: "maybe-name-thread"; threadId: string; turnId: string; completedSummary: TurnRuntimeCompletedSummary } | { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean } | { type: "apply-app-server-resource-event"; event: AppServerResourceEvent } | { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent }; -type ConversationRuntimeCompletedSummary = Extract["completedSummary"]; +type TurnRuntimeCompletedSummary = Extract["completedSummary"]; export interface ChatNotificationPlan { actions: readonly ChatAction[]; @@ -57,28 +57,28 @@ export function planChatNotification( function runtimeEventsPlan( state: ChatState, - notification: Parameters[0], + notification: Parameters[0], localItemId: LocalItemIdProvider, ): ChatNotificationPlan { - const plan = planConversationRuntimeEvents(state, conversationRuntimeEventsFromNotification(notification, localItemId)); - return { actions: plan.actions, effects: plan.outcomes.flatMap(chatNotificationEffectsFromConversationRuntimeOutcome) }; + const plan = planTurnRuntimeEvents(state, turnRuntimeEventsFromNotification(notification, localItemId)); + return { actions: plan.actions, effects: plan.outcomes.flatMap(chatNotificationEffectsFromTurnRuntimeOutcome) }; } -function chatNotificationEffectsFromConversationRuntimeOutcome(outcome: ConversationRuntimeOutcome): readonly ChatNotificationEffect[] { +function chatNotificationEffectsFromTurnRuntimeOutcome(outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] { switch (outcome.type) { - case "run-started": + case "turn-started": return [ { type: "apply-thread-catalog-event", event: { type: "thread-touched", threadId: outcome.threadId, recencyAt: outcome.recencyAt }, }, ]; - case "run-completed": + case "turn-completed": return [ { type: "maybe-name-thread", threadId: outcome.threadId, - turnId: outcome.runId, + turnId: outcome.turnId, completedSummary: outcome.completedSummary, }, { type: "refresh-threads" }, @@ -178,7 +178,7 @@ function threadGoalPlan( if (state.activeThread.id !== threadId) return EMPTY_PLAN; const actions: ChatAction[] = [{ type: "active-thread/goal-set", goal }]; const item = goalChangeItem(localItemId("goal"), state.activeThread.goal, goal); - if (item) actions.push({ type: "message-stream/item-upserted", item }); + if (item) actions.push({ type: "thread-stream/item-upserted", item }); return { actions, effects: [] }; } diff --git a/src/features/chat/app-server/inbound/runtime-events.ts b/src/features/chat/app-server/inbound/runtime-events.ts index 905c93ab..ffa269c6 100644 --- a/src/features/chat/app-server/inbound/runtime-events.ts +++ b/src/features/chat/app-server/inbound/runtime-events.ts @@ -1,28 +1,24 @@ import type { ServerNotification } from "../../../../app-server/connection/rpc-messages"; import { jsonPreview } from "../../../../domain/display/json-preview"; -import type { ConversationRuntimeEvent } from "../../application/conversation/runtime-events"; +import type { TurnRuntimeEvent } from "../../application/turns/runtime-events"; import { STREAMED_COMMAND_RUNNING_TEXT, STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT, STREAMED_MCP_PROGRESS_LABEL, -} from "../../domain/message-stream/factories/streaming-items"; -import { createSystemItem } from "../../domain/message-stream/factories/system-items"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; -import { - type AppServerFileChange, - normalizeFileChanges, - streamingFileChangeMessageStreamItem, -} from "../mappers/message-stream/file-changes"; -import { hookRunMessageStreamItem } from "../mappers/message-stream/hook-run-items"; -import { createAutoReviewResultItem, createReviewResultItem } from "../mappers/message-stream/review-result-items"; -import { taskProgressMessageStreamItem } from "../mappers/message-stream/task-progress"; +} from "../../domain/thread-stream/factories/streaming-items"; +import { createSystemItem } from "../../domain/thread-stream/factories/system-items"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import { type AppServerFileChange, normalizeFileChanges, streamingFileChangeThreadStreamItem } from "../mappers/thread-stream/file-changes"; +import { hookRunThreadStreamItem } from "../mappers/thread-stream/hook-run-items"; +import { createAutoReviewResultItem, createReviewResultItem } from "../mappers/thread-stream/review-result-items"; +import { taskProgressThreadStreamItem } from "../mappers/thread-stream/task-progress"; import { type AppServerTurnItem, completedConversationSummaryFromAppServerTurn, - messageStreamItemFromTurnItem, - messageStreamItemsFromTurns, shouldSuppressLifecycleItem, -} from "../mappers/message-stream/turn-items"; + threadStreamItemFromTurnItem, + threadStreamItemsFromTurns, +} from "../mappers/thread-stream/turn-items"; import type { StreamUpdateNotification, TurnLifecycleNotification, UserVisibleNoticeNotification } from "./notification-routing"; const MESSAGE_CONTEXT_COMPACTED = "Context compacted."; @@ -33,17 +29,17 @@ type RuntimeEventSource = | Extract | UserVisibleNoticeNotification; -export function conversationRuntimeEventsFromNotification( +export function turnRuntimeEventsFromNotification( notification: RuntimeEventSource, localItemId: (prefix: string) => string, -): readonly ConversationRuntimeEvent[] { +): readonly TurnRuntimeEvent[] { switch (notification.method) { case "item/agentMessage/delta": return [ { type: "assistantDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, delta: notification.params.delta, completeReasoning: true, }, @@ -53,7 +49,7 @@ export function conversationRuntimeEventsFromNotification( { type: "planDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, delta: notification.params.delta, }, ]; @@ -61,7 +57,7 @@ export function conversationRuntimeEventsFromNotification( return [ { type: "itemUpserted", - item: taskProgressMessageStreamItem(notification.params.turnId, notification.params.explanation, notification.params.plan), + item: taskProgressThreadStreamItem(notification.params.turnId, notification.params.explanation, notification.params.plan), }, ]; case "item/reasoning/summaryTextDelta": @@ -70,7 +66,7 @@ export function conversationRuntimeEventsFromNotification( { type: "textDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, label: "reasoning", delta: notification.params.delta, kind: "reasoning", @@ -81,7 +77,7 @@ export function conversationRuntimeEventsFromNotification( { type: "textDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, label: "reasoning", delta: "", kind: "reasoning", @@ -96,7 +92,7 @@ export function conversationRuntimeEventsFromNotification( { type: "itemOutputDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, delta: notification.params.delta, kind: "command", fallbackText: STREAMED_COMMAND_RUNNING_TEXT, @@ -114,14 +110,14 @@ export function conversationRuntimeEventsFromNotification( { type: "itemOutputDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, delta: notification.params.delta, kind: "fileChange", fallbackText: STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT, }, ]; case "turn/diff/updated": - return [{ type: "turnDiffUpdated", runId: notification.params.turnId, diff: notification.params.diff }]; + return [{ type: "turnDiffUpdated", turnId: notification.params.turnId, diff: notification.params.diff }]; case "hook/started": return hookRunEvents(notification.params.run, notification.params.turnId, "running"); case "hook/completed": @@ -131,7 +127,7 @@ export function conversationRuntimeEventsFromNotification( { type: "toolOutputDelta", itemId: notification.params.itemId, - runId: notification.params.turnId, + turnId: notification.params.turnId, delta: notification.params.message, fallbackLabel: STREAMED_MCP_PROGRESS_LABEL, }, @@ -144,20 +140,20 @@ export function conversationRuntimeEventsFromNotification( case "turn/started": return [ { - type: "runStarted", + type: "turnStarted", threadId: notification.params.threadId, - runId: notification.params.turn.id, + turnId: notification.params.turn.id, recencyAt: notification.params.turn.startedAt, }, ]; case "turn/completed": return [ { - type: "runCompleted", + type: "turnCompleted", threadId: notification.params.threadId, - runId: notification.params.turn.id, + turnId: notification.params.turn.id, status: notification.params.turn.status, - completedItems: messageStreamItemsFromTurns([notification.params.turn]), + completedItems: threadStreamItemsFromTurns([notification.params.turn]), completedSummary: completedConversationSummaryFromAppServerTurn(notification.params.turn), }, ]; @@ -177,35 +173,35 @@ export function conversationRuntimeEventsFromNotification( } } -function startedItemEvents(item: AppServerTurnItem, runId: string): readonly ConversationRuntimeEvent[] { +function startedItemEvents(item: AppServerTurnItem, turnId: string): readonly TurnRuntimeEvent[] { if (shouldSuppressLifecycleItem(item)) return []; - const streamItem = messageStreamItemFromTurnItem(item, runId); + const streamItem = threadStreamItemFromTurnItem(item, turnId); return streamItem ? [{ type: "itemUpserted", item: streamItem }] : []; } -function completedItemEvents(item: AppServerTurnItem, runId: string): readonly ConversationRuntimeEvent[] { +function completedItemEvents(item: AppServerTurnItem, turnId: string): readonly TurnRuntimeEvent[] { if (item.type === "userMessage") return []; - const streamItem = messageStreamItemFromTurnItem(item, runId); - return streamItem ? [{ type: "itemCompleted", runId, item: streamItem }] : []; + const streamItem = threadStreamItemFromTurnItem(item, turnId); + return streamItem ? [{ type: "itemCompleted", turnId, item: streamItem }] : []; } -function fileChangeItem(itemId: string, runId: string, changes: readonly AppServerFileChange[], status: string): MessageStreamItem { - return streamingFileChangeMessageStreamItem(itemId, runId, normalizeFileChanges(changes), status); +function fileChangeItem(itemId: string, turnId: string, changes: readonly AppServerFileChange[], status: string): ThreadStreamItem { + return streamingFileChangeThreadStreamItem(itemId, turnId, normalizeFileChanges(changes), status); } function hookRunEvents( run: Extract["params"]["run"], - runId: string | null, + turnId: string | null, status: string, -): readonly ConversationRuntimeEvent[] { - const item = hookRunMessageStreamItem(run, runId, status); - return item ? [{ type: "hookRunObserved", item, runId, eventName: run.eventName }] : []; +): readonly TurnRuntimeEvent[] { + const item = hookRunThreadStreamItem(run, turnId, status); + return item ? [{ type: "hookRunObserved", item, turnId, eventName: run.eventName }] : []; } function jsonNoticeEvent( notification: Extract }>, localItemId: (prefix: string) => string, -): ConversationRuntimeEvent { +): TurnRuntimeEvent { return { type: "systemNotice", item: createSystemItem(localItemId("system"), `${notification.method}: ${jsonPreview(notification.params)}`), diff --git a/src/features/chat/app-server/mappers/message-stream/agent-items.ts b/src/features/chat/app-server/mappers/thread-stream/agent-items.ts similarity index 83% rename from src/features/chat/app-server/mappers/message-stream/agent-items.ts rename to src/features/chat/app-server/mappers/thread-stream/agent-items.ts index b3040f01..5271524a 100644 --- a/src/features/chat/app-server/mappers/message-stream/agent-items.ts +++ b/src/features/chat/app-server/mappers/thread-stream/agent-items.ts @@ -1,4 +1,4 @@ -import type { AgentMessageStreamItem, AgentStateSummary, ExecutionState } from "../../../domain/message-stream/items"; +import type { AgentStateSummary, AgentThreadStreamItem, ExecutionState } from "../../../domain/thread-stream/items"; import { collabAgentStateExecutionState, type ExecutionStateByStatus, @@ -12,7 +12,7 @@ const STANDARD_TOOL_STATES: ExecutionStateByStatus = { failed: "failed", }; -interface MessageStreamCollabAgentToolCall { +interface ThreadStreamCollabAgentToolCall { id: string; tool: string; status: string; @@ -21,15 +21,15 @@ interface MessageStreamCollabAgentToolCall { prompt: string | null; model: string | null; reasoningEffort: string | null; - agentsStates: Record; + agentsStates: Record; } -interface MessageStreamCollabAgentState { +interface ThreadStreamCollabAgentState { status?: string | null; message?: string | null; } -export function agentMessageStreamItem(item: MessageStreamCollabAgentToolCall, turnId?: string): AgentMessageStreamItem { +export function agentThreadStreamItem(item: ThreadStreamCollabAgentToolCall, turnId?: string): AgentThreadStreamItem { const agents = agentStatesDisplay(item.agentsStates); return { id: item.id, @@ -49,7 +49,7 @@ export function agentMessageStreamItem(item: MessageStreamCollabAgentToolCall, t }; } -function agentStatesDisplay(states: MessageStreamCollabAgentToolCall["agentsStates"]): AgentStateSummary[] { +function agentStatesDisplay(states: ThreadStreamCollabAgentToolCall["agentsStates"]): AgentStateSummary[] { return Object.entries(states) .map(([threadId, state]) => { const status = state?.status ?? "unknown"; diff --git a/src/features/chat/app-server/mappers/message-stream/execution-state.ts b/src/features/chat/app-server/mappers/thread-stream/execution-state.ts similarity index 89% rename from src/features/chat/app-server/mappers/message-stream/execution-state.ts rename to src/features/chat/app-server/mappers/thread-stream/execution-state.ts index ebe093ff..ed0360db 100644 --- a/src/features/chat/app-server/mappers/message-stream/execution-state.ts +++ b/src/features/chat/app-server/mappers/thread-stream/execution-state.ts @@ -1,9 +1,9 @@ -import { type MessageStreamExecutionState, RUNNING_EXECUTION_STATE } from "../../../domain/message-stream/execution-state"; -import type { ExecutionState } from "../../../domain/message-stream/items"; +import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state"; +import type { ExecutionState } from "../../../domain/thread-stream/items"; export { RUNNING_EXECUTION_STATE }; -export type ExecutionStateByStatus = Readonly>; +export type ExecutionStateByStatus = Readonly>; const COMMAND_STATES = { inProgress: RUNNING_EXECUTION_STATE, diff --git a/src/features/chat/app-server/mappers/message-stream/file-changes.ts b/src/features/chat/app-server/mappers/thread-stream/file-changes.ts similarity index 73% rename from src/features/chat/app-server/mappers/message-stream/file-changes.ts rename to src/features/chat/app-server/mappers/thread-stream/file-changes.ts index b199991d..a33a0c9c 100644 --- a/src/features/chat/app-server/mappers/message-stream/file-changes.ts +++ b/src/features/chat/app-server/mappers/thread-stream/file-changes.ts @@ -1,4 +1,4 @@ -import type { MessageStreamFileChange, MessageStreamItem } from "../../../domain/message-stream/items"; +import type { ThreadStreamFileChange, ThreadStreamItem } from "../../../domain/thread-stream/items"; import { patchApplyExecutionState } from "./execution-state"; export interface AppServerFileChange { @@ -9,7 +9,7 @@ export interface AppServerFileChange { readonly diff: string; } -export function normalizeFileChanges(changes: readonly AppServerFileChange[]): MessageStreamFileChange[] { +export function normalizeFileChanges(changes: readonly AppServerFileChange[]): ThreadStreamFileChange[] { return changes.map((change) => ({ kind: change.kind.type, path: change.path, @@ -17,12 +17,12 @@ export function normalizeFileChanges(changes: readonly AppServerFileChange[]): M })); } -export function streamingFileChangeMessageStreamItem( +export function streamingFileChangeThreadStreamItem( itemId: string, turnId: string, - changes: readonly MessageStreamFileChange[], + changes: readonly ThreadStreamFileChange[], status: string, -): MessageStreamItem { +): ThreadStreamItem { return { id: itemId, kind: "fileChange", diff --git a/src/features/chat/app-server/mappers/message-stream/hook-run-items.ts b/src/features/chat/app-server/mappers/thread-stream/hook-run-items.ts similarity index 87% rename from src/features/chat/app-server/mappers/message-stream/hook-run-items.ts rename to src/features/chat/app-server/mappers/thread-stream/hook-run-items.ts index 6d2057fd..0c0c5677 100644 --- a/src/features/chat/app-server/mappers/message-stream/hook-run-items.ts +++ b/src/features/chat/app-server/mappers/thread-stream/hook-run-items.ts @@ -1,7 +1,7 @@ -import type { HookMessageStreamItem } from "../../../domain/message-stream/items"; +import type { HookThreadStreamItem } from "../../../domain/thread-stream/items"; import { type ExecutionStateByStatus, executionStateFromStatus, RUNNING_EXECUTION_STATE } from "./execution-state"; -interface MessageStreamHookRun { +interface ThreadStreamHookRun { id: string; eventName: string | null; statusMessage: string | null; @@ -18,7 +18,7 @@ const HOOK_RUN_STATES: ExecutionStateByStatus = { stopped: "failed", }; -export function hookRunMessageStreamItem(run: MessageStreamHookRun, turnId: string | null, status: string): HookMessageStreamItem | null { +export function hookRunThreadStreamItem(run: ThreadStreamHookRun, turnId: string | null, status: string): HookThreadStreamItem | null { if (run.id.length === 0) return null; const trimmedEventName = run.eventName?.trim(); const eventName = trimmedEventName && trimmedEventName.length > 0 ? trimmedEventName : "Hook"; diff --git a/src/features/chat/app-server/mappers/message-stream/permission-rows.ts b/src/features/chat/app-server/mappers/thread-stream/permission-rows.ts similarity index 91% rename from src/features/chat/app-server/mappers/message-stream/permission-rows.ts rename to src/features/chat/app-server/mappers/thread-stream/permission-rows.ts index 073beb3d..d64f6019 100644 --- a/src/features/chat/app-server/mappers/message-stream/permission-rows.ts +++ b/src/features/chat/app-server/mappers/thread-stream/permission-rows.ts @@ -1,5 +1,5 @@ import { jsonPreview } from "../../../../../domain/display/json-preview"; -import type { MessageStreamAuditFact } from "../../../domain/message-stream/items"; +import type { ThreadStreamAuditFact } from "../../../domain/thread-stream/items"; export interface AutoReviewPermissionProfile { network?: { enabled?: boolean | null } | null; @@ -22,8 +22,8 @@ type AutoReviewFileSystemPath = | { kind: string }; }; -export function autoReviewPermissionRows(permissions: AutoReviewPermissionProfile): MessageStreamAuditFact[] { - const rows: MessageStreamAuditFact[] = []; +export function autoReviewPermissionRows(permissions: AutoReviewPermissionProfile): ThreadStreamAuditFact[] { + const rows: ThreadStreamAuditFact[] = []; const networkEnabled = permissions.network?.enabled; if (typeof networkEnabled === "boolean") { rows.push({ key: "network", value: networkEnabled ? "enabled" : "disabled" }); @@ -45,7 +45,7 @@ export function autoReviewPermissionRows(permissions: AutoReviewPermissionProfil return rows; } -function addOptional(rows: MessageStreamAuditFact[], key: string, value: unknown): void { +function addOptional(rows: ThreadStreamAuditFact[], key: string, value: unknown): void { if (value === null || value === undefined) return; if (Array.isArray(value) && value.length === 0) return; rows.push({ key, value: stringValue(value) }); diff --git a/src/features/chat/app-server/mappers/message-stream/review-result-items.ts b/src/features/chat/app-server/mappers/thread-stream/review-result-items.ts similarity index 96% rename from src/features/chat/app-server/mappers/message-stream/review-result-items.ts rename to src/features/chat/app-server/mappers/thread-stream/review-result-items.ts index 391e7839..89b2c1fa 100644 --- a/src/features/chat/app-server/mappers/message-stream/review-result-items.ts +++ b/src/features/chat/app-server/mappers/thread-stream/review-result-items.ts @@ -1,5 +1,5 @@ import { pathRelativeToRoot } from "../../../../../domain/vault/paths"; -import type { ExecutionState, MessageStreamAuditFact, MessageStreamItem } from "../../../domain/message-stream/items"; +import type { ExecutionState, ThreadStreamAuditFact, ThreadStreamItem } from "../../../domain/thread-stream/items"; import { type ExecutionStateByStatus, executionStateFromStatus, RUNNING_EXECUTION_STATE } from "./execution-state"; import { type AutoReviewPermissionProfile, autoReviewPermissionRows } from "./permission-rows"; @@ -50,7 +50,7 @@ type AutoReviewAction = } | { type: "requestPermissions"; reason: string | null; permissions: AutoReviewPermissionProfile }; -export function createReviewResultItem(id: string, text: string): MessageStreamItem { +export function createReviewResultItem(id: string, text: string): ThreadStreamItem { const parsed = parseAutomaticApprovalReviewMessage(text); if (parsed) { return { @@ -72,7 +72,7 @@ export function createReviewResultItem(id: string, text: string): MessageStreamI }; } -export function createAutoReviewResultItem(params: AutoReviewNotification): MessageStreamItem { +export function createAutoReviewResultItem(params: AutoReviewNotification): ThreadStreamItem { const completed = "decisionSource" in params; const status = params.review.status; const action = autoReviewActionLabel(params.action); @@ -124,7 +124,7 @@ function parseAutomaticApprovalReviewMessage( }; } -function autoReviewActionRows(action: AutoReviewAction): MessageStreamAuditFact[] { +function autoReviewActionRows(action: AutoReviewAction): ThreadStreamAuditFact[] { if (action.type === "command") { return [ { key: "action", value: "command" }, diff --git a/src/features/chat/app-server/mappers/message-stream/task-progress.ts b/src/features/chat/app-server/mappers/thread-stream/task-progress.ts similarity index 83% rename from src/features/chat/app-server/mappers/message-stream/task-progress.ts rename to src/features/chat/app-server/mappers/thread-stream/task-progress.ts index 8e8a6bd5..dc69390c 100644 --- a/src/features/chat/app-server/mappers/message-stream/task-progress.ts +++ b/src/features/chat/app-server/mappers/thread-stream/task-progress.ts @@ -1,4 +1,4 @@ -import type { MessageStreamItem } from "../../../domain/message-stream/items"; +import type { ThreadStreamItem } from "../../../domain/thread-stream/items"; import { type ExecutionStateByStatus, executionStateFromStatus, RUNNING_EXECUTION_STATE } from "./execution-state"; const TASK_STATES = { @@ -14,11 +14,7 @@ interface TaskPlanStep { status: TaskStepStatus; } -export function taskProgressMessageStreamItem( - turnId: string, - explanation: string | null, - plan: readonly TaskPlanStep[], -): MessageStreamItem { +export function taskProgressThreadStreamItem(turnId: string, explanation: string | null, plan: readonly TaskPlanStep[]): ThreadStreamItem { const trimmedExplanation = explanation?.trim(); const status = plan.some((step) => step.status === "inProgress" || step.status === "pending") ? "inProgress" : "completed"; return { diff --git a/src/features/chat/app-server/mappers/message-stream/turn-items.ts b/src/features/chat/app-server/mappers/thread-stream/turn-items.ts similarity index 79% rename from src/features/chat/app-server/mappers/message-stream/turn-items.ts rename to src/features/chat/app-server/mappers/thread-stream/turn-items.ts index 95a77635..167919d7 100644 --- a/src/features/chat/app-server/mappers/message-stream/turn-items.ts +++ b/src/features/chat/app-server/mappers/thread-stream/turn-items.ts @@ -8,12 +8,12 @@ import { jsonPreview } from "../../../../../domain/display/json-preview"; import type { HistoricalTurn } from "../../../../../domain/threads/history"; import { referencedThreadMetadataFromPrompt } from "../../../../../domain/threads/reference"; import type { ThreadConversationSummary } from "../../../../../domain/threads/transcript"; -import { fileMentionsFromInput } from "../../../domain/message-stream/format/file-mentions"; -import { normalizeProposedPlanMarkdown } from "../../../domain/message-stream/format/proposed-plan"; -import { userMessageDisplayText } from "../../../domain/message-stream/format/user-message-text"; -import type { CommandMessageStreamTarget, MessageStreamDiagnosticSection, MessageStreamItem } from "../../../domain/message-stream/items"; -import type { MessageStreamItemProvenance } from "../../../domain/message-stream/provenance"; -import { agentMessageStreamItem } from "./agent-items"; +import { fileMentionsFromInput } from "../../../domain/thread-stream/format/file-mentions"; +import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan"; +import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text"; +import type { CommandThreadStreamTarget, ThreadStreamDiagnosticSection, ThreadStreamItem } from "../../../domain/thread-stream/items"; +import type { ThreadStreamItemProvenance } from "../../../domain/thread-stream/provenance"; +import { agentThreadStreamItem } from "./agent-items"; import { appServerFailedStatusLabel, commandExecutionState, @@ -52,67 +52,67 @@ export function completedConversationSummaryFromAppServerTurn(turn: TurnRecord): return completedConversationSummaryFromTurnRecord(turn); } -export function messageStreamItemsFromTurns(turns: readonly HistoricalTurn[]): MessageStreamItem[] { +export function threadStreamItemsFromTurns(turns: readonly HistoricalTurn[]): ThreadStreamItem[] { const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)); - const items: MessageStreamItem[] = []; + const items: ThreadStreamItem[] = []; for (const turn of sortedTurns) { for (const item of turn.items as readonly TurnItem[]) { - const streamItem = messageStreamItemFromTurnItem(item, turn.id); + const streamItem = threadStreamItemFromTurnItem(item, turn.id); if (streamItem) items.push(streamItem); } } return items; } -export function messageStreamItemFromTurnItem(item: TurnItem, turnId?: string): MessageStreamItem | null { - const streamItem = messageStreamItemFromTurnItemCore(item, turnId); +export function threadStreamItemFromTurnItem(item: TurnItem, turnId?: string): ThreadStreamItem | null { + const streamItem = threadStreamItemFromTurnItemCore(item, turnId); return streamItem ? withTurnItemProvenance(streamItem, item) : null; } -function messageStreamItemFromTurnItemCore(item: TurnItem, turnId?: string): MessageStreamItem | null { +function threadStreamItemFromTurnItemCore(item: TurnItem, turnId?: string): ThreadStreamItem | null { switch (item.type) { case "userMessage": - return userMessageStreamItem(item, turnId); + return userThreadStreamItem(item, turnId); case "agentMessage": - return assistantMessageStreamItemFromTurn(item, turnId); + return assistantThreadStreamItemFromTurn(item, turnId); case "commandExecution": - return commandMessageStreamItem(item, turnId); + return commandThreadStreamItem(item, turnId); case "fileChange": - return fileChangeMessageStreamItem(item, turnId); + return fileChangeThreadStreamItem(item, turnId); case "plan": - return proposedPlanMessageStreamItem(item, turnId); + return proposedPlanThreadStreamItem(item, turnId); case "hookPrompt": - return hookPromptMessageStreamItem(item, turnId); + return hookPromptThreadStreamItem(item, turnId); case "reasoning": - return reasoningMessageStreamItem(item, turnId); + return reasoningThreadStreamItem(item, turnId); case "mcpToolCall": - return mcpToolCallMessageStreamItem(item, turnId); + return mcpToolCallThreadStreamItem(item, turnId); case "dynamicToolCall": - return dynamicToolCallMessageStreamItem(item, turnId); + return dynamicToolCallThreadStreamItem(item, turnId); case "collabAgentToolCall": - return agentMessageStreamItem(item, turnId); + return agentThreadStreamItem(item, turnId); case "webSearch": - return webSearchMessageStreamItem(item, turnId); + return webSearchThreadStreamItem(item, turnId); case "imageView": - return imageViewMessageStreamItem(item, turnId); + return imageViewThreadStreamItem(item, turnId); case "sleep": - return sleepMessageStreamItem(item, turnId); + return sleepThreadStreamItem(item, turnId); case "imageGeneration": - return imageGenerationMessageStreamItem(item, turnId); + return imageGenerationThreadStreamItem(item, turnId); case "subAgentActivity": return null; case "enteredReviewMode": case "exitedReviewMode": - return reviewModeMessageStreamItem(item, turnId); + return reviewModeThreadStreamItem(item, turnId); case "contextCompaction": - return contextCompactionMessageStreamItem(item, turnId); + return contextCompactionThreadStreamItem(item, turnId); default: return ignoredUnsupportedTurnItem(item); } } -function withTurnItemProvenance(item: MessageStreamItem, turnItem: TurnItem): MessageStreamItem { - const provenance: MessageStreamItemProvenance = { +function withTurnItemProvenance(item: ThreadStreamItem, turnItem: TurnItem): ThreadStreamItem { + const provenance: ThreadStreamItemProvenance = { source: "appServer", channel: "turnItem", itemType: turnItem.type, @@ -129,15 +129,15 @@ function turnItemSourceFields(item: { id: string }, turnId?: string): TurnItemSo }; } -function userMessageStreamItem(item: UserMessageItem, turnId?: string): MessageStreamItem { +function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStreamItem { const text = turnUserItemText(item); const referencedThread = referencedThreadMetadataFromPrompt(text); const mentionedFiles = fileMentionsFromInput(item.content); if (referencedThread) { return { ...turnItemSourceFields(item, turnId), - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: userMessageDisplayText(referencedThread.text, item.content), copyText: referencedThread.text, @@ -148,8 +148,8 @@ function userMessageStreamItem(item: UserMessageItem, turnId?: string): MessageS } return { ...turnItemSourceFields(item, turnId), - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: userMessageDisplayText(text, item.content), copyText: text, @@ -158,32 +158,32 @@ function userMessageStreamItem(item: UserMessageItem, turnId?: string): MessageS }; } -function assistantMessageStreamItemFromTurn(item: AgentMessageItem, turnId?: string): MessageStreamItem { +function assistantThreadStreamItemFromTurn(item: AgentMessageItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), - kind: "message", - messageKind: "assistantResponse", + kind: "dialogue", + dialogueKind: "assistantResponse", role: "assistant", text: item.text, copyText: item.text, - messageState: "completed", + dialogueState: "completed", }; } -function proposedPlanMessageStreamItem(item: PlanItem, turnId?: string): MessageStreamItem { +function proposedPlanThreadStreamItem(item: PlanItem, turnId?: string): ThreadStreamItem { const text = normalizeProposedPlanMarkdown(item.text); return { ...turnItemSourceFields(item, turnId), - kind: "message", - messageKind: "proposedPlan", + kind: "dialogue", + dialogueKind: "proposedPlan", role: "assistant", text, copyText: text, - messageState: "completed", + dialogueState: "completed", }; } -function hookPromptMessageStreamItem(item: HookPromptItem, turnId?: string): MessageStreamItem { +function hookPromptThreadStreamItem(item: HookPromptItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), kind: "hook", @@ -192,7 +192,7 @@ function hookPromptMessageStreamItem(item: HookPromptItem, turnId?: string): Mes }; } -function reasoningMessageStreamItem(item: ReasoningItem, turnId?: string): MessageStreamItem { +function reasoningThreadStreamItem(item: ReasoningItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), kind: "reasoning", @@ -201,7 +201,7 @@ function reasoningMessageStreamItem(item: ReasoningItem, turnId?: string): Messa }; } -function mcpToolCallMessageStreamItem(item: McpToolCallItem, turnId?: string): MessageStreamItem { +function mcpToolCallThreadStreamItem(item: McpToolCallItem, turnId?: string): ThreadStreamItem { const name = `${item.server}.${item.tool}`; const target = jsonTargetLabel(item.arguments); return { @@ -225,7 +225,7 @@ function mcpToolCallMessageStreamItem(item: McpToolCallItem, turnId?: string): M }; } -function dynamicToolCallMessageStreamItem(item: DynamicToolCallItem, turnId?: string): MessageStreamItem { +function dynamicToolCallThreadStreamItem(item: DynamicToolCallItem, turnId?: string): ThreadStreamItem { const name = `${item.namespace ? `${item.namespace}.` : ""}${item.tool}`; const target = jsonTargetLabel(item.arguments); const failure = item.success === false ? "failed" : appServerFailedStatusLabel(item.status); @@ -246,7 +246,7 @@ function dynamicToolCallMessageStreamItem(item: DynamicToolCallItem, turnId?: st }; } -function webSearchMessageStreamItem(item: WebSearchItem, turnId?: string): MessageStreamItem { +function webSearchThreadStreamItem(item: WebSearchItem, turnId?: string): ThreadStreamItem { const target = webSearchTarget(item); return { ...turnItemSourceFields(item, turnId), @@ -260,7 +260,7 @@ function webSearchMessageStreamItem(item: WebSearchItem, turnId?: string): Messa }; } -function imageViewMessageStreamItem(item: ImageViewItem, turnId?: string): MessageStreamItem { +function imageViewThreadStreamItem(item: ImageViewItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), kind: "tool", @@ -270,7 +270,7 @@ function imageViewMessageStreamItem(item: ImageViewItem, turnId?: string): Messa }; } -function sleepMessageStreamItem(item: SleepItem, turnId?: string): MessageStreamItem { +function sleepThreadStreamItem(item: SleepItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), kind: "wait", @@ -280,7 +280,7 @@ function sleepMessageStreamItem(item: SleepItem, turnId?: string): MessageStream }; } -function imageGenerationMessageStreamItem(item: ImageGenerationItem, turnId?: string): MessageStreamItem { +function imageGenerationThreadStreamItem(item: ImageGenerationItem, turnId?: string): ThreadStreamItem { const target = item.savedPath ?? item.result; const failureReason = appServerFailedStatusLabel(item.status); return { @@ -303,7 +303,7 @@ function imageGenerationMessageStreamItem(item: ImageGenerationItem, turnId?: st }; } -function reviewModeMessageStreamItem(item: ReviewModeItem, turnId?: string): MessageStreamItem { +function reviewModeThreadStreamItem(item: ReviewModeItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), kind: "tool", @@ -314,7 +314,7 @@ function reviewModeMessageStreamItem(item: ReviewModeItem, turnId?: string): Mes }; } -function contextCompactionMessageStreamItem(item: ContextCompactionItem, turnId?: string): MessageStreamItem { +function contextCompactionThreadStreamItem(item: ContextCompactionItem, turnId?: string): ThreadStreamItem { return { ...turnItemSourceFields(item, turnId), kind: "contextCompaction", @@ -329,7 +329,7 @@ function reasoningText(item: ReasoningItem): string { .join("\n\n"); } -function commandTarget(item: CommandExecutionItem): CommandMessageStreamTarget { +function commandTarget(item: CommandExecutionItem): CommandThreadStreamTarget { const action = representativeCommandAction(item.commandActions); if (action?.type === "search") { const query = commandActionValue(action.query) ?? undefined; @@ -421,7 +421,7 @@ function webSearchQueryList( return unique.join("; "); } -function webSearchDetails(item: WebSearchItem): Extract["webSearch"] { +function webSearchDetails(item: WebSearchItem): Extract["webSearch"] { const details: { action?: string; query?: string; @@ -444,7 +444,7 @@ function webSearchDetails(item: WebSearchItem): Extract 0 ? details : undefined; } -function commandMessageStreamItem(item: CommandExecutionItem, turnId?: string): MessageStreamItem { +function commandThreadStreamItem(item: CommandExecutionItem, turnId?: string): ThreadStreamItem { const exitCode = typeof item.exitCode === "number" ? item.exitCode : undefined; const durationMs = typeof item.durationMs === "number" ? item.durationMs : undefined; return { @@ -463,7 +463,7 @@ function commandMessageStreamItem(item: CommandExecutionItem, turnId?: string): }; } -function fileChangeMessageStreamItem(item: FileChangeItem, turnId?: string): MessageStreamItem { +function fileChangeThreadStreamItem(item: FileChangeItem, turnId?: string): ThreadStreamItem { const changes = normalizeFileChanges(item.changes); return { ...turnItemSourceFields(item, turnId), @@ -519,7 +519,7 @@ function jsonTargetLabel(value: unknown): string | null { function jsonDiagnosticSections( ...sections: readonly { readonly title: string; readonly value: unknown }[] -): readonly MessageStreamDiagnosticSection[] | undefined { +): readonly ThreadStreamDiagnosticSection[] | undefined { const diagnostics = sections .filter((section) => section.value !== null && section.value !== undefined) .map((section) => ({ title: section.title, body: jsonPreview(section.value) })); diff --git a/src/features/chat/app-server/thread-reference-resolver.ts b/src/features/chat/app-server/thread-reference-resolver.ts index f78c1c23..7025e67e 100644 --- a/src/features/chat/app-server/thread-reference-resolver.ts +++ b/src/features/chat/app-server/thread-reference-resolver.ts @@ -4,7 +4,7 @@ import { shortThreadId } from "../../../domain/threads/id"; import type { Thread } from "../../../domain/threads/model"; import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadPromptBundle } from "../../../domain/threads/reference"; import type { ComposerInputSnapshot } from "../application/composer/input-snapshot"; -import type { ThreadReferenceInput } from "../application/conversation/slash-command-execution"; +import type { ThreadReferenceInput } from "../application/turns/slash-command-execution"; interface ThreadReferenceResolverHost { currentClient(): ThreadConversationSummaryClient | null; diff --git a/src/features/chat/app-server/transports/session-transports.ts b/src/features/chat/app-server/transports/session-transports.ts index 18c550e4..64afe99a 100644 --- a/src/features/chat/app-server/transports/session-transports.ts +++ b/src/features/chat/app-server/transports/session-transports.ts @@ -17,7 +17,6 @@ import { import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns"; import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; import type { ThreadTurnsPage } from "../../../../domain/threads/history"; -import type { ChatTurnTransport } from "../../application/conversation/turn-transport"; import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport"; import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport"; import type { @@ -28,7 +27,8 @@ import type { } from "../../application/threads/thread-loading-transport"; import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport"; import type { ThreadStartTransport } from "../../application/threads/thread-start-transport"; -import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; +import type { ChatTurnTransport } from "../../application/turns/turn-transport"; +import { threadStreamItemsFromTurns } from "../mappers/thread-stream/turn-items"; interface CurrentChatAppServerClientHost { currentClient(): AppServerClient | null; @@ -161,7 +161,7 @@ function createChatThreadMutationTransport(host: ChatAppServerTransportHost): Th return { thread: snapshot.thread, cwd: snapshot.cwd, - items: messageStreamItemsFromTurns(snapshot.turns), + items: threadStreamItemsFromTurns(snapshot.turns), }; }), }; @@ -235,7 +235,7 @@ async function readChatThreadHistoryPage( function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage { return { - items: messageStreamItemsFromTurns(page.turns), + items: threadStreamItemsFromTurns(page.turns), nextCursor: page.nextCursor, hadTurns: page.turns.length > 0, }; diff --git a/src/features/chat/application/conversation/runtime-event-plan.ts b/src/features/chat/application/conversation/runtime-event-plan.ts deleted file mode 100644 index b1401c11..00000000 --- a/src/features/chat/application/conversation/runtime-event-plan.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { reconcileCompletedTurnItems } from "../../domain/message-stream/completed-turn-reconciliation"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; -import { attachHookRunsToTurn, completeReasoningItems, upsertMessageStreamItemById } from "../../domain/message-stream/updates"; -import { messageStreamItems } from "../state/message-stream"; -import { type ChatAction, type ChatState, chatReducer } from "../state/root-reducer"; -import type { ConversationRuntimeEvent } from "./runtime-events"; -import { activeTurnId, pendingTurnStart as pendingTurnStartForState } from "./turn-state"; - -export type ConversationRuntimeOutcome = - | { type: "run-started"; threadId: string; runId: string; recencyAt: number | null } - | { type: "run-completed"; threadId: string; runId: string; completedSummary: ConversationRuntimeEventCompletedSummary }; - -type ConversationRuntimeEventCompletedSummary = Extract["completedSummary"]; - -export interface ConversationRuntimePlan { - actions: readonly ChatAction[]; - outcomes: readonly ConversationRuntimeOutcome[]; -} - -const EMPTY_PLAN: ConversationRuntimePlan = { actions: [], outcomes: [] }; - -export function planConversationRuntimeEvents(state: ChatState, events: readonly ConversationRuntimeEvent[]): ConversationRuntimePlan { - let currentState = state; - const actions: ChatAction[] = []; - const outcomes: ConversationRuntimeOutcome[] = []; - for (const event of events) { - const plan = planConversationRuntimeEvent(currentState, event); - actions.push(...plan.actions); - outcomes.push(...plan.outcomes); - currentState = reducePlannedActions(currentState, plan.actions); - } - return actions.length === 0 && outcomes.length === 0 ? EMPTY_PLAN : { actions, outcomes }; -} - -function planConversationRuntimeEvent(state: ChatState, event: ConversationRuntimeEvent): ConversationRuntimePlan { - switch (event.type) { - case "assistantDelta": - return actionPlan({ - type: "message-stream/assistant-delta-appended", - itemId: event.itemId, - turnId: event.runId, - delta: event.delta, - completeReasoning: event.completeReasoning, - }); - case "planDelta": - return actionPlan({ - type: "message-stream/plan-delta-appended", - itemId: event.itemId, - turnId: event.runId, - delta: event.delta, - }); - case "textDelta": - return actionPlan({ - type: "message-stream/item-text-appended", - itemId: event.itemId, - turnId: event.runId, - label: event.label, - delta: event.delta, - kind: event.kind, - }); - case "toolOutputDelta": - return actionPlan({ - type: "message-stream/tool-output-appended", - itemId: event.itemId, - turnId: event.runId, - delta: event.delta, - fallbackLabel: event.fallbackLabel, - }); - case "itemOutputDelta": - return actionPlan({ - type: "message-stream/item-output-appended", - itemId: event.itemId, - turnId: event.runId, - delta: event.delta, - kind: event.kind, - fallbackText: event.fallbackText, - }); - case "itemUpserted": - return actionPlan({ type: "message-stream/item-upserted", item: event.item }); - case "itemCompleted": - return completedItemPlan(event.item, event.runId); - case "autoReviewUpdated": - return autoReviewUpdatedPlan(state, event.item); - case "runStarted": - return runStartedPlan(state, event); - case "runCompleted": - return runCompletedPlan(state, event); - case "turnDiffUpdated": - return actionPlan({ type: "message-stream/turn-diff-updated", turnId: event.runId, diff: event.diff }); - case "hookRunObserved": - return hookRunPlan(state, event); - case "requestResolved": - return actionPlan({ type: "request/resolved", requestId: event.requestId }); - case "reviewWarning": - return reviewWarningPlan(state, event.item); - case "systemNotice": - return actionPlan({ type: "message-stream/system-item-added", item: event.item }); - } -} - -function runStartedPlan(state: ChatState, event: Extract): ConversationRuntimePlan { - return { - actions: [ - { - type: "turn/started", - threadId: event.threadId, - turnId: event.runId, - items: messageStreamItemsWithPendingPromptSubmitHooks(state, event.runId), - }, - ], - outcomes: [ - { - type: "run-started", - threadId: event.threadId, - runId: event.runId, - recencyAt: event.recencyAt, - }, - ], - }; -} - -function runCompletedPlan(state: ChatState, event: Extract): ConversationRuntimePlan { - if (activeTurnId(state) !== event.runId) return EMPTY_PLAN; - return { - actions: [ - { - type: "turn/completed", - turnId: event.runId, - status: event.status, - items: completeReasoningItems( - reconcileCompletedTurnItems({ - currentItems: messageStreamItems(state.messageStream), - completedTurnId: event.runId, - turnItems: event.completedItems, - }), - event.runId, - ), - }, - ], - outcomes: [ - { - type: "run-completed", - threadId: event.threadId, - runId: event.runId, - completedSummary: event.completedSummary, - }, - ], - }; -} - -function completedItemPlan(item: MessageStreamItem, runId: string): ConversationRuntimePlan { - return { - actions: [ - { type: "message-stream/item-upserted", item }, - ...(item.kind === "reasoning" ? ([{ type: "message-stream/reasoning-completed", turnId: runId }] satisfies ChatAction[]) : []), - ], - outcomes: [], - }; -} - -function hookRunPlan(state: ChatState, event: Extract): ConversationRuntimePlan { - const resolvedRunId = hookRunId(state, event); - const item = resolvedRunId ? { ...event.item, turnId: resolvedRunId } : event.item; - const currentPendingTurnStart = pendingTurnStartForState(state); - let pendingTurnStart = currentPendingTurnStart; - if (!resolvedRunId && currentPendingTurnStart && event.eventName === "userPromptSubmit") { - const hookIds = currentPendingTurnStart.promptSubmitHookItemIds; - pendingTurnStart = hookIds.includes(item.id) - ? currentPendingTurnStart - : { ...currentPendingTurnStart, promptSubmitHookItemIds: [...hookIds, item.id] }; - } - return actionPlan({ - type: "turn/pending-start-hook-upserted", - item, - pendingTurnStart, - }); -} - -function hookRunId(state: ChatState, event: Extract): string | null { - if (event.runId) return event.runId; - if (event.eventName === "userPromptSubmit" && !pendingTurnStartForState(state)) return activeTurnId(state); - return null; -} - -function reviewWarningPlan(state: ChatState, item: MessageStreamItem): ConversationRuntimePlan { - if ( - isUnstructuredAutoReviewWarning(item) && - hasStructuredAutoReviewResult(messageStreamItems(state.messageStream), activeTurnId(state)) - ) { - return EMPTY_PLAN; - } - return actionPlan({ type: "message-stream/item-upserted", item }); -} - -function autoReviewUpdatedPlan(state: ChatState, item: MessageStreamItem): ConversationRuntimePlan { - return actionPlan({ - type: "message-stream/items-replaced", - items: upsertMessageStreamItemById( - messageStreamItems(state.messageStream).filter((currentItem) => !isUnstructuredAutoReviewWarning(currentItem)), - item, - ), - }); -} - -function messageStreamItemsWithPendingPromptSubmitHooks(state: ChatState, runId: string): readonly MessageStreamItem[] { - const pending = pendingTurnStartForState(state); - const items = messageStreamItems(state.messageStream); - if (!pending) return items; - return attachHookRunsToTurn(items, runId, pending.promptSubmitHookItemIds, pending.anchorItemId); -} - -function hasStructuredAutoReviewResult(items: readonly MessageStreamItem[], activeRunId: string | null): boolean { - return items.some( - (item) => - item.kind === "reviewResult" && Boolean(item.turnId) && (!activeRunId || item.turnId === activeRunId) && isAutoReviewText(item.text), - ); -} - -function isUnstructuredAutoReviewWarning(item: MessageStreamItem): boolean { - return item.kind === "reviewResult" && !item.turnId && isAutoReviewText(item.text); -} - -function isAutoReviewText(text: string): boolean { - return /^Auto-review\b/i.test(text.trim()); -} - -function reducePlannedActions(state: ChatState, actions: readonly ChatAction[]): ChatState { - return actions.reduce(reducePlannedAction, state); -} - -function reducePlannedAction(state: ChatState, action: ChatAction): ChatState { - return chatReducer(state, action); -} - -function actionPlan(action: ChatAction): ConversationRuntimePlan { - return { actions: [action], outcomes: [] }; -} diff --git a/src/features/chat/application/runtime/snapshot.ts b/src/features/chat/application/runtime/snapshot.ts index 5029cf64..02ae3b32 100644 --- a/src/features/chat/application/runtime/snapshot.ts +++ b/src/features/chat/application/runtime/snapshot.ts @@ -1,8 +1,8 @@ -import type { MessageStreamItem } from "../../domain/message-stream/items"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { activeThreadRuntimeState, pendingRuntimeIntentState } from "../../domain/runtime/state"; -import { messageStreamItems } from "../state/message-stream"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; import type { ChatState } from "../state/root-reducer"; +import { threadStreamItems } from "../state/thread-stream"; interface RuntimeSnapshotInput { runtimeConfig: ChatState["connection"]["runtimeConfig"]; @@ -13,7 +13,7 @@ interface RuntimeSnapshotInput { availableModels: ChatState["connection"]["availableModels"]; } -export function messageItemsHaveThreadTurns(items: readonly MessageStreamItem[]): boolean { +export function threadStreamItemsHaveThreadTurns(items: readonly ThreadStreamItem[]): boolean { return items.some((item) => item.turnId); } @@ -38,7 +38,7 @@ export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot { activeThread: state.activeThread, runtime: state.runtime, rateLimit: state.connection.rateLimit, - hasThreadTurns: messageItemsHaveThreadTurns(messageStreamItems(state.messageStream)), + hasThreadTurns: threadStreamItemsHaveThreadTurns(threadStreamItems(state.threadStream)), availableModels: state.connection.availableModels, }); } diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index 4fdc20d9..7efedc9e 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -8,15 +8,15 @@ import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/p import type { ServerInitialization } from "../../../../domain/server/initialization"; import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; import { type Thread, upsertThread } from "../../../../domain/threads/model"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; import type { CollaborationModeSelection } from "../../domain/runtime/intent"; import type { ActiveThreadRuntimeState } from "../../domain/runtime/state"; -import type { PendingTurnStart } from "../conversation/turn-state"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import type { PendingTurnStart } from "../turns/turn-state"; interface ResumedThreadActionParams { response: ThreadActivationSnapshot; listedThreads?: readonly Thread[]; - items?: readonly MessageStreamItem[]; + items?: readonly ThreadStreamItem[]; preserveRequestedRuntimeSettings?: boolean; serviceTierKnown?: boolean; } @@ -39,7 +39,7 @@ interface ResumedThreadFromActiveRuntimeParams { | "activePermissionProfile" >; listedThreads?: readonly Thread[]; - items?: readonly MessageStreamItem[]; + items?: readonly ThreadStreamItem[]; } export interface ActiveThreadResumedAction extends RuntimePermissionState, RuntimePermissionKnownState { @@ -51,7 +51,7 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti serviceTier: ServiceTier | null; serviceTierKnown?: boolean; approvalsReviewer: ActiveThreadRuntimeState["approvalsReviewer"]; - items?: readonly MessageStreamItem[]; + items?: readonly ThreadStreamItem[]; status?: string; listedThreads?: readonly Thread[]; preserveRequestedRuntimeSettings?: boolean; @@ -108,19 +108,19 @@ export interface DisclosureSetAction { export interface TurnOptimisticStartedAction { type: "turn/optimistic-started"; - item: MessageStreamItem; + item: ThreadStreamItem; pendingTurnStart: PendingTurnStart; } export interface TurnStartAcknowledgedAction { type: "turn/start-acknowledged"; turnId: string; - items: readonly MessageStreamItem[]; + items: readonly ThreadStreamItem[]; } export interface TurnStartFailedAction { type: "turn/start-failed"; - items: readonly MessageStreamItem[]; + items: readonly ThreadStreamItem[]; } export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromActiveRuntimeParams): ActiveThreadResumedAction { diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 9bb60358..db418f61 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -10,7 +10,6 @@ import { createServerDiagnostics } from "../../../../domain/server/diagnostics"; import type { ServerInitialization } from "../../../../domain/server/initialization"; import type { ThreadGoal } from "../../../../domain/threads/goal"; import type { Thread } from "../../../../domain/threads/model"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; import { type CollaborationModeSelection, type RequestedFastMode, unchangedCollaborationModeIntent } from "../../domain/runtime/intent"; import { type ChatRuntimeState, @@ -29,14 +28,8 @@ import { resetReasoningEffortToConfigRuntimeState, setSelectedCollaborationModeRuntimeState, } from "../../domain/runtime/state"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; import type { ComposerSuggestion } from "../composer/suggestions"; -import { - type ChatTurnState, - initialChatTurnState, - type PendingTurnStart, - STATUS_TURN_RUNNING, - transitionChatTurnLifecycleState, -} from "../conversation/turn-state"; import { type ChatRequestState, initialChatRequestState, @@ -45,6 +38,13 @@ import { reduceRequestSlice, resolveChatRequest, } from "../pending-requests/state"; +import { + type ChatTurnState, + initialChatTurnState, + type PendingTurnStart, + STATUS_TURN_RUNNING, + transitionChatTurnLifecycleState, +} from "../turns/turn-state"; import type { ActiveThreadResumedAction, ActiveThreadSettingsAppliedAction, @@ -57,18 +57,18 @@ import type { TurnStartAcknowledgedAction, TurnStartFailedAction, } from "./actions"; -import { - type ChatMessageStreamState, - initialChatMessageStreamState, - isMessageStreamAction, - type MessageStreamAction, - messageStreamItems, - messageStreamStartActiveSegment, - messageStreamWithActiveTurnItems, - messageStreamWithItems, - reduceMessageStreamSlice, -} from "./message-stream"; import { definedPatch, patchObject } from "./patch"; +import { + type ChatThreadStreamState, + initialChatThreadStreamState, + isThreadStreamAction, + reduceThreadStreamSlice, + type ThreadStreamAction, + threadStreamItems, + threadStreamStartActiveSegment, + threadStreamWithActiveTurnItems, + threadStreamWithItems, +} from "./thread-stream"; import { type ChatUiState, clearAllRequestDisclosures, @@ -124,7 +124,7 @@ interface ChatStateShape { activeThread: ChatActiveThreadState; runtime: ChatRuntimeState; turn: ChatTurnState; - messageStream: ChatMessageStreamState; + threadStream: ChatThreadStreamState; requests: ChatRequestState; composer: ChatComposerState; ui: ChatUiState; @@ -169,14 +169,14 @@ interface TurnStartedAction { type: "turn/started"; threadId: string; turnId: string; - items?: readonly MessageStreamItem[]; + items?: readonly ThreadStreamItem[]; } interface TurnCompletedAction { type: "turn/completed"; turnId: string; status: string; - items: readonly MessageStreamItem[]; + items: readonly ThreadStreamItem[]; } type TurnAction = @@ -213,12 +213,12 @@ export type ChatAction = ChatTransitionAction | ChatSliceAction; interface RequestResolvedAction { type: "request/resolved"; requestId: PendingRequestId; - resultItem?: MessageStreamItem; + resultItem?: ThreadStreamItem; } interface PendingStartHookUpsertedAction { type: "turn/pending-start-hook-upserted"; - item: MessageStreamItem; + item: ThreadStreamItem; pendingTurnStart: PendingTurnStart | null; } @@ -238,7 +238,7 @@ type ChatSliceAction = | ActiveThreadAction | RuntimeAction | RequestAction - | MessageStreamAction + | ThreadStreamAction | ComposerAction | UiAction; @@ -249,7 +249,7 @@ export function createChatState(): ChatState { activeThread: initialActiveThreadState(), runtime: initialChatRuntimeState(), turn: initialTurnState(), - messageStream: initialMessageStreamState(), + threadStream: initialThreadStreamState(), requests: initialRequestState(), composer: initialComposerState(), ui: initialUiState(), @@ -344,7 +344,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr }, }, turn: initialTurnState(), - messageStream: initialMessageStreamState(action.items ?? []), + threadStream: initialThreadStreamState(action.items ?? []), requests: initialRequestState(), composer: initialComposerState(), ui: initialUiState(), @@ -397,9 +397,9 @@ function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction activeThread: { ...state.activeThread, id: action.threadId }, turn: { lifecycle }, connection: { ...state.connection, statusText: STATUS_TURN_RUNNING }, - messageStream: action.items - ? messageStreamWithActiveTurnItems(state.messageStream, action.turnId, action.items) - : messageStreamStartActiveSegment(state.messageStream, action.turnId, []), + threadStream: action.items + ? threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items) + : threadStreamStartActiveSegment(state.threadStream, action.turnId, []), }); } @@ -408,7 +408,7 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc if (lifecycle === state.turn.lifecycle) return state; return patchChatState(state, { turn: { lifecycle }, - messageStream: messageStreamWithItems(state.messageStream, action.items), + threadStream: threadStreamWithItems(state.threadStream, action.items), connection: { ...state.connection, statusText: `Turn ${action.status}.` }, }); } @@ -420,7 +420,7 @@ function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOpt }); return patchChatState(state, { turn: { lifecycle }, - messageStream: messageStreamStartActiveSegment(state.messageStream, null, [action.item]), + threadStream: threadStreamStartActiveSegment(state.threadStream, null, [action.item]), }); } @@ -432,7 +432,7 @@ function reduceTurnStartAcknowledgedTransition(state: ChatState, action: TurnSta if (lifecycle === state.turn.lifecycle) return state; return patchChatState(state, { turn: { lifecycle }, - messageStream: messageStreamWithActiveTurnItems(state.messageStream, action.turnId, action.items), + threadStream: threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items), }); } @@ -441,13 +441,13 @@ function reduceTurnStartFailedTransition(state: ChatState, action: TurnStartFail if (lifecycle === state.turn.lifecycle) return state; return patchChatState(state, { turn: { lifecycle }, - messageStream: messageStreamWithItems(state.messageStream, action.items), + threadStream: threadStreamWithItems(state.threadStream, action.items), }); } function reducePendingStartHookUpsertedTransition(state: ChatState, action: PendingStartHookUpsertedAction): ChatState { return patchChatState(state, { - messageStream: reduceMessageStreamSlice(state.messageStream, { type: "message-stream/item-upserted", item: action.item }), + threadStream: reduceThreadStreamSlice(state.threadStream, { type: "thread-stream/item-upserted", item: action.item }), turn: { lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "pending-start-hook-upserted", @@ -463,9 +463,9 @@ function reduceRequestResolvedTransition(state: ChatState, action: RequestResolv return patchChatState(state, { requests, ui: clearResolvedRequestDisclosures(state.ui, action.requestId), - messageStream: action.resultItem - ? reduceMessageStreamSlice(state.messageStream, { type: "message-stream/item-added", item: action.resultItem }) - : state.messageStream, + threadStream: action.resultItem + ? reduceThreadStreamSlice(state.threadStream, { type: "thread-stream/item-added", item: action.resultItem }) + : state.threadStream, }); } @@ -474,7 +474,7 @@ function clearTurnScopedState(state: ChatState): ChatState { turn: { lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "cleared" }), }, - messageStream: messageStreamWithItems(state.messageStream, messageStreamItems(state.messageStream)), + threadStream: threadStreamWithItems(state.threadStream, threadStreamItems(state.threadStream)), requests: initialRequestState(), ui: clearAllRequestDisclosures(state.ui), }); @@ -485,7 +485,7 @@ function clearThreadScopedState(state: ChatState): ChatState { patchChatState(state, { activeThread: initialActiveThreadState(), runtime: initialChatRuntimeState(), - messageStream: initialMessageStreamState(), + threadStream: initialThreadStreamState(), composer: initialComposerState(), ui: initialUiState(), }), @@ -516,7 +516,7 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState runtime: reduceRuntimeSlice(state.runtime, action), turn: state.turn, requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests, - messageStream: isMessageStreamAction(action) ? reduceMessageStreamSlice(state.messageStream, action) : state.messageStream, + threadStream: isThreadStreamAction(action) ? reduceThreadStreamSlice(state.threadStream, action) : state.threadStream, composer: reduceComposerSlice(state.composer, action), ui: isUiAction(action) ? reduceUiSlice(state.ui, action) : state.ui, }); @@ -656,8 +656,8 @@ function initialTurnState(): ChatTurnState { return initialChatTurnState(); } -function initialMessageStreamState(items: readonly MessageStreamItem[] = []): ChatMessageStreamState { - return initialChatMessageStreamState(items); +function initialThreadStreamState(items: readonly ThreadStreamItem[] = []): ChatThreadStreamState { + return initialChatThreadStreamState(items); } function initialRequestState(): ChatRequestState { diff --git a/src/features/chat/application/state/store.ts b/src/features/chat/application/state/store.ts index 9538e1ff..db84ae3c 100644 --- a/src/features/chat/application/state/store.ts +++ b/src/features/chat/application/state/store.ts @@ -1,5 +1,5 @@ -import type { ChatMessageStreamActiveSegment, ChatMessageStreamState } from "./message-stream"; import { type ChatAction, type ChatState, chatReducer, createChatState } from "./root-reducer"; +import type { ChatThreadStreamActiveSegment, ChatThreadStreamState } from "./thread-stream"; import { cloneDisclosureUiState } from "./ui-state"; export interface ChatStateStore { @@ -44,7 +44,7 @@ function cloneChatState(state: ChatState): ChatState { activeThread: { ...state.activeThread }, runtime: { ...state.runtime }, turn: { lifecycle: state.turn.lifecycle }, - messageStream: cloneMessageStreamState(state.messageStream), + threadStream: cloneThreadStreamState(state.threadStream), requests: { approvals: [...state.requests.approvals], pendingUserInputs: [...state.requests.pendingUserInputs], @@ -67,7 +67,7 @@ function cloneChatState(state: ChatState): ChatState { }; } -function cloneMessageStreamState(state: ChatMessageStreamState): ChatMessageStreamState { +function cloneThreadStreamState(state: ChatThreadStreamState): ChatThreadStreamState { return { stableItems: [...state.stableItems], activeSegment: cloneActiveSegment(state.activeSegment), @@ -78,7 +78,7 @@ function cloneMessageStreamState(state: ChatMessageStreamState): ChatMessageStre }; } -function cloneActiveSegment(segment: ChatMessageStreamActiveSegment | null): ChatMessageStreamActiveSegment | null { +function cloneActiveSegment(segment: ChatThreadStreamActiveSegment | null): ChatThreadStreamActiveSegment | null { if (!segment) return null; return { turnId: segment.turnId, diff --git a/src/features/chat/application/state/message-stream.ts b/src/features/chat/application/state/thread-stream.ts similarity index 52% rename from src/features/chat/application/state/message-stream.ts rename to src/features/chat/application/state/thread-stream.ts index fb8859ea..8922f5eb 100644 --- a/src/features/chat/application/state/message-stream.ts +++ b/src/features/chat/application/state/thread-stream.ts @@ -1,54 +1,54 @@ import { - streamedItemOutputMessageStreamItem, - streamedTextMessageStreamItem, - streamedToolOutputMessageStreamItem, -} from "../../domain/message-stream/factories/streaming-items"; -import { normalizeProposedPlanMarkdown } from "../../domain/message-stream/format/proposed-plan"; -import type { MessageStreamItem, MessageStreamMessageItem } from "../../domain/message-stream/items"; -import { messageStreamSemanticClassifications } from "../../domain/message-stream/semantics/classify"; -import { messageStreamIsTurnInitiator } from "../../domain/message-stream/semantics/predicates"; -import { completeReasoningItems, upsertMessageStreamItemById } from "../../domain/message-stream/updates"; + streamedItemOutputThreadStreamItem, + streamedTextThreadStreamItem, + streamedToolOutputThreadStreamItem, +} from "../../domain/thread-stream/factories/streaming-items"; +import { normalizeProposedPlanMarkdown } from "../../domain/thread-stream/format/proposed-plan"; +import type { ThreadStreamDialogueItem, ThreadStreamItem } from "../../domain/thread-stream/items"; +import { threadStreamSemanticClassifications } from "../../domain/thread-stream/semantics/classify"; +import { threadStreamIsTurnInitiator } from "../../domain/thread-stream/semantics/predicates"; +import { completeReasoningItems, upsertThreadStreamItemById } from "../../domain/thread-stream/updates"; import { definedPatch, patchObject } from "./patch"; -export interface ChatMessageStreamActiveSegment { +export interface ChatThreadStreamActiveSegment { readonly turnId: string | null; - readonly items: readonly MessageStreamItem[]; + readonly items: readonly ThreadStreamItem[]; readonly indexById: ReadonlyMap; readonly indexBySourceItemId: ReadonlyMap; } -export interface ChatMessageStreamState { - readonly stableItems: readonly MessageStreamItem[]; - readonly activeSegment: ChatMessageStreamActiveSegment | null; +export interface ChatThreadStreamState { + readonly stableItems: readonly ThreadStreamItem[]; + readonly activeSegment: ChatThreadStreamActiveSegment | null; readonly turnDiffs: ReadonlyMap; readonly historyCursor: string | null; readonly loadingHistory: boolean; readonly reportedLogs: ReadonlySet; } -export interface MessageStreamRollbackCandidate { +export interface ThreadStreamRollbackCandidate { turnId: string; itemId: string; text: string; } -export type MessageStreamAction = - | { type: "message-stream/item-added"; item: MessageStreamItem } - | { type: "message-stream/system-item-added"; item: MessageStreamItem } - | { type: "message-stream/deduped-log-added"; text: string; item: MessageStreamItem } - | { type: "message-stream/history-loading-set"; loading: boolean } +export type ThreadStreamAction = + | { type: "thread-stream/item-added"; item: ThreadStreamItem } + | { type: "thread-stream/system-item-added"; item: ThreadStreamItem } + | { type: "thread-stream/deduped-log-added"; text: string; item: ThreadStreamItem } + | { type: "thread-stream/history-loading-set"; loading: boolean } | { - type: "message-stream/items-replaced"; - items: readonly MessageStreamItem[]; + type: "thread-stream/items-replaced"; + items: readonly ThreadStreamItem[]; historyCursor?: string | null; loadingHistory?: boolean; } - | { type: "message-stream/item-upserted"; item: MessageStreamItem } - | { type: "message-stream/reasoning-completed"; turnId: string } - | { type: "message-stream/assistant-delta-appended"; itemId: string; turnId: string; delta: string; completeReasoning?: boolean } - | { type: "message-stream/plan-delta-appended"; itemId: string; turnId: string; delta: string } + | { type: "thread-stream/item-upserted"; item: ThreadStreamItem } + | { type: "thread-stream/reasoning-completed"; turnId: string } + | { type: "thread-stream/assistant-delta-appended"; itemId: string; turnId: string; delta: string; completeReasoning?: boolean } + | { type: "thread-stream/plan-delta-appended"; itemId: string; turnId: string; delta: string } | { - type: "message-stream/item-text-appended"; + type: "thread-stream/item-text-appended"; itemId: string; turnId: string; label: string; @@ -56,44 +56,44 @@ export type MessageStreamAction = kind: "tool" | "hook" | "reasoning"; } | { - type: "message-stream/tool-output-appended"; + type: "thread-stream/tool-output-appended"; itemId: string; turnId: string; delta: string; fallbackLabel: string; } | { - type: "message-stream/item-output-appended"; + type: "thread-stream/item-output-appended"; itemId: string; turnId: string; delta: string; kind: "command" | "fileChange"; fallbackText: string; } - | { type: "message-stream/turn-diff-updated"; turnId: string; diff: string }; + | { type: "thread-stream/turn-diff-updated"; turnId: string; diff: string }; -export function isMessageStreamAction(action: { type: string }): action is MessageStreamAction { +export function isThreadStreamAction(action: { type: string }): action is ThreadStreamAction { switch (action.type) { - case "message-stream/item-added": - case "message-stream/system-item-added": - case "message-stream/deduped-log-added": - case "message-stream/history-loading-set": - case "message-stream/items-replaced": - case "message-stream/item-upserted": - case "message-stream/reasoning-completed": - case "message-stream/assistant-delta-appended": - case "message-stream/plan-delta-appended": - case "message-stream/item-text-appended": - case "message-stream/tool-output-appended": - case "message-stream/item-output-appended": - case "message-stream/turn-diff-updated": + case "thread-stream/item-added": + case "thread-stream/system-item-added": + case "thread-stream/deduped-log-added": + case "thread-stream/history-loading-set": + case "thread-stream/items-replaced": + case "thread-stream/item-upserted": + case "thread-stream/reasoning-completed": + case "thread-stream/assistant-delta-appended": + case "thread-stream/plan-delta-appended": + case "thread-stream/item-text-appended": + case "thread-stream/tool-output-appended": + case "thread-stream/item-output-appended": + case "thread-stream/turn-diff-updated": return true; default: return false; } } -export function initialChatMessageStreamState(items: readonly MessageStreamItem[] = []): ChatMessageStreamState { +export function initialChatThreadStreamState(items: readonly ThreadStreamItem[] = []): ChatThreadStreamState { return { stableItems: items, activeSegment: null, @@ -104,39 +104,39 @@ export function initialChatMessageStreamState(items: readonly MessageStreamItem[ }; } -export function messageStreamItems(state: Pick): readonly MessageStreamItem[] { +export function threadStreamItems(state: Pick): readonly ThreadStreamItem[] { if (!state.activeSegment || state.activeSegment.items.length === 0) return state.stableItems; return [...state.stableItems, ...state.activeSegment.items]; } -export function messageStreamStableItems(state: Pick): readonly MessageStreamItem[] { +export function threadStreamStableItems(state: Pick): readonly ThreadStreamItem[] { return state.stableItems; } -export function messageStreamActiveItems(state: Pick): readonly MessageStreamItem[] { +export function threadStreamActiveItems(state: Pick): readonly ThreadStreamItem[] { return state.activeSegment?.items ?? []; } -export function messageStreamIsEmpty(state: Pick): boolean { +export function threadStreamIsEmpty(state: Pick): boolean { return state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0); } -export function messageStreamTurnsAfterTurnId( - state: Pick, +export function threadStreamTurnsAfterTurnId( + state: Pick, turnId: string, ): number | null { - const turnIds = orderedTurnIds(messageStreamItems(state)); + const turnIds = orderedTurnIds(threadStreamItems(state)); const index = turnIds.indexOf(turnId); return index === -1 ? null : turnIds.length - index - 1; } -export function messageStreamRollbackCandidate( - state: Pick, -): MessageStreamRollbackCandidate | null { - return messageStreamRollbackCandidateFromItems(messageStreamItems(state)); +export function threadStreamRollbackCandidate( + state: Pick, +): ThreadStreamRollbackCandidate | null { + return threadStreamRollbackCandidateFromItems(threadStreamItems(state)); } -export function messageStreamRollbackCandidateFromItems(items: readonly MessageStreamItem[]): MessageStreamRollbackCandidate | null { +export function threadStreamRollbackCandidateFromItems(items: readonly ThreadStreamItem[]): ThreadStreamRollbackCandidate | null { const lastTurnId = latestTurnId(items); if (!lastTurnId) return null; @@ -150,11 +150,11 @@ export function messageStreamRollbackCandidateFromItems(items: readonly MessageS }; } -export function messageStreamWithItems( - state: ChatMessageStreamState, - items: readonly MessageStreamItem[], - patch: Partial> = {}, -): ChatMessageStreamState { +export function threadStreamWithItems( + state: ChatThreadStreamState, + items: readonly ThreadStreamItem[], + patch: Partial> = {}, +): ChatThreadStreamState { return patchObject(state, { stableItems: items, activeSegment: null, @@ -162,11 +162,11 @@ export function messageStreamWithItems( }); } -export function messageStreamWithActiveTurnItems( - state: ChatMessageStreamState, +export function threadStreamWithActiveTurnItems( + state: ChatThreadStreamState, turnId: string, - items: readonly MessageStreamItem[], -): ChatMessageStreamState { + items: readonly ThreadStreamItem[], +): ChatThreadStreamState { const stableItems = items.filter((item) => item.turnId !== turnId); const activeItems = items.filter((item) => item.turnId === turnId); return patchObject(state, { @@ -175,158 +175,158 @@ export function messageStreamWithActiveTurnItems( }); } -export function messageStreamStartActiveSegment( - state: ChatMessageStreamState, +export function threadStreamStartActiveSegment( + state: ChatThreadStreamState, turnId: string | null, - items: readonly MessageStreamItem[], -): ChatMessageStreamState { + items: readonly ThreadStreamItem[], +): ChatThreadStreamState { return patchObject(state, { activeSegment: activeSegmentFromItems(turnId, items) }); } -export function reduceMessageStreamSlice(state: ChatMessageStreamState, action: MessageStreamAction): ChatMessageStreamState { +export function reduceThreadStreamSlice(state: ChatThreadStreamState, action: ThreadStreamAction): ChatThreadStreamState { switch (action.type) { - case "message-stream/item-added": - case "message-stream/system-item-added": - return patchObject(state, appendMessageStreamItemPatch(state, action.item)); - case "message-stream/deduped-log-added": + case "thread-stream/item-added": + case "thread-stream/system-item-added": + return patchObject(state, appendThreadStreamItemPatch(state, action.item)); + case "thread-stream/deduped-log-added": if (state.reportedLogs.has(action.text)) return state; return patchObject(state, { reportedLogs: new Set([...state.reportedLogs, action.text]), - ...appendMessageStreamItemPatch(state, action.item), + ...appendThreadStreamItemPatch(state, action.item), }); - case "message-stream/items-replaced": - return messageStreamWithItems(state, action.items, { + case "thread-stream/items-replaced": + return threadStreamWithItems(state, action.items, { ...definedPatch("historyCursor", action.historyCursor), ...definedPatch("loadingHistory", action.loadingHistory), }); - case "message-stream/history-loading-set": + case "thread-stream/history-loading-set": return patchObject(state, { loadingHistory: action.loading }); - case "message-stream/item-upserted": - return upsertMessageStreamItem(state, action.item); - case "message-stream/reasoning-completed": - return completeReasoningInMessageStream(state, action.turnId); - case "message-stream/assistant-delta-appended": - return appendAssistantDeltaToMessageStream(state, action.itemId, action.turnId, action.delta, action.completeReasoning ?? false); - case "message-stream/plan-delta-appended": - return appendPlanDeltaToMessageStream(state, action.itemId, action.turnId, action.delta); - case "message-stream/item-text-appended": - return appendItemTextToMessageStream(state, action.itemId, action.turnId, action.label, action.delta, action.kind); - case "message-stream/tool-output-appended": - return appendToolOutputToMessageStream(state, action.itemId, action.turnId, action.delta, action.fallbackLabel); - case "message-stream/item-output-appended": - return appendItemOutputToMessageStream(state, action.itemId, action.turnId, action.delta, action.kind, action.fallbackText); - case "message-stream/turn-diff-updated": + case "thread-stream/item-upserted": + return upsertThreadStreamItem(state, action.item); + case "thread-stream/reasoning-completed": + return completeReasoningInThreadStream(state, action.turnId); + case "thread-stream/assistant-delta-appended": + return appendAssistantDeltaToThreadStream(state, action.itemId, action.turnId, action.delta, action.completeReasoning ?? false); + case "thread-stream/plan-delta-appended": + return appendPlanDeltaToThreadStream(state, action.itemId, action.turnId, action.delta); + case "thread-stream/item-text-appended": + return appendItemTextToThreadStream(state, action.itemId, action.turnId, action.label, action.delta, action.kind); + case "thread-stream/tool-output-appended": + return appendToolOutputToThreadStream(state, action.itemId, action.turnId, action.delta, action.fallbackLabel); + case "thread-stream/item-output-appended": + return appendItemOutputToThreadStream(state, action.itemId, action.turnId, action.delta, action.kind, action.fallbackText); + case "thread-stream/turn-diff-updated": return patchObject(state, { turnDiffs: updatedTurnDiffs(state.turnDiffs, action.turnId, action.diff), }); } } -function appendMessageStreamItemPatch(state: ChatMessageStreamState, item: MessageStreamItem): Partial { +function appendThreadStreamItemPatch(state: ChatThreadStreamState, item: ThreadStreamItem): Partial { if (shouldUseActiveSegment(state.activeSegment, item)) { return { activeSegment: appendActiveSegmentItem(state.activeSegment, item) }; } return { stableItems: [...state.stableItems, item] }; } -function upsertMessageStreamItem(state: ChatMessageStreamState, item: MessageStreamItem): ChatMessageStreamState { +function upsertThreadStreamItem(state: ChatThreadStreamState, item: ThreadStreamItem): ChatThreadStreamState { if (shouldUseActiveSegment(state.activeSegment, item)) { return patchObject(state, { activeSegment: upsertActiveSegmentItem(state.activeSegment, item) }); } - return patchObject(state, { stableItems: upsertMessageStreamItemById(state.stableItems, item) }); + return patchObject(state, { stableItems: upsertThreadStreamItemById(state.stableItems, item) }); } -function appendAssistantDeltaToMessageStream( - state: ChatMessageStreamState, +function appendAssistantDeltaToThreadStream( + state: ChatThreadStreamState, sourceItemId: string, turnId: string, delta: string, completeReasoning: boolean, -): ChatMessageStreamState { - const current = completeReasoning ? completeReasoningInMessageStream(state, turnId) : state; +): ChatThreadStreamState { + const current = completeReasoning ? completeReasoningInThreadStream(state, turnId) : state; return updateActiveSegment(current, turnId, (segment) => { const index = segment.indexBySourceItemId.get(sourceItemId); if (index !== undefined) { return replaceActiveSegmentItem(segment, index, (item) => - item.kind === "message" && item.messageKind === "assistantResponse" + item.kind === "dialogue" && item.dialogueKind === "assistantResponse" ? { ...item, text: `${item.text}${delta}`, copyText: `${item.text}${delta}`, turnId: item.turnId ?? turnId, - messageState: "streaming", + dialogueState: "streaming", } : item, ); } return appendActiveSegmentItem(segment, { id: sourceItemId, - kind: "message", - messageKind: "assistantResponse", + kind: "dialogue", + dialogueKind: "assistantResponse", role: "assistant", text: delta, copyText: delta, turnId, sourceItemId, provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId }, - messageState: "streaming", + dialogueState: "streaming", }); }); } -function appendPlanDeltaToMessageStream( - state: ChatMessageStreamState, +function appendPlanDeltaToThreadStream( + state: ChatThreadStreamState, sourceItemId: string, turnId: string, delta: string, -): ChatMessageStreamState { +): ChatThreadStreamState { return updateActiveSegment(state, turnId, (segment) => { const index = segment.indexBySourceItemId.get(sourceItemId); if (index !== undefined) { return replaceActiveSegmentItem(segment, index, (item) => { - if (item.kind !== "message" || item.role !== "assistant") return item; + if (item.kind !== "dialogue" || item.role !== "assistant") return item; const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`); return { ...item, - messageKind: "proposedPlan", + dialogueKind: "proposedPlan", text, copyText: text, turnId: item.turnId ?? turnId, - messageState: "streaming", + dialogueState: "streaming", }; }); } const text = normalizeProposedPlanMarkdown(delta); return appendActiveSegmentItem(segment, { id: sourceItemId, - kind: "message", - messageKind: "proposedPlan", + kind: "dialogue", + dialogueKind: "proposedPlan", role: "assistant", text, copyText: text, turnId, sourceItemId, provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId }, - messageState: "streaming", + dialogueState: "streaming", }); }); } -function appendItemTextToMessageStream( - state: ChatMessageStreamState, +function appendItemTextToThreadStream( + state: ChatThreadStreamState, sourceItemId: string, turnId: string, label: string, delta: string, kind: "tool" | "hook" | "reasoning", -): ChatMessageStreamState { +): ChatThreadStreamState { return updateActiveSegment(state, turnId, (segment) => { const index = segment.indexBySourceItemId.get(sourceItemId); if (index !== undefined) { return replaceActiveSegmentItem(segment, index, (item) => appendTextToMatchingStreamItemKind(item, kind, delta)); } return appendActiveSegmentItem(segment, { - ...streamedTextMessageStreamItem({ + ...streamedTextThreadStreamItem({ id: sourceItemId, kind, label, @@ -337,13 +337,13 @@ function appendItemTextToMessageStream( }); } -function appendToolOutputToMessageStream( - state: ChatMessageStreamState, +function appendToolOutputToThreadStream( + state: ChatThreadStreamState, sourceItemId: string, turnId: string, delta: string, fallbackLabel: string, -): ChatMessageStreamState { +): ChatThreadStreamState { return updateActiveSegment(state, turnId, (segment) => { const index = segment.indexBySourceItemId.get(sourceItemId); if (index !== undefined) { @@ -355,7 +355,7 @@ function appendToolOutputToMessageStream( } return appendActiveSegmentItem( segment, - streamedToolOutputMessageStreamItem({ + streamedToolOutputThreadStreamItem({ id: sourceItemId, turnId, output: delta, @@ -365,14 +365,14 @@ function appendToolOutputToMessageStream( }); } -function appendItemOutputToMessageStream( - state: ChatMessageStreamState, +function appendItemOutputToThreadStream( + state: ChatThreadStreamState, sourceItemId: string, turnId: string, delta: string, kind: "command" | "fileChange", fallbackText: string, -): ChatMessageStreamState { +): ChatThreadStreamState { return updateActiveSegment(state, turnId, (segment) => { const index = segment.indexBySourceItemId.get(sourceItemId); if (index !== undefined) { @@ -382,7 +382,7 @@ function appendItemOutputToMessageStream( } return appendActiveSegmentItem( segment, - streamedItemOutputMessageStreamItem({ + streamedItemOutputThreadStreamItem({ id: sourceItemId, kind, turnId, @@ -393,7 +393,7 @@ function appendItemOutputToMessageStream( }); } -function completeReasoningInMessageStream(state: ChatMessageStreamState, turnId: string): ChatMessageStreamState { +function completeReasoningInThreadStream(state: ChatThreadStreamState, turnId: string): ChatThreadStreamState { const stableItems = completeReasoningItems(state.stableItems, turnId); const activeSegment = state.activeSegment; @@ -412,10 +412,10 @@ function completeReasoningInMessageStream(state: ChatMessageStreamState, turnId: } function updateActiveSegment( - state: ChatMessageStreamState, + state: ChatThreadStreamState, turnId: string, - update: (segment: ChatMessageStreamActiveSegment) => ChatMessageStreamActiveSegment, -): ChatMessageStreamState { + update: (segment: ChatThreadStreamActiveSegment) => ChatThreadStreamActiveSegment, +): ChatThreadStreamState { const activeSegment = state.activeSegment; if (activeSegment?.turnId && activeSegment.turnId !== turnId) return state; const segment = @@ -427,38 +427,34 @@ function updateActiveSegment( return patchObject(state, { activeSegment: update(segment) }); } -function appendTextToMatchingStreamItemKind( - item: MessageStreamItem, - kind: "tool" | "hook" | "reasoning", - delta: string, -): MessageStreamItem { +function appendTextToMatchingStreamItemKind(item: ThreadStreamItem, kind: "tool" | "hook" | "reasoning", delta: string): ThreadStreamItem { if (item.kind !== kind) return item; return { ...item, text: `${item.text ?? ""}${delta}` }; } function shouldUseActiveSegment( - segment: ChatMessageStreamActiveSegment | null, - item: MessageStreamItem, -): segment is ChatMessageStreamActiveSegment { + segment: ChatThreadStreamActiveSegment | null, + item: ThreadStreamItem, +): segment is ChatThreadStreamActiveSegment { if (!segment) return false; return !item.turnId || !segment.turnId || item.turnId === segment.turnId; } -function appendActiveSegmentItem(segment: ChatMessageStreamActiveSegment, item: MessageStreamItem): ChatMessageStreamActiveSegment { +function appendActiveSegmentItem(segment: ChatThreadStreamActiveSegment, item: ThreadStreamItem): ChatThreadStreamActiveSegment { return activeSegmentFromItems(segment.turnId, [...segment.items, item]); } -function upsertActiveSegmentItem(segment: ChatMessageStreamActiveSegment, item: MessageStreamItem): ChatMessageStreamActiveSegment { +function upsertActiveSegmentItem(segment: ChatThreadStreamActiveSegment, item: ThreadStreamItem): ChatThreadStreamActiveSegment { const index = segment.indexById.get(item.id); if (index === undefined) return appendActiveSegmentItem(segment, item); - return replaceActiveSegmentItem(segment, index, (previous) => upsertMessageStreamItemById([previous], item)[0] ?? item); + return replaceActiveSegmentItem(segment, index, (previous) => upsertThreadStreamItemById([previous], item)[0] ?? item); } function replaceActiveSegmentItem( - segment: ChatMessageStreamActiveSegment, + segment: ChatThreadStreamActiveSegment, index: number, - replacement: (item: MessageStreamItem) => MessageStreamItem, -): ChatMessageStreamActiveSegment { + replacement: (item: ThreadStreamItem) => ThreadStreamItem, +): ChatThreadStreamActiveSegment { const previous = segment.items[index]; if (!previous) return segment; const next = replacement(previous); @@ -468,7 +464,7 @@ function replaceActiveSegmentItem( return activeSegmentFromItems(segment.turnId, items); } -function activeSegmentFromItems(turnId: string | null, items: readonly MessageStreamItem[]): ChatMessageStreamActiveSegment { +function activeSegmentFromItems(turnId: string | null, items: readonly ThreadStreamItem[]): ChatThreadStreamActiveSegment { const indexById = new Map(); const indexBySourceItemId = new Map(); items.forEach((item, index) => { @@ -488,7 +484,7 @@ function updatedTurnDiffs(turnDiffs: ReadonlyMap, turnId: string return next; } -function orderedTurnIds(items: readonly MessageStreamItem[]): string[] { +function orderedTurnIds(items: readonly ThreadStreamItem[]): string[] { const turnIds: string[] = []; const seen = new Set(); for (const item of items) { @@ -499,17 +495,17 @@ function orderedTurnIds(items: readonly MessageStreamItem[]): string[] { return turnIds; } -function latestTurnId(items: readonly MessageStreamItem[]): string | null { +function latestTurnId(items: readonly ThreadStreamItem[]): string | null { for (const item of [...items].reverse()) { if (item.turnId) return item.turnId; } return null; } -function promptMessageForTurn(items: readonly MessageStreamItem[], turnId: string): MessageStreamMessageItem | null { - const classification = messageStreamSemanticClassifications(items).find( - (classification) => classification.item.turnId === turnId && messageStreamIsTurnInitiator(classification), +function promptMessageForTurn(items: readonly ThreadStreamItem[], turnId: string): ThreadStreamDialogueItem | null { + const classification = threadStreamSemanticClassifications(items).find( + (classification) => classification.item.turnId === turnId && threadStreamIsTurnInitiator(classification), ); const item = classification?.item; - return item?.kind === "message" ? item : null; + return item?.kind === "dialogue" ? item : null; } diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index 4fd066ba..dc3a4cd2 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -1,6 +1,6 @@ import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal"; -import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; -import type { GoalMessageStreamItem } from "../../domain/message-stream/items"; +import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items"; +import type { GoalThreadStreamItem } from "../../domain/thread-stream/items"; import type { LocalIdSource } from "../local-id-source"; import type { ChatStateStore } from "../state/store"; import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport"; @@ -10,7 +10,7 @@ export interface ThreadGoalSyncHost { goalTransport: ThreadGoalReadTransport; localItemIds: LocalIdSource; addSystemMessage: (text: string) => void; - addGoalEvent: (item: GoalMessageStreamItem) => void; + addGoalEvent: (item: GoalThreadStreamItem) => void; refreshLiveState: () => void; } diff --git a/src/features/chat/application/threads/history-controller.ts b/src/features/chat/application/threads/history-controller.ts index c199e9d6..1d8306c3 100644 --- a/src/features/chat/application/threads/history-controller.ts +++ b/src/features/chat/application/threads/history-controller.ts @@ -1,6 +1,6 @@ -import { messageStreamItems } from "../state/message-stream"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import { threadStreamItems } from "../state/thread-stream"; import type { ThreadHistoryPage, ThreadHistoryTransport } from "./thread-loading-transport"; export interface HistoryControllerHost { @@ -33,7 +33,7 @@ export class HistoryController { invalidate(): void { this.lifecycle = transitionThreadHistoryLoadLifecycle(this.lifecycle, { type: "invalidated" }); - this.dispatch({ type: "message-stream/history-loading-set", loading: false }); + this.dispatch({ type: "thread-stream/history-loading-set", loading: false }); } async loadLatest(threadId = this.state.activeThread.id): Promise { @@ -57,7 +57,7 @@ export class HistoryController { this.host.setThreadTurnPresence(response.hadTurns); this.host.showLatestPageAtBottom(); this.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: response.items, historyCursor: response.nextCursor, }); @@ -66,20 +66,20 @@ export class HistoryController { async loadOlder(): Promise { const state = this.state; - if (!state.activeThread.id || !state.messageStream.historyCursor || state.messageStream.loadingHistory) return; + if (!state.activeThread.id || !state.threadStream.historyCursor || state.threadStream.loadingHistory) return; const threadId = state.activeThread.id; - const cursor = state.messageStream.historyCursor; + const cursor = state.threadStream.historyCursor; const load = this.startLoading(threadId, "older"); try { const response = await this.host.historyTransport.readHistoryPage(threadId, cursor, 20); if (!response) return; if (this.isStale(load)) return; const current = this.state; - const currentItems = messageStreamItems(current.messageStream); + const currentItems = threadStreamItems(current.threadStream); const olderItems = response.items; const existingIds = new Set(currentItems.map((item) => item.id)); this.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: [...olderItems.filter((item) => !existingIds.has(item.id)), ...currentItems], historyCursor: response.nextCursor, }); @@ -94,14 +94,14 @@ export class HistoryController { private startLoading(threadId: string, mode: ActiveThreadHistoryLoad["mode"]): ActiveThreadHistoryLoad { const load: ActiveThreadHistoryLoad = { kind: "loading", threadId, mode }; this.lifecycle = transitionThreadHistoryLoadLifecycle(this.lifecycle, { type: "started", load }); - this.dispatch({ type: "message-stream/history-loading-set", loading: true }); + this.dispatch({ type: "thread-stream/history-loading-set", loading: true }); return load; } private finishLoading(load: ActiveThreadHistoryLoad): void { if (this.isStale(load)) return; this.lifecycle = transitionThreadHistoryLoadLifecycle(this.lifecycle, { type: "finished", load }); - this.dispatch({ type: "message-stream/history-loading-set", loading: false }); + this.dispatch({ type: "thread-stream/history-loading-set", loading: false }); } private isStale(load: ActiveThreadHistoryLoad): boolean { diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index 37173776..136ce902 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -1,10 +1,10 @@ import { threadRenameDraftTitle } from "../../../../domain/threads/title"; import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model"; -import { messageStreamItems } from "../state/message-stream"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import { threadStreamItems } from "../state/thread-stream"; import { type ChatRenameGeneratingUiState, type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state"; -import { firstThreadTitleContextFromMessageStreamItems } from "./title-context"; +import { firstThreadTitleContextFromThreadStreamItems } from "./title-context"; interface RenameEditState { draft: string; @@ -113,7 +113,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH } export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null { - return state.activeThread.id === threadId ? firstThreadTitleContextFromMessageStreamItems(messageStreamItems(state.messageStream)) : null; + return state.activeThread.id === threadId ? firstThreadTitleContextFromThreadStreamItems(threadStreamItems(state.threadStream)) : null; } function renameState(host: ThreadRenameEditorActionsHost): ChatRenameUiState { diff --git a/src/features/chat/application/threads/resume-actions.ts b/src/features/chat/application/threads/resume-actions.ts index 7f1cedfb..66c62b33 100644 --- a/src/features/chat/application/threads/resume-actions.ts +++ b/src/features/chat/application/threads/resume-actions.ts @@ -1,7 +1,7 @@ import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; import { resumedThreadAction } from "../state/actions"; -import { messageStreamIsEmpty } from "../state/message-stream"; import type { ChatStateStore } from "../state/store"; +import { threadStreamIsEmpty } from "../state/thread-stream"; import type { HistoryController } from "./history-controller"; import type { RestorationController } from "./restoration-controller"; import type { ActiveChatResume, ChatResumeWorkTracker } from "./resume-work"; @@ -55,7 +55,7 @@ async function resumeThread(host: ResumeActionsHost, threadId: string): Promise< if (isStaleResume(host, resume)) return; await host.syncThreadGoal(response.activation.thread.id); if (isStaleResume(host, resume)) return; - const renderFallbackMessage = messageStreamIsEmpty(host.stateStore.getState().messageStream); + const renderFallbackMessage = threadStreamIsEmpty(host.stateStore.getState().threadStream); if (renderFallbackMessage) { host.addSystemMessage(`Resumed thread ${response.activation.thread.id}`); } diff --git a/src/features/chat/application/threads/thread-loading-transport.ts b/src/features/chat/application/threads/thread-loading-transport.ts index cbb2a174..1112fe91 100644 --- a/src/features/chat/application/threads/thread-loading-transport.ts +++ b/src/features/chat/application/threads/thread-loading-transport.ts @@ -1,8 +1,8 @@ import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; export interface ThreadHistoryPage { - items: MessageStreamItem[]; + items: ThreadStreamItem[]; nextCursor: string | null; hadTurns: boolean; } diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index b9818f44..498fdd5d 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,10 +1,10 @@ import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model"; import { activeThreadRuntimeState } from "../../domain/runtime/state"; -import { chatTurnBusy } from "../conversation/turn-state"; import { resumedThreadActionFromActiveRuntime } from "../state/actions"; -import { messageStreamRollbackCandidate, messageStreamTurnsAfterTurnId } from "../state/message-stream"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream"; +import { chatTurnBusy } from "../turns/turn-state"; import type { ThreadMutationTransport } from "./thread-mutation-transport"; const STATUS_COMPACTION_REQUESTED = "Compaction requested."; @@ -113,7 +113,7 @@ async function forkThreadFromTurn( } const scope = captureThreadManagementPanelScope(host, threadId); - const selectedTurnDistanceFromEnd = turnId ? messageStreamTurnsAfterTurnId(threadManagementState(host).messageStream, turnId) : 0; + const selectedTurnDistanceFromEnd = turnId ? threadStreamTurnsAfterTurnId(threadManagementState(host).threadStream, turnId) : 0; if (selectedTurnDistanceFromEnd === null) { host.addSystemMessage("Could not find the selected turn to fork."); return; @@ -173,7 +173,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin } const scope = captureThreadManagementPanelScope(host, threadId); - const candidate = messageStreamRollbackCandidate(threadManagementState(host).messageStream); + const candidate = threadStreamRollbackCandidate(threadManagementState(host).threadStream); if (!candidate) { host.addSystemMessage("No completed turn to roll back."); return; @@ -194,7 +194,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin }), ); threadManagementDispatch(host, { - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: snapshot.items, historyCursor: null, loadingHistory: false, diff --git a/src/features/chat/application/threads/thread-mutation-transport.ts b/src/features/chat/application/threads/thread-mutation-transport.ts index 6b28e778..2069d254 100644 --- a/src/features/chat/application/threads/thread-mutation-transport.ts +++ b/src/features/chat/application/threads/thread-mutation-transport.ts @@ -1,10 +1,10 @@ import type { Thread } from "../../../../domain/threads/model"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; export interface ThreadRollbackSnapshot { thread: Thread; cwd: string; - items: MessageStreamItem[]; + items: ThreadStreamItem[]; } export interface ThreadMutationTransport { diff --git a/src/features/chat/application/threads/thread-navigation-actions.ts b/src/features/chat/application/threads/thread-navigation-actions.ts index cdef5ac9..03adf18f 100644 --- a/src/features/chat/application/threads/thread-navigation-actions.ts +++ b/src/features/chat/application/threads/thread-navigation-actions.ts @@ -1,5 +1,5 @@ -import { chatTurnBusy } from "../conversation/turn-state"; import type { ChatStateStore } from "../state/store"; +import { chatTurnBusy } from "../turns/turn-state"; import type { ActiveThreadIdentitySync } from "./active-thread-identity-sync"; import { canSwitchToThread } from "./thread-switching"; diff --git a/src/features/chat/application/threads/thread-switching.ts b/src/features/chat/application/threads/thread-switching.ts index 74db50a7..f3640594 100644 --- a/src/features/chat/application/threads/thread-switching.ts +++ b/src/features/chat/application/threads/thread-switching.ts @@ -1,5 +1,5 @@ -import { chatTurnBusy } from "../conversation/turn-state"; import type { ChatState } from "../state/root-reducer"; +import { chatTurnBusy } from "../turns/turn-state"; export function canSwitchToThread(state: ChatState, threadId: string): boolean { return !chatTurnBusy(state) || threadId === state.activeThread.id; diff --git a/src/features/chat/application/threads/title-context.ts b/src/features/chat/application/threads/title-context.ts index 5693d80a..0ec14bf4 100644 --- a/src/features/chat/application/threads/title-context.ts +++ b/src/features/chat/application/threads/title-context.ts @@ -1,10 +1,10 @@ import { type ThreadTitleContext, threadTitleContextPromptText } from "../../../../domain/threads/title-generation-model"; -import type { MessageStreamItem, MessageStreamMessageItem } from "../../domain/message-stream/items"; -import { isCompletedTurnOutcomeMessage } from "../../domain/message-stream/selectors"; +import type { ThreadStreamDialogueItem, ThreadStreamItem } from "../../domain/thread-stream/items"; +import { isCompletedTurnOutcomeMessage } from "../../domain/thread-stream/selectors"; -export function threadTitleContextFromMessageStreamItems(turnId: string, items: readonly MessageStreamItem[]): ThreadTitleContext | null { +export function threadTitleContextFromThreadStreamItems(turnId: string, items: readonly ThreadStreamItem[]): ThreadTitleContext | null { const turnItems = items.filter((item) => item.turnId === turnId); - const userRequest = turnItems.find(isUserMessageStreamItem)?.text.trim() ?? precedingUnscopedTitleSeed(turnId, items) ?? ""; + const userRequest = turnItems.find(isUserThreadStreamDialogueItem)?.text.trim() ?? precedingUnscopedTitleSeed(turnId, items) ?? ""; const assistantResponse = [...turnItems].reverse().find(isCompletedTurnOutcomeMessageItem)?.text.trim() ?? ""; if (!userRequest || !assistantResponse) return null; return { @@ -13,32 +13,32 @@ export function threadTitleContextFromMessageStreamItems(turnId: string, items: }; } -export function firstThreadTitleContextFromMessageStreamItems(items: readonly MessageStreamItem[]): ThreadTitleContext | null { +export function firstThreadTitleContextFromThreadStreamItems(items: readonly ThreadStreamItem[]): ThreadTitleContext | null { const turnIds = new Set(); for (const item of items) { if (!item.turnId || turnIds.has(item.turnId)) continue; turnIds.add(item.turnId); - const context = threadTitleContextFromMessageStreamItems(item.turnId, items); + const context = threadTitleContextFromThreadStreamItems(item.turnId, items); if (context) return context; } return null; } -function isUserMessageStreamItem(item: MessageStreamItem): item is MessageStreamMessageItem & { role: "user" } { - return item.kind === "message" && item.role === "user"; +function isUserThreadStreamDialogueItem(item: ThreadStreamItem): item is ThreadStreamDialogueItem & { role: "user" } { + return item.kind === "dialogue" && item.role === "user"; } -function isCompletedTurnOutcomeMessageItem(item: MessageStreamItem): item is MessageStreamMessageItem { - return item.kind === "message" && isCompletedTurnOutcomeMessage(item); +function isCompletedTurnOutcomeMessageItem(item: ThreadStreamItem): item is ThreadStreamDialogueItem { + return item.kind === "dialogue" && isCompletedTurnOutcomeMessage(item); } -function precedingUnscopedTitleSeed(turnId: string, items: readonly MessageStreamItem[]): string | null { +function precedingUnscopedTitleSeed(turnId: string, items: readonly ThreadStreamItem[]): string | null { const firstTurnItemIndex = items.findIndex((item) => item.turnId === turnId); if (firstTurnItemIndex < 1) return null; for (let index = firstTurnItemIndex - 1; index >= 0; index -= 1) { const item = items[index]; if (!item || item.turnId) return null; - if (item.kind === "message" && item.role === "user") return item.text.trim(); + if (item.kind === "dialogue" && item.role === "user") return item.text.trim(); if (item.kind === "goal" && item.objective) return item.objective.trim(); } return null; diff --git a/src/features/chat/application/conversation/composer-submit-actions.ts b/src/features/chat/application/turns/composer-submit-actions.ts similarity index 100% rename from src/features/chat/application/conversation/composer-submit-actions.ts rename to src/features/chat/application/turns/composer-submit-actions.ts diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/turns/composition.ts similarity index 87% rename from src/features/chat/application/conversation/composition.ts rename to src/features/chat/application/turns/composition.ts index ed3ce113..3ee07d84 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/turns/composition.ts @@ -1,6 +1,6 @@ import type { CodexInput } from "../../../../domain/chat/input"; import type { Thread } from "../../../../domain/threads/model"; -import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; +import type { ThreadStreamNoticeSection } from "../../domain/thread-stream/items"; import type { ComposerInputSnapshot } from "../composer/input-snapshot"; import type { LocalIdSource } from "../local-id-source"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; @@ -14,7 +14,7 @@ import { executeSlashCommandWithState, type SlashCommandExecutorHost } from "./s import { createTurnSubmissionActions } from "./turn-submission-actions"; import type { ChatTurnTransport } from "./turn-transport"; -export interface ConversationTurnActionsContext { +export interface TurnWorkflowContext { stateStore: ChatStateStore; localItemIds: LocalIdSource; connectionAvailable: () => boolean; @@ -24,15 +24,15 @@ export interface ConversationTurnActionsContext { status: { set: (status: string) => void; addSystemMessage: (text: string) => void; - addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; + addStructuredSystemMessage: (text: string, details: ThreadStreamNoticeSection[]) => void; }; runtime: { - connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; - permissionDetails: () => MessageStreamNoticeSection[]; + connectionDiagnosticDetails: () => ThreadStreamNoticeSection[]; + permissionDetails: () => ThreadStreamNoticeSection[]; modelStatusLines: () => string[]; effortStatusLines: () => string[]; statusSummaryLines: () => string[]; - toolInventoryDetails: () => MessageStreamNoticeSection[] | Promise; + toolInventoryDetails: () => ThreadStreamNoticeSection[] | Promise; }; thread: { ensureRestoredThreadLoaded: () => Promise; @@ -52,15 +52,15 @@ export interface ConversationTurnActionsContext { }; } -export interface ConversationTurnActionsRefs { - threadStarter: ConversationThreadStarter; +export interface TurnWorkflowRefs { + threadStarter: TurnWorkflowThreadStarter; runtimeSettings: ChatRuntimeSettingsActions; threadActions: ThreadManagementActions; reconnectPanel: () => Promise; goals: GoalActions; } -interface ConversationThreadStarter { +interface TurnWorkflowThreadStarter { startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>; } @@ -68,15 +68,12 @@ interface PlanImplementation { implement: (itemId: string) => Promise; } -export interface ConversationTurnActions { +export interface TurnWorkflowActions { planImplementation: PlanImplementation; composerSubmit: ComposerSubmitActions; } -export function createConversationTurnActions( - context: ConversationTurnActionsContext, - refs: ConversationTurnActionsRefs, -): ConversationTurnActions { +export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: TurnWorkflowRefs): TurnWorkflowActions { const { stateStore, localItemIds, connectionAvailable, turnTransport, referThread, clipUrl, status, runtime, thread, composer, scroll } = context; const turnSubmission = createTurnSubmissionActions({ @@ -159,7 +156,7 @@ export function createConversationTurnActions( }; } -async function startThreadForGoal(starter: ConversationThreadStarter, objective: string): Promise { +async function startThreadForGoal(starter: TurnWorkflowThreadStarter, objective: string): Promise { const response = await starter.startThread(objective, { syncGoal: false }); return response?.threadId ?? null; } diff --git a/src/features/chat/application/conversation/optimistic-turn-start.ts b/src/features/chat/application/turns/optimistic-turn-start.ts similarity index 74% rename from src/features/chat/application/conversation/optimistic-turn-start.ts rename to src/features/chat/application/turns/optimistic-turn-start.ts index d67d3545..f3cfab7e 100644 --- a/src/features/chat/application/conversation/optimistic-turn-start.ts +++ b/src/features/chat/application/turns/optimistic-turn-start.ts @@ -1,10 +1,10 @@ import type { CodexInput } from "../../../../domain/chat/input"; -import { fileMentionsFromInput } from "../../domain/message-stream/format/file-mentions"; -import { userMessageDisplayText } from "../../domain/message-stream/format/user-message-text"; -import type { MessageStreamFileMention, MessageStreamItem, MessageStreamMessageItem } from "../../domain/message-stream/items"; -import { isLocalSteerMessageClientId } from "../../domain/message-stream/local-message-ids"; -import type { MessageStreamItemProvenance } from "../../domain/message-stream/provenance"; -import { attachHookRunsToTurn } from "../../domain/message-stream/updates"; +import { fileMentionsFromInput } from "../../domain/thread-stream/format/file-mentions"; +import { userMessageDisplayText } from "../../domain/thread-stream/format/user-message-text"; +import type { ThreadStreamDialogueItem, ThreadStreamFileMention, ThreadStreamItem } from "../../domain/thread-stream/items"; +import { isLocalSteerMessageClientId } from "../../domain/thread-stream/local-message-ids"; +import type { ThreadStreamItemProvenance } from "../../domain/thread-stream/provenance"; +import { attachHookRunsToTurn } from "../../domain/thread-stream/updates"; import type { PendingTurnStart } from "./turn-state"; interface LocalUserMessageParams { @@ -12,12 +12,12 @@ interface LocalUserMessageParams { text: string; copyText?: string; turnId?: string; - referencedThread?: MessageStreamMessageItem["referencedThread"]; - mentionedFiles?: readonly MessageStreamFileMention[]; + referencedThread?: ThreadStreamDialogueItem["referencedThread"]; + mentionedFiles?: readonly ThreadStreamFileMention[]; } export interface OptimisticTurnStartAckParams { - items: readonly MessageStreamItem[]; + items: readonly ThreadStreamItem[]; optimisticUserId: string; turnId: string; pendingTurnStart: PendingTurnStart | null; @@ -28,7 +28,7 @@ export interface LocalUserMessageFromInputParams extends Omit (item.id === params.optimisticUserId ? { ...item, turnId: params.turnId } : item)); if (!params.pendingTurnStart) return items; return attachHookRunsToTurn(items, params.turnId, params.pendingTurnStart.promptSubmitHookItemIds, params.pendingTurnStart.anchorItemId); } -export function cleanupFailedTurnStart(params: FailedTurnStartCleanupParams): MessageStreamItem[] { +export function cleanupFailedTurnStart(params: FailedTurnStartCleanupParams): ThreadStreamItem[] { const withoutOptimisticUser = params.optimisticUserId ? params.items.filter((item) => item.id !== params.optimisticUserId) : [...params.items]; diff --git a/src/features/chat/application/conversation/plan-implementation.ts b/src/features/chat/application/turns/plan-implementation.ts similarity index 82% rename from src/features/chat/application/conversation/plan-implementation.ts rename to src/features/chat/application/turns/plan-implementation.ts index aa5af45d..3897bcd5 100644 --- a/src/features/chat/application/conversation/plan-implementation.ts +++ b/src/features/chat/application/turns/plan-implementation.ts @@ -1,8 +1,8 @@ -import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/message-stream/selectors"; import type { ChatRuntimeState } from "../../domain/runtime/state"; -import { type ChatMessageStreamState, messageStreamItems } from "../state/message-stream"; +import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/thread-stream/selectors"; import type { ChatActiveThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import { type ChatThreadStreamState, threadStreamItems } from "../state/thread-stream"; import { type ChatTurnState, chatTurnBusy } from "./turn-state"; const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; @@ -18,7 +18,7 @@ export function implementPlanTargetFromState(state: { activeThread: Pick; turn: ChatTurnState; runtime: { pending: Pick }; - messageStream: Pick; + threadStream: Pick; }): PlanImplementationTarget | null { if ( !state.activeThread.id || @@ -28,7 +28,7 @@ export function implementPlanTargetFromState(state: { ) { return null; } - return latestImplementablePlanTargetFromItems(messageStreamItems(state.messageStream)); + return latestImplementablePlanTargetFromItems(threadStreamItems(state.threadStream)); } export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise { diff --git a/src/features/chat/application/turns/runtime-event-plan.ts b/src/features/chat/application/turns/runtime-event-plan.ts new file mode 100644 index 00000000..e0fc2f12 --- /dev/null +++ b/src/features/chat/application/turns/runtime-event-plan.ts @@ -0,0 +1,237 @@ +import { reconcileCompletedTurnItems } from "../../domain/thread-stream/completed-turn-reconciliation"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import { attachHookRunsToTurn, completeReasoningItems, upsertThreadStreamItemById } from "../../domain/thread-stream/updates"; +import { type ChatAction, type ChatState, chatReducer } from "../state/root-reducer"; +import { threadStreamItems } from "../state/thread-stream"; +import type { TurnRuntimeEvent } from "./runtime-events"; +import { activeTurnId, pendingTurnStart as pendingTurnStartForState } from "./turn-state"; + +export type TurnRuntimeOutcome = + | { type: "turn-started"; threadId: string; turnId: string; recencyAt: number | null } + | { type: "turn-completed"; threadId: string; turnId: string; completedSummary: TurnRuntimeEventCompletedSummary }; + +type TurnRuntimeEventCompletedSummary = Extract["completedSummary"]; + +export interface TurnRuntimePlan { + actions: readonly ChatAction[]; + outcomes: readonly TurnRuntimeOutcome[]; +} + +const EMPTY_PLAN: TurnRuntimePlan = { actions: [], outcomes: [] }; + +export function planTurnRuntimeEvents(state: ChatState, events: readonly TurnRuntimeEvent[]): TurnRuntimePlan { + let currentState = state; + const actions: ChatAction[] = []; + const outcomes: TurnRuntimeOutcome[] = []; + for (const event of events) { + const plan = planTurnRuntimeEvent(currentState, event); + actions.push(...plan.actions); + outcomes.push(...plan.outcomes); + currentState = reducePlannedActions(currentState, plan.actions); + } + return actions.length === 0 && outcomes.length === 0 ? EMPTY_PLAN : { actions, outcomes }; +} + +function planTurnRuntimeEvent(state: ChatState, event: TurnRuntimeEvent): TurnRuntimePlan { + switch (event.type) { + case "assistantDelta": + return actionPlan({ + type: "thread-stream/assistant-delta-appended", + itemId: event.itemId, + turnId: event.turnId, + delta: event.delta, + completeReasoning: event.completeReasoning, + }); + case "planDelta": + return actionPlan({ + type: "thread-stream/plan-delta-appended", + itemId: event.itemId, + turnId: event.turnId, + delta: event.delta, + }); + case "textDelta": + return actionPlan({ + type: "thread-stream/item-text-appended", + itemId: event.itemId, + turnId: event.turnId, + label: event.label, + delta: event.delta, + kind: event.kind, + }); + case "toolOutputDelta": + return actionPlan({ + type: "thread-stream/tool-output-appended", + itemId: event.itemId, + turnId: event.turnId, + delta: event.delta, + fallbackLabel: event.fallbackLabel, + }); + case "itemOutputDelta": + return actionPlan({ + type: "thread-stream/item-output-appended", + itemId: event.itemId, + turnId: event.turnId, + delta: event.delta, + kind: event.kind, + fallbackText: event.fallbackText, + }); + case "itemUpserted": + return actionPlan({ type: "thread-stream/item-upserted", item: event.item }); + case "itemCompleted": + return completedItemPlan(event.item, event.turnId); + case "autoReviewUpdated": + return autoReviewUpdatedPlan(state, event.item); + case "turnStarted": + return turnStartedPlan(state, event); + case "turnCompleted": + return turnCompletedPlan(state, event); + case "turnDiffUpdated": + return actionPlan({ type: "thread-stream/turn-diff-updated", turnId: event.turnId, diff: event.diff }); + case "hookRunObserved": + return hookRunPlan(state, event); + case "requestResolved": + return actionPlan({ type: "request/resolved", requestId: event.requestId }); + case "reviewWarning": + return reviewWarningPlan(state, event.item); + case "systemNotice": + return actionPlan({ type: "thread-stream/system-item-added", item: event.item }); + } +} + +function turnStartedPlan(state: ChatState, event: Extract): TurnRuntimePlan { + return { + actions: [ + { + type: "turn/started", + threadId: event.threadId, + turnId: event.turnId, + items: threadStreamItemsWithPendingPromptSubmitHooks(state, event.turnId), + }, + ], + outcomes: [ + { + type: "turn-started", + threadId: event.threadId, + turnId: event.turnId, + recencyAt: event.recencyAt, + }, + ], + }; +} + +function turnCompletedPlan(state: ChatState, event: Extract): TurnRuntimePlan { + if (activeTurnId(state) !== event.turnId) return EMPTY_PLAN; + return { + actions: [ + { + type: "turn/completed", + turnId: event.turnId, + status: event.status, + items: completeReasoningItems( + reconcileCompletedTurnItems({ + currentItems: threadStreamItems(state.threadStream), + completedTurnId: event.turnId, + turnItems: event.completedItems, + }), + event.turnId, + ), + }, + ], + outcomes: [ + { + type: "turn-completed", + threadId: event.threadId, + turnId: event.turnId, + completedSummary: event.completedSummary, + }, + ], + }; +} + +function completedItemPlan(item: ThreadStreamItem, turnId: string): TurnRuntimePlan { + return { + actions: [ + { type: "thread-stream/item-upserted", item }, + ...(item.kind === "reasoning" ? ([{ type: "thread-stream/reasoning-completed", turnId: turnId }] satisfies ChatAction[]) : []), + ], + outcomes: [], + }; +} + +function hookRunPlan(state: ChatState, event: Extract): TurnRuntimePlan { + const resolvedTurnId = hookTurnId(state, event); + const item = resolvedTurnId ? { ...event.item, turnId: resolvedTurnId } : event.item; + const currentPendingTurnStart = pendingTurnStartForState(state); + let pendingTurnStart = currentPendingTurnStart; + if (!resolvedTurnId && currentPendingTurnStart && event.eventName === "userPromptSubmit") { + const hookIds = currentPendingTurnStart.promptSubmitHookItemIds; + pendingTurnStart = hookIds.includes(item.id) + ? currentPendingTurnStart + : { ...currentPendingTurnStart, promptSubmitHookItemIds: [...hookIds, item.id] }; + } + return actionPlan({ + type: "turn/pending-start-hook-upserted", + item, + pendingTurnStart, + }); +} + +function hookTurnId(state: ChatState, event: Extract): string | null { + if (event.turnId) return event.turnId; + if (event.eventName === "userPromptSubmit" && !pendingTurnStartForState(state)) return activeTurnId(state); + return null; +} + +function reviewWarningPlan(state: ChatState, item: ThreadStreamItem): TurnRuntimePlan { + if (isUnstructuredAutoReviewWarning(item) && hasStructuredAutoReviewResult(threadStreamItems(state.threadStream), activeTurnId(state))) { + return EMPTY_PLAN; + } + return actionPlan({ type: "thread-stream/item-upserted", item }); +} + +function autoReviewUpdatedPlan(state: ChatState, item: ThreadStreamItem): TurnRuntimePlan { + return actionPlan({ + type: "thread-stream/items-replaced", + items: upsertThreadStreamItemById( + threadStreamItems(state.threadStream).filter((currentItem) => !isUnstructuredAutoReviewWarning(currentItem)), + item, + ), + }); +} + +function threadStreamItemsWithPendingPromptSubmitHooks(state: ChatState, turnId: string): readonly ThreadStreamItem[] { + const pending = pendingTurnStartForState(state); + const items = threadStreamItems(state.threadStream); + if (!pending) return items; + return attachHookRunsToTurn(items, turnId, pending.promptSubmitHookItemIds, pending.anchorItemId); +} + +function hasStructuredAutoReviewResult(items: readonly ThreadStreamItem[], activeTurnId: string | null): boolean { + return items.some( + (item) => + item.kind === "reviewResult" && + Boolean(item.turnId) && + (!activeTurnId || item.turnId === activeTurnId) && + isAutoReviewText(item.text), + ); +} + +function isUnstructuredAutoReviewWarning(item: ThreadStreamItem): boolean { + return item.kind === "reviewResult" && !item.turnId && isAutoReviewText(item.text); +} + +function isAutoReviewText(text: string): boolean { + return /^Auto-review\b/i.test(text.trim()); +} + +function reducePlannedActions(state: ChatState, actions: readonly ChatAction[]): ChatState { + return actions.reduce(reducePlannedAction, state); +} + +function reducePlannedAction(state: ChatState, action: ChatAction): ChatState { + return chatReducer(state, action); +} + +function actionPlan(action: ChatAction): TurnRuntimePlan { + return { actions: [action], outcomes: [] }; +} diff --git a/src/features/chat/application/conversation/runtime-events.ts b/src/features/chat/application/turns/runtime-events.ts similarity index 60% rename from src/features/chat/application/conversation/runtime-events.ts rename to src/features/chat/application/turns/runtime-events.ts index 23d900ac..d84a8caa 100644 --- a/src/features/chat/application/conversation/runtime-events.ts +++ b/src/features/chat/application/turns/runtime-events.ts @@ -1,83 +1,83 @@ import type { PendingRequestId } from "../../../../domain/pending-requests/model"; import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; -type ConversationRuntimeTextItemKind = "tool" | "hook" | "reasoning"; -type ConversationRuntimeOutputItemKind = "command" | "fileChange"; +type TurnRuntimeTextItemKind = "tool" | "hook" | "reasoning"; +type TurnRuntimeOutputItemKind = "command" | "fileChange"; -export type ConversationRuntimeEvent = +export type TurnRuntimeEvent = | { type: "assistantDelta"; - runId: string; + turnId: string; itemId: string; delta: string; completeReasoning: boolean; } | { type: "planDelta"; - runId: string; + turnId: string; itemId: string; delta: string; } | { type: "textDelta"; - runId: string; + turnId: string; itemId: string; label: string; delta: string; - kind: ConversationRuntimeTextItemKind; + kind: TurnRuntimeTextItemKind; } | { type: "toolOutputDelta"; - runId: string; + turnId: string; itemId: string; delta: string; fallbackLabel: string; } | { type: "itemOutputDelta"; - runId: string; + turnId: string; itemId: string; delta: string; - kind: ConversationRuntimeOutputItemKind; + kind: TurnRuntimeOutputItemKind; fallbackText: string; } | { type: "itemUpserted"; - item: MessageStreamItem; + item: ThreadStreamItem; } | { type: "itemCompleted"; - runId: string; - item: MessageStreamItem; + turnId: string; + item: ThreadStreamItem; } | { type: "autoReviewUpdated"; - item: MessageStreamItem; + item: ThreadStreamItem; } | { - type: "runStarted"; + type: "turnStarted"; threadId: string; - runId: string; + turnId: string; recencyAt: number | null; } | { - type: "runCompleted"; + type: "turnCompleted"; threadId: string; - runId: string; + turnId: string; status: string; - completedItems: readonly MessageStreamItem[]; + completedItems: readonly ThreadStreamItem[]; completedSummary: ThreadConversationSummary | null; } | { type: "turnDiffUpdated"; - runId: string; + turnId: string; diff: string; } | { type: "hookRunObserved"; - item: MessageStreamItem; - runId: string | null; + item: ThreadStreamItem; + turnId: string | null; eventName: string; } | { @@ -86,9 +86,9 @@ export type ConversationRuntimeEvent = } | { type: "reviewWarning"; - item: MessageStreamItem; + item: ThreadStreamItem; } | { type: "systemNotice"; - item: MessageStreamItem; + item: ThreadStreamItem; }; diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts similarity index 96% rename from src/features/chat/application/conversation/slash-command-execution.ts rename to src/features/chat/application/turns/slash-command-execution.ts index 54051ac1..3fca412e 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -7,8 +7,8 @@ import type { Thread } from "../../../../domain/threads/model"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import { resolveThreadSearchQuery } from "../../../../domain/threads/search"; import { threadDisplayTitle } from "../../../../domain/threads/title"; -import type { MessageStreamAuditFact, MessageStreamNoticeSection } from "../../domain/message-stream/items"; import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels"; +import type { ThreadStreamAuditFact, ThreadStreamNoticeSection } from "../../domain/thread-stream/items"; import type { ComposerInputSnapshot } from "../composer/input-snapshot"; import { type SlashCommandName, @@ -37,7 +37,7 @@ export interface SlashCommandExecutionPorts { }; reconnect: () => Promise; addSystemMessage: (text: string) => void; - addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; + addStructuredSystemMessage: (text: string, details: ThreadStreamNoticeSection[]) => void; runtimeSettings: { toggleFastMode: ChatRuntimeSettingsActions["toggleFastMode"]; toggleCollaborationMode: ChatRuntimeSettingsActions["toggleCollaborationMode"]; @@ -56,9 +56,9 @@ export interface SlashCommandExecutionPorts { clear: GoalActions["clear"]; }; statusSummaryLines: () => string[]; - permissionDetails: () => MessageStreamNoticeSection[]; - connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; - toolInventoryDetails: () => MessageStreamNoticeSection[] | Promise; + permissionDetails: () => ThreadStreamNoticeSection[]; + connectionDiagnosticDetails: () => ThreadStreamNoticeSection[]; + toolInventoryDetails: () => ThreadStreamNoticeSection[] | Promise; modelStatusLines: () => string[]; effortStatusLines: () => string[]; } @@ -423,8 +423,8 @@ function goalUsageError(message: string): string { ); } -function goalDetails(goal: ThreadGoal): MessageStreamNoticeSection[] { - const auditFacts: MessageStreamAuditFact[] = [ +function goalDetails(goal: ThreadGoal): ThreadStreamNoticeSection[] { + const auditFacts: ThreadStreamAuditFact[] = [ { key: "status", value: goal.status }, { key: "objective", value: goal.objective }, { @@ -451,13 +451,13 @@ function usageError(command: SlashCommandName, message: string): string { return `${definition.command} ${message}. Usage: ${definition.usage}`; } -function detailsFromLines(lines: string[]): MessageStreamNoticeSection[] { +function detailsFromLines(lines: string[]): ThreadStreamNoticeSection[] { const first = lines[0] ?? ""; const content = first.includes(": ") ? lines : lines.slice(1); return [{ auditFacts: content.map(lineToRow) }]; } -function lineToRow(line: string): MessageStreamAuditFact { +function lineToRow(line: string): ThreadStreamAuditFact { const separator = line.indexOf(": "); if (separator > 0) { return { diff --git a/src/features/chat/application/conversation/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts similarity index 100% rename from src/features/chat/application/conversation/slash-command-executor.ts rename to src/features/chat/application/turns/slash-command-executor.ts diff --git a/src/features/chat/application/conversation/submission-state.ts b/src/features/chat/application/turns/submission-state.ts similarity index 76% rename from src/features/chat/application/conversation/submission-state.ts rename to src/features/chat/application/turns/submission-state.ts index 014c1f71..4afcac3e 100644 --- a/src/features/chat/application/conversation/submission-state.ts +++ b/src/features/chat/application/turns/submission-state.ts @@ -1,7 +1,7 @@ import type { Thread } from "../../../../domain/threads/model"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; -import { messageStreamItems } from "../state/message-stream"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; import type { ChatState } from "../state/root-reducer"; +import { threadStreamItems } from "../state/thread-stream"; import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state"; export interface SubmissionStateSnapshot { @@ -9,7 +9,7 @@ export interface SubmissionStateSnapshot { activeTurnId: string | null; busy: boolean; listedThreads: readonly Thread[]; - items: readonly MessageStreamItem[]; + items: readonly ThreadStreamItem[]; pendingTurnStart: PendingTurnStart | null; } @@ -19,7 +19,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh activeTurnId: activeTurnId(state), busy: chatTurnBusy(state), listedThreads: state.threadList.listedThreads, - items: messageStreamItems(state.messageStream), + items: threadStreamItems(state.threadStream), pendingTurnStart: pendingTurnStart(state), }; } diff --git a/src/features/chat/application/conversation/turn-state.ts b/src/features/chat/application/turns/turn-state.ts similarity index 100% rename from src/features/chat/application/conversation/turn-state.ts rename to src/features/chat/application/turns/turn-state.ts diff --git a/src/features/chat/application/conversation/turn-submission-actions.ts b/src/features/chat/application/turns/turn-submission-actions.ts similarity index 99% rename from src/features/chat/application/conversation/turn-submission-actions.ts rename to src/features/chat/application/turns/turn-submission-actions.ts index 22dd1004..cfa8afa3 100644 --- a/src/features/chat/application/conversation/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -203,7 +203,7 @@ async function steerCurrentTurn( prunePreservedComposerContext(host, request, { clearSuggestions: true }); if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return true; host.stateStore.dispatch({ - type: "message-stream/item-added", + type: "thread-stream/item-added", item: localUserMessageItemFromInput({ id: localSteerId, text: prepared.text, diff --git a/src/features/chat/application/conversation/turn-transport.ts b/src/features/chat/application/turns/turn-transport.ts similarity index 100% rename from src/features/chat/application/conversation/turn-transport.ts rename to src/features/chat/application/turns/turn-transport.ts diff --git a/src/features/chat/domain/message-stream/execution-state.ts b/src/features/chat/domain/message-stream/execution-state.ts deleted file mode 100644 index 66300b52..00000000 --- a/src/features/chat/domain/message-stream/execution-state.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { ExecutionState } from "./items"; - -export type MessageStreamExecutionState = Exclude; -export const RUNNING_EXECUTION_STATE: MessageStreamExecutionState = "running"; diff --git a/src/features/chat/domain/message-stream/selectors.ts b/src/features/chat/domain/message-stream/selectors.ts deleted file mode 100644 index 03da2068..00000000 --- a/src/features/chat/domain/message-stream/selectors.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { MessageStreamItem } from "./items"; -import { messageStreamSemanticClassifications } from "./semantics/classify"; - -export interface ForkCandidate { - itemId: string; - turnId: string; -} - -export interface PlanImplementationTarget { - itemId: string; -} - -export interface MessageStreamItemsEmptySource { - items: readonly MessageStreamItem[]; - stableItems?: readonly MessageStreamItem[] | undefined; - activeItems?: readonly MessageStreamItem[] | undefined; -} - -export function messageStreamItemsEmpty(source: MessageStreamItemsEmptySource): boolean { - if (!source.stableItems && !source.activeItems) return source.items.length === 0; - return messageStreamSegmentsEmpty(source.stableItems ?? [], source.activeItems ?? []); -} - -export function messageStreamSegmentsEmpty(stableItems: readonly MessageStreamItem[], activeItems: readonly MessageStreamItem[]): boolean { - return stableItems.length === 0 && activeItems.length === 0; -} - -export function forkCandidatesFromItems(items: readonly MessageStreamItem[]): readonly ForkCandidate[] { - const turnOutcomeItemsByTurn = new Map(); - for (const { item, capabilities } of messageStreamSemanticClassifications(items)) { - if (!item.turnId || !capabilities.isTurnOutcome) continue; - turnOutcomeItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId }); - } - return [...turnOutcomeItemsByTurn.values()]; -} - -export function latestImplementablePlanTargetFromItems(items: readonly MessageStreamItem[]): PlanImplementationTarget | null { - const classification = [...messageStreamSemanticClassifications(items)].reverse().find((item) => item.capabilities.canImplementPlan); - return classification ? { itemId: classification.item.id } : null; -} - -export function isCompletedTurnOutcomeMessage(item: MessageStreamItem): boolean { - return messageStreamSemanticClassifications([item])[0]?.capabilities.isTurnOutcome ?? false; -} diff --git a/src/features/chat/domain/message-stream/semantics/predicates.ts b/src/features/chat/domain/message-stream/semantics/predicates.ts deleted file mode 100644 index 19cc0eea..00000000 --- a/src/features/chat/domain/message-stream/semantics/predicates.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { MessageStreamMeaningEvent, MessageStreamMeaningPlane, MessageStreamSemanticClassification } from "./types"; - -function messageStreamHasMeaning( - classification: MessageStreamSemanticClassification, - plane: MessageStreamMeaningPlane, - event: MessageStreamMeaningEvent, -): boolean { - return classification.meaning.plane === plane && classification.meaning.event === event; -} - -export function messageStreamIsTurnInitiator(classification: MessageStreamSemanticClassification): boolean { - return ( - (classification.placement.scope === "turn" || classification.placement.scope === "pendingTurn") && - classification.placement.turnRole === "initiator" - ); -} - -export function messageStreamIsTurnSteer(classification: MessageStreamSemanticClassification): boolean { - return ( - (classification.placement.scope === "turn" || classification.placement.scope === "pendingTurn") && - classification.placement.turnRole === "steer" - ); -} - -export function messageStreamIsWorkspaceResult(classification: MessageStreamSemanticClassification): boolean { - return messageStreamHasMeaning(classification, "workspace", "result"); -} - -export function messageStreamIsCoordinationProgress(classification: MessageStreamSemanticClassification): boolean { - return messageStreamHasMeaning(classification, "coordination", "progress"); -} - -function messageStreamIsPermissionDecision(classification: MessageStreamSemanticClassification): boolean { - return messageStreamHasMeaning(classification, "permission", "decision"); -} - -export function messageStreamIsAutoReviewDecision(classification: MessageStreamSemanticClassification): boolean { - const { provenance } = classification; - if (!messageStreamIsPermissionDecision(classification) || !provenance) return false; - if (provenance.source === "appServer" && provenance.channel === "notification") return provenance.event === "autoReview"; - if (provenance.source === "panel" && provenance.channel === "notice") return provenance.reason === "parsedAutoReview"; - return false; -} diff --git a/src/features/chat/domain/message-stream/semantics/types.ts b/src/features/chat/domain/message-stream/semantics/types.ts deleted file mode 100644 index 292926c2..00000000 --- a/src/features/chat/domain/message-stream/semantics/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ExecutionState, MessageStreamItem } from "../items"; -import type { MessageStreamItemProvenance } from "../provenance"; - -type MessageStreamTurnRole = "initiator" | "steer" | "detail" | "outcome"; - -export type MessageStreamPlacement = - | { - scope: "thread"; - } - | { - scope: "turn"; - turnId: string; - turnRole: MessageStreamTurnRole; - } - | { - scope: "pendingTurn"; - turnRole: Extract; - } - | { - scope: "item"; - parentItemId: string; - turnId?: string; - } - | { - scope: "panel"; - }; - -export type MessageStreamMeaningPlane = - | "dialogue" - | "interaction" - | "execution" - | "workspace" - | "coordination" - | "permission" - | "review" - | "context" - | "diagnostic"; - -export type MessageStreamMeaningEvent = - | "request" - | "response" - | "proposal" - | "progress" - | "evidence" - | "result" - | "decision" - | "stateChange" - | "notice"; - -export interface MessageStreamMeaning { - plane: MessageStreamMeaningPlane; - event: MessageStreamMeaningEvent; -} - -export interface MessageStreamLifecycle { - state: Exclude; -} - -export interface MessageStreamSemanticCapabilities { - canForkFromHere: boolean; - canRollbackToPrompt: boolean; - canImplementPlan: boolean; - isTurnOutcome: boolean; -} - -export interface MessageStreamSemanticClassification { - item: MessageStreamItem; - provenance?: MessageStreamItemProvenance; - placement: MessageStreamPlacement; - meaning: MessageStreamMeaning; - lifecycle?: MessageStreamLifecycle; - capabilities: MessageStreamSemanticCapabilities; -} diff --git a/src/features/chat/domain/pending-requests/result-items.ts b/src/features/chat/domain/pending-requests/result-items.ts index b0e9da2a..2c3125ed 100644 --- a/src/features/chat/domain/pending-requests/result-items.ts +++ b/src/features/chat/domain/pending-requests/result-items.ts @@ -7,9 +7,9 @@ import { type PendingMcpElicitation, type PendingUserInput, } from "../../../../domain/pending-requests/model"; -import type { MessageStreamItem, MessageStreamUserInputQuestionResult } from "../message-stream/items"; +import type { ThreadStreamItem, ThreadStreamUserInputQuestionResult } from "../thread-stream/items"; -export function createApprovalResultItem(approval: PendingApproval, action: ApprovalAction): MessageStreamItem { +export function createApprovalResultItem(approval: PendingApproval, action: ApprovalAction): ThreadStreamItem { const kind = approvalActionKind(action); return { id: `approval-${String(approval.requestId)}`, @@ -32,7 +32,7 @@ export function createUserInputResultItem( input: PendingUserInput, answers: Record, status: "submitted" | "cancelled", -): MessageStreamItem { +): ThreadStreamItem { const questionCount = input.params.questions.length; const label = questionCount === 1 ? "1 question" : `${String(questionCount)} questions`; const questions = input.params.questions.map((question) => ({ @@ -57,7 +57,7 @@ export function createMcpElicitationResultItem( elicitation: PendingMcpElicitation, action: McpElicitationAction, content: Record | null, -): MessageStreamItem { +): ThreadStreamItem { const accepted = action === "accept"; return { id: `mcp-elicitation-${action}-${String(elicitation.requestId)}`, @@ -99,7 +99,7 @@ function mcpElicitationResultText(elicitation: PendingMcpElicitation, action: Mc function mcpElicitationResultQuestions( elicitation: PendingMcpElicitation, content: Record | null, -): readonly MessageStreamUserInputQuestionResult[] { +): readonly ThreadStreamUserInputQuestionResult[] { if (elicitation.params.mode === "url") { return [ { diff --git a/src/features/chat/domain/message-stream/completed-turn-reconciliation.ts b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts similarity index 76% rename from src/features/chat/domain/message-stream/completed-turn-reconciliation.ts rename to src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts index 482971ad..c69b5595 100644 --- a/src/features/chat/domain/message-stream/completed-turn-reconciliation.ts +++ b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts @@ -1,14 +1,14 @@ -import type { MessageStreamItem, MessageStreamMessageItem } from "./items"; +import type { ThreadStreamDialogueItem, ThreadStreamItem } from "./items"; import { isLocalUserMessageId } from "./local-message-ids"; -import { upsertMessageStreamItemById } from "./updates"; +import { upsertThreadStreamItemById } from "./updates"; export interface CompletedTurnReconciliationInput { - currentItems: readonly MessageStreamItem[]; + currentItems: readonly ThreadStreamItem[]; completedTurnId: string; - turnItems: readonly MessageStreamItem[]; + turnItems: readonly ThreadStreamItem[]; } -export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationInput): readonly MessageStreamItem[] { +export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationInput): readonly ThreadStreamItem[] { const { currentItems, completedTurnId, turnItems } = input; if (turnItems.length === 0) return currentItems; @@ -24,7 +24,7 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn .filter((item) => item.turnId === completedTurnId) .filter((item) => !isReconciledOptimisticUserMessage(item, completedTurnId, serverUserClientIds, serverUserFallbackTexts)); for (const item of turnItems) { - mergedTurnItems = upsertMessageStreamItemById(mergedTurnItems, item); + mergedTurnItems = upsertThreadStreamItemById(mergedTurnItems, item); } const retainedItems = currentWithServerUsers @@ -33,20 +33,20 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn return [...retainedItems, ...mergedTurnItems]; } -function isUserMessage(item: MessageStreamItem): item is MessageStreamMessageItem & { role: "user" } { - return item.kind === "message" && item.role === "user"; +function isUserMessage(item: ThreadStreamItem): item is ThreadStreamDialogueItem & { role: "user" } { + return item.kind === "dialogue" && item.role === "user"; } function serverUserMessageForOptimisticItem( - item: MessageStreamItem, - serverUserMessagesByClientId: ReadonlyMap, -): (MessageStreamMessageItem & { role: "user" }) | null { + item: ThreadStreamItem, + serverUserMessagesByClientId: ReadonlyMap, +): (ThreadStreamDialogueItem & { role: "user" }) | null { if (!isUserMessage(item) || !isLocalUserMessageId(item.id)) return null; return serverUserMessagesByClientId.get(item.id) ?? null; } function isReconciledOptimisticUserMessage( - item: MessageStreamItem, + item: ThreadStreamItem, completedTurnId: string, serverUserClientIds: Set, serverUserFallbackTexts: Set, @@ -56,7 +56,7 @@ function isReconciledOptimisticUserMessage( } function isFallbackOptimisticUserMessageForTurn( - item: MessageStreamMessageItem & { role: "user" }, + item: ThreadStreamDialogueItem & { role: "user" }, completedTurnId: string, serverUserFallbackTexts: Set, ): boolean { diff --git a/src/features/chat/domain/thread-stream/execution-state.ts b/src/features/chat/domain/thread-stream/execution-state.ts new file mode 100644 index 00000000..81267a9c --- /dev/null +++ b/src/features/chat/domain/thread-stream/execution-state.ts @@ -0,0 +1,4 @@ +import type { ExecutionState } from "./items"; + +export type ThreadStreamExecutionState = Exclude; +export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running"; diff --git a/src/features/chat/domain/message-stream/factories/goal-items.ts b/src/features/chat/domain/thread-stream/factories/goal-items.ts similarity index 94% rename from src/features/chat/domain/message-stream/factories/goal-items.ts rename to src/features/chat/domain/thread-stream/factories/goal-items.ts index 27754977..57ff54e6 100644 --- a/src/features/chat/domain/message-stream/factories/goal-items.ts +++ b/src/features/chat/domain/thread-stream/factories/goal-items.ts @@ -1,6 +1,6 @@ import { truncate } from "../../../../../domain/display/text-preview"; import type { ThreadGoal, ThreadGoalStatus } from "../../../../../domain/threads/goal"; -import type { GoalMessageStreamItem } from "../items"; +import type { GoalThreadStreamItem } from "../items"; const GOAL_SUMMARY_LIMIT = 140; @@ -13,7 +13,7 @@ function goalChangeMessage(previous: ThreadGoal | null, next: ThreadGoal | null) return null; } -export function goalChangeItem(id: string, previous: ThreadGoal | null, next: ThreadGoal | null): GoalMessageStreamItem | null { +export function goalChangeItem(id: string, previous: ThreadGoal | null, next: ThreadGoal | null): GoalThreadStreamItem | null { const message = goalChangeMessage(previous, next); if (!message) return null; const objective = next?.objective ?? previous?.objective; diff --git a/src/features/chat/domain/message-stream/factories/streaming-items.ts b/src/features/chat/domain/thread-stream/factories/streaming-items.ts similarity index 81% rename from src/features/chat/domain/message-stream/factories/streaming-items.ts rename to src/features/chat/domain/thread-stream/factories/streaming-items.ts index 58ac4ec8..9ac4f202 100644 --- a/src/features/chat/domain/message-stream/factories/streaming-items.ts +++ b/src/features/chat/domain/thread-stream/factories/streaming-items.ts @@ -1,18 +1,18 @@ import { RUNNING_EXECUTION_STATE } from "../execution-state"; -import type { MessageStreamItem, MessageStreamItemKind } from "../items"; +import type { ThreadStreamItem, ThreadStreamItemKind } from "../items"; export const STREAMED_COMMAND_RUNNING_TEXT = "Command running"; export const STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT = "File change inProgress"; export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress"; const UNKNOWN_STREAMED_COMMAND_CWD = "(unknown)"; -export function streamedTextMessageStreamItem(params: { +export function streamedTextThreadStreamItem(params: { id: string; turnId: string; label: string; delta: string; - kind: Extract; -}): MessageStreamItem { + kind: Extract; +}): ThreadStreamItem { return { id: params.id, kind: params.kind, @@ -24,12 +24,12 @@ export function streamedTextMessageStreamItem(params: { }; } -export function streamedToolOutputMessageStreamItem(params: { +export function streamedToolOutputThreadStreamItem(params: { id: string; turnId: string; output: string; fallbackLabel: string; -}): MessageStreamItem { +}): ThreadStreamItem { return { id: params.id, kind: "tool", @@ -42,13 +42,13 @@ export function streamedToolOutputMessageStreamItem(params: { }; } -export function streamedItemOutputMessageStreamItem(params: { +export function streamedItemOutputThreadStreamItem(params: { id: string; turnId: string; output: string; kind: "command" | "fileChange"; fallbackText: string; -}): MessageStreamItem { +}): ThreadStreamItem { return { id: params.id, kind: params.kind, @@ -71,5 +71,5 @@ export function streamedItemOutputMessageStreamItem(params: { status: "running", executionState: RUNNING_EXECUTION_STATE, }), - } as MessageStreamItem; + } as ThreadStreamItem; } diff --git a/src/features/chat/domain/message-stream/factories/system-items.ts b/src/features/chat/domain/thread-stream/factories/system-items.ts similarity index 64% rename from src/features/chat/domain/message-stream/factories/system-items.ts rename to src/features/chat/domain/thread-stream/factories/system-items.ts index 623cf596..df37707a 100644 --- a/src/features/chat/domain/message-stream/factories/system-items.ts +++ b/src/features/chat/domain/thread-stream/factories/system-items.ts @@ -1,6 +1,6 @@ -import type { MessageStreamItem, MessageStreamNoticeSection } from "../items"; +import type { ThreadStreamItem, ThreadStreamNoticeSection } from "../items"; -export function createSystemItem(id: string, text: string): MessageStreamItem { +export function createSystemItem(id: string, text: string): ThreadStreamItem { return { id, kind: "system", @@ -10,7 +10,7 @@ export function createSystemItem(id: string, text: string): MessageStreamItem { }; } -export function createStructuredSystemItem(id: string, text: string, noticeSections: MessageStreamNoticeSection[]): MessageStreamItem { +export function createStructuredSystemItem(id: string, text: string, noticeSections: ThreadStreamNoticeSection[]): ThreadStreamItem { return { id, kind: "system", diff --git a/src/features/chat/domain/message-stream/format/agent-message-preview.ts b/src/features/chat/domain/thread-stream/format/agent-message-preview.ts similarity index 100% rename from src/features/chat/domain/message-stream/format/agent-message-preview.ts rename to src/features/chat/domain/thread-stream/format/agent-message-preview.ts diff --git a/src/features/chat/domain/message-stream/format/file-mentions.ts b/src/features/chat/domain/thread-stream/format/file-mentions.ts similarity index 82% rename from src/features/chat/domain/message-stream/format/file-mentions.ts rename to src/features/chat/domain/thread-stream/format/file-mentions.ts index eeb77e8c..5c258c78 100644 --- a/src/features/chat/domain/message-stream/format/file-mentions.ts +++ b/src/features/chat/domain/thread-stream/format/file-mentions.ts @@ -1,12 +1,12 @@ import { ACTIVE_FILE_MENTION_NAME, type CodexInputItem } from "../../../../../domain/chat/input"; -import type { MessageStreamFileMention } from "../items"; +import type { ThreadStreamFileMention } from "../items"; const ACTIVE_FILE_DISPLAY_NAME = "Active file"; -export function fileMentionsFromInput(input: readonly CodexInputItem[]): MessageStreamFileMention[] { +export function fileMentionsFromInput(input: readonly CodexInputItem[]): ThreadStreamFileMention[] { const seenFilePaths = new Set(); const seenActiveNotePaths = new Set(); - const mentions: MessageStreamFileMention[] = []; + const mentions: ThreadStreamFileMention[] = []; for (const item of input) { if (item.type !== "mention") continue; const activeNoteMention = item.name === ACTIVE_FILE_MENTION_NAME; diff --git a/src/features/chat/domain/message-stream/format/proposed-plan.ts b/src/features/chat/domain/thread-stream/format/proposed-plan.ts similarity index 100% rename from src/features/chat/domain/message-stream/format/proposed-plan.ts rename to src/features/chat/domain/thread-stream/format/proposed-plan.ts diff --git a/src/features/chat/domain/message-stream/format/user-message-text.ts b/src/features/chat/domain/thread-stream/format/user-message-text.ts similarity index 100% rename from src/features/chat/domain/message-stream/format/user-message-text.ts rename to src/features/chat/domain/thread-stream/format/user-message-text.ts diff --git a/src/features/chat/domain/message-stream/items.ts b/src/features/chat/domain/thread-stream/items.ts similarity index 54% rename from src/features/chat/domain/message-stream/items.ts rename to src/features/chat/domain/thread-stream/items.ts index 609fbbcb..232f0657 100644 --- a/src/features/chat/domain/message-stream/items.ts +++ b/src/features/chat/domain/thread-stream/items.ts @@ -1,8 +1,8 @@ import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; -import type { MessageStreamItemProvenance } from "./provenance"; +import type { ThreadStreamItemProvenance } from "./provenance"; -export type MessageStreamItemKind = - | "message" +export type ThreadStreamItemKind = + | "dialogue" | "command" | "fileChange" | "tool" @@ -17,21 +17,21 @@ export type MessageStreamItemKind = | "approvalResult" | "userInputResult" | "reviewResult"; -type MessageStreamRole = "user" | "assistant" | "system" | "tool"; +type ThreadStreamRole = "user" | "assistant" | "system" | "tool"; export type ExecutionState = "running" | "completed" | "failed" | null; -type MessageState = "streaming" | "completed"; +type DialogueState = "streaming" | "completed"; -interface MessageStreamBase { +interface ThreadStreamBase { readonly id: string; - readonly kind: MessageStreamItemKind; - readonly role: MessageStreamRole; + readonly kind: ThreadStreamItemKind; + readonly role: ThreadStreamRole; readonly turnId?: string; readonly sourceItemId?: string; - readonly provenance?: MessageStreamItemProvenance; + readonly provenance?: ThreadStreamItemProvenance; readonly executionState?: ExecutionState; } -export type MessageStreamPrimaryTarget = +export type ThreadStreamPrimaryTarget = | { readonly kind: "path"; readonly path: string; @@ -41,61 +41,61 @@ export type MessageStreamPrimaryTarget = readonly value: string; }; -export interface MessageStreamDiagnosticSection { +export interface ThreadStreamDiagnosticSection { readonly title: string; readonly body: string; } -interface MessageStreamWebSearchDetails { +interface ThreadStreamWebSearchDetails { readonly action?: string; readonly query?: string; readonly url?: string; readonly pattern?: string; } -interface MessageStreamImageGenerationDetails { +interface ThreadStreamImageGenerationDetails { readonly savedPath?: string; readonly revisedPrompt?: string | null; readonly result?: string; } -interface MessageStreamHookRunDetails { +interface ThreadStreamHookRunDetails { readonly eventName: string; readonly statusMessage?: string; readonly durationMs?: string; readonly entries: readonly { readonly kind: string; readonly text: string }[]; } -export interface MessageStreamAuditFact { +export interface ThreadStreamAuditFact { readonly key: string; readonly value: string; } -export interface MessageStreamNoticeSection { +export interface ThreadStreamNoticeSection { readonly title?: string; - readonly auditFacts?: readonly MessageStreamAuditFact[]; + readonly auditFacts?: readonly ThreadStreamAuditFact[]; readonly body?: string; } -interface MessageStreamApprovalResultDetails { +interface ThreadStreamApprovalResultDetails { readonly status: string; readonly scope: "session" | "turn"; readonly request: string; - readonly auditFacts: readonly MessageStreamAuditFact[]; + readonly auditFacts: readonly ThreadStreamAuditFact[]; } -export interface MessageStreamUserInputQuestionResult { +export interface ThreadStreamUserInputQuestionResult { readonly id: string; readonly header: string; readonly question: string; readonly answer?: string; } -interface MessageStreamReviewResultDetails { - readonly auditFacts: readonly MessageStreamAuditFact[]; +interface ThreadStreamReviewResultDetails { + readonly auditFacts: readonly ThreadStreamAuditFact[]; } -export type CommandMessageStreamTarget = +export type CommandThreadStreamTarget = | { readonly kind: "read"; readonly path?: string; @@ -115,49 +115,52 @@ export type CommandMessageStreamTarget = readonly commandLine: string; }; -interface MessageStreamMessageBase extends MessageStreamBase { - readonly kind: "message"; +interface ThreadStreamDialogueBase extends ThreadStreamBase { + readonly kind: "dialogue"; readonly role: "user" | "assistant"; readonly text: string; readonly clientId?: string; readonly copyText?: string; readonly referencedThread?: ReferencedThreadMetadata; - readonly mentionedFiles?: readonly MessageStreamFileMention[]; + readonly mentionedFiles?: readonly ThreadStreamFileMention[]; } -interface UserMessageStreamItem extends MessageStreamMessageBase { - readonly messageKind: "user"; +interface UserThreadStreamDialogueItem extends ThreadStreamDialogueBase { + readonly dialogueKind: "user"; readonly role: "user"; - readonly messageState?: never; + readonly dialogueState?: never; } -interface AssistantResponseMessageStreamItem extends MessageStreamMessageBase { - readonly messageKind: "assistantResponse"; +interface AssistantResponseThreadStreamDialogueItem extends ThreadStreamDialogueBase { + readonly dialogueKind: "assistantResponse"; readonly role: "assistant"; - readonly messageState: MessageState; + readonly dialogueState: DialogueState; } -interface ProposedPlanMessageStreamItem extends MessageStreamMessageBase { - readonly messageKind: "proposedPlan"; +interface ProposedPlanThreadStreamDialogueItem extends ThreadStreamDialogueBase { + readonly dialogueKind: "proposedPlan"; readonly role: "assistant"; - readonly messageState: MessageState; + readonly dialogueState: DialogueState; } -export type MessageStreamMessageItem = UserMessageStreamItem | AssistantResponseMessageStreamItem | ProposedPlanMessageStreamItem; +export type ThreadStreamDialogueItem = + | UserThreadStreamDialogueItem + | AssistantResponseThreadStreamDialogueItem + | ProposedPlanThreadStreamDialogueItem; -export interface MessageStreamFileMention { +export interface ThreadStreamFileMention { readonly name: string; readonly path: string; } -interface SystemMessageStreamItem extends MessageStreamBase { +interface SystemThreadStreamItem extends ThreadStreamBase { readonly kind: "system"; readonly role: "system"; readonly text: string; - readonly noticeSections?: readonly MessageStreamNoticeSection[]; + readonly noticeSections?: readonly ThreadStreamNoticeSection[]; } -export interface GoalMessageStreamItem extends MessageStreamBase { +export interface GoalThreadStreamItem extends ThreadStreamBase { readonly kind: "goal"; readonly role: "tool"; readonly text: string; @@ -165,32 +168,32 @@ export interface GoalMessageStreamItem extends MessageStreamBase { readonly objective?: string; } -interface UserInputResultMessageStreamItem extends MessageStreamBase { +interface UserInputResultThreadStreamItem extends ThreadStreamBase { readonly kind: "userInputResult"; readonly role: "tool"; readonly text: string; - readonly questions: readonly MessageStreamUserInputQuestionResult[]; + readonly questions: readonly ThreadStreamUserInputQuestionResult[]; } -export interface ApprovalResultMessageStreamItem extends MessageStreamBase { +export interface ApprovalResultThreadStreamItem extends ThreadStreamBase { readonly kind: "approvalResult"; readonly role: "tool"; readonly text: string; - readonly approval: MessageStreamApprovalResultDetails; + readonly approval: ThreadStreamApprovalResultDetails; } -export interface ReviewResultMessageStreamItem extends MessageStreamBase { +export interface ReviewResultThreadStreamItem extends ThreadStreamBase { readonly kind: "reviewResult"; readonly role: "tool"; readonly text: string; - readonly review?: MessageStreamReviewResultDetails; + readonly review?: ThreadStreamReviewResultDetails; } -export interface CommandMessageStreamItem extends MessageStreamBase { +export interface CommandThreadStreamItem extends ThreadStreamBase { readonly kind: "command"; readonly role: "tool"; readonly commandAction: "read" | "search" | "listFiles" | "command"; - readonly commandTarget: CommandMessageStreamTarget; + readonly commandTarget: CommandThreadStreamTarget; readonly command: string; readonly cwd: string; readonly status: string; @@ -199,54 +202,54 @@ export interface CommandMessageStreamItem extends MessageStreamBase { readonly output?: string; } -export interface MessageStreamFileChange { +export interface ThreadStreamFileChange { readonly kind: string; readonly path: string; readonly diff: string; } -export interface FileChangeMessageStreamItem extends MessageStreamBase { +export interface FileChangeThreadStreamItem extends ThreadStreamBase { readonly kind: "fileChange"; readonly role: "tool"; readonly status: string; - readonly changes: readonly MessageStreamFileChange[]; + readonly changes: readonly ThreadStreamFileChange[]; readonly output?: string; } -interface ToolMessageStreamBase extends MessageStreamBase { +interface ToolThreadStreamBase extends ThreadStreamBase { readonly role: "tool"; readonly text?: string; readonly toolName?: string; - readonly primaryTarget?: MessageStreamPrimaryTarget; + readonly primaryTarget?: ThreadStreamPrimaryTarget; readonly operation?: string; readonly failureReason?: string; readonly status?: string; readonly output?: string; } -export interface ToolCallMessageStreamItem extends ToolMessageStreamBase { +export interface ToolCallThreadStreamItem extends ToolThreadStreamBase { readonly kind: "tool"; - readonly diagnostics?: readonly MessageStreamDiagnosticSection[]; - readonly webSearch?: MessageStreamWebSearchDetails; - readonly imageGeneration?: MessageStreamImageGenerationDetails; + readonly diagnostics?: readonly ThreadStreamDiagnosticSection[]; + readonly webSearch?: ThreadStreamWebSearchDetails; + readonly imageGeneration?: ThreadStreamImageGenerationDetails; } -export interface HookMessageStreamItem extends ToolMessageStreamBase { +export interface HookThreadStreamItem extends ToolThreadStreamBase { readonly kind: "hook"; - readonly hookRun?: MessageStreamHookRunDetails; + readonly hookRun?: ThreadStreamHookRunDetails; } -export interface ReasoningMessageStreamItem extends ToolMessageStreamBase { +export interface ReasoningThreadStreamItem extends ToolThreadStreamBase { readonly kind: "reasoning"; readonly text: string; } -interface ContextCompactionMessageStreamItem extends MessageStreamBase { +interface ContextCompactionThreadStreamItem extends ThreadStreamBase { readonly kind: "contextCompaction"; readonly role: "tool"; } -interface WaitMessageStreamItem extends MessageStreamBase { +interface WaitThreadStreamItem extends ThreadStreamBase { readonly kind: "wait"; readonly role: "tool"; readonly text: string; @@ -257,7 +260,7 @@ interface TaskProgressStep { readonly status: "pending" | "inProgress" | "completed"; } -export interface TaskProgressMessageStreamItem extends MessageStreamBase { +export interface TaskProgressThreadStreamItem extends ThreadStreamBase { readonly kind: "taskProgress"; readonly role: "tool"; readonly text?: string; @@ -279,7 +282,7 @@ export interface AgentRunSummaryAgent { readonly messagePreview: string | null; } -export interface AgentMessageStreamItem extends MessageStreamBase { +export interface AgentThreadStreamItem extends ThreadStreamBase { readonly kind: "agent"; readonly role: "tool"; readonly text?: string; @@ -301,19 +304,19 @@ export interface AgentRunSummary { readonly additionalAgents: number; } -export type MessageStreamItem = - | MessageStreamMessageItem - | SystemMessageStreamItem - | GoalMessageStreamItem - | UserInputResultMessageStreamItem - | CommandMessageStreamItem - | FileChangeMessageStreamItem - | ToolCallMessageStreamItem - | HookMessageStreamItem - | ReasoningMessageStreamItem - | WaitMessageStreamItem - | ContextCompactionMessageStreamItem - | TaskProgressMessageStreamItem - | AgentMessageStreamItem - | ApprovalResultMessageStreamItem - | ReviewResultMessageStreamItem; +export type ThreadStreamItem = + | ThreadStreamDialogueItem + | SystemThreadStreamItem + | GoalThreadStreamItem + | UserInputResultThreadStreamItem + | CommandThreadStreamItem + | FileChangeThreadStreamItem + | ToolCallThreadStreamItem + | HookThreadStreamItem + | ReasoningThreadStreamItem + | WaitThreadStreamItem + | ContextCompactionThreadStreamItem + | TaskProgressThreadStreamItem + | AgentThreadStreamItem + | ApprovalResultThreadStreamItem + | ReviewResultThreadStreamItem; diff --git a/src/features/chat/domain/message-stream/local-message-ids.ts b/src/features/chat/domain/thread-stream/local-message-ids.ts similarity index 100% rename from src/features/chat/domain/message-stream/local-message-ids.ts rename to src/features/chat/domain/thread-stream/local-message-ids.ts diff --git a/src/features/chat/domain/message-stream/provenance.ts b/src/features/chat/domain/thread-stream/provenance.ts similarity index 94% rename from src/features/chat/domain/message-stream/provenance.ts rename to src/features/chat/domain/thread-stream/provenance.ts index 9d963d90..aca01845 100644 --- a/src/features/chat/domain/message-stream/provenance.ts +++ b/src/features/chat/domain/thread-stream/provenance.ts @@ -1,4 +1,4 @@ -export type MessageStreamItemProvenance = +export type ThreadStreamItemProvenance = | { source: "appServer"; channel: "turnItem"; diff --git a/src/features/chat/domain/thread-stream/selectors.ts b/src/features/chat/domain/thread-stream/selectors.ts new file mode 100644 index 00000000..cd812cef --- /dev/null +++ b/src/features/chat/domain/thread-stream/selectors.ts @@ -0,0 +1,44 @@ +import type { ThreadStreamItem } from "./items"; +import { threadStreamSemanticClassifications } from "./semantics/classify"; + +export interface ForkCandidate { + itemId: string; + turnId: string; +} + +export interface PlanImplementationTarget { + itemId: string; +} + +export interface ThreadStreamItemsEmptySource { + items: readonly ThreadStreamItem[]; + stableItems?: readonly ThreadStreamItem[] | undefined; + activeItems?: readonly ThreadStreamItem[] | undefined; +} + +export function threadStreamItemsEmpty(source: ThreadStreamItemsEmptySource): boolean { + if (!source.stableItems && !source.activeItems) return source.items.length === 0; + return threadStreamSegmentsEmpty(source.stableItems ?? [], source.activeItems ?? []); +} + +export function threadStreamSegmentsEmpty(stableItems: readonly ThreadStreamItem[], activeItems: readonly ThreadStreamItem[]): boolean { + return stableItems.length === 0 && activeItems.length === 0; +} + +export function forkCandidatesFromItems(items: readonly ThreadStreamItem[]): readonly ForkCandidate[] { + const turnOutcomeItemsByTurn = new Map(); + for (const { item, capabilities } of threadStreamSemanticClassifications(items)) { + if (!item.turnId || !capabilities.isTurnOutcome) continue; + turnOutcomeItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId }); + } + return [...turnOutcomeItemsByTurn.values()]; +} + +export function latestImplementablePlanTargetFromItems(items: readonly ThreadStreamItem[]): PlanImplementationTarget | null { + const classification = [...threadStreamSemanticClassifications(items)].reverse().find((item) => item.capabilities.canImplementPlan); + return classification ? { itemId: classification.item.id } : null; +} + +export function isCompletedTurnOutcomeMessage(item: ThreadStreamItem): boolean { + return threadStreamSemanticClassifications([item])[0]?.capabilities.isTurnOutcome ?? false; +} diff --git a/src/features/chat/domain/message-stream/semantics/active-turn.ts b/src/features/chat/domain/thread-stream/semantics/active-turn.ts similarity index 68% rename from src/features/chat/domain/message-stream/semantics/active-turn.ts rename to src/features/chat/domain/thread-stream/semantics/active-turn.ts index f5430b88..c39e9baa 100644 --- a/src/features/chat/domain/message-stream/semantics/active-turn.ts +++ b/src/features/chat/domain/thread-stream/semantics/active-turn.ts @@ -3,27 +3,27 @@ import type { AgentRunSummary, AgentRunSummaryAgent, AgentStateSummary, - MessageStreamItem, - ReasoningMessageStreamItem, - TaskProgressMessageStreamItem, + ReasoningThreadStreamItem, + TaskProgressThreadStreamItem, + ThreadStreamItem, } from "../items"; -import { messageStreamSemanticClassifications } from "./classify"; -import { messageStreamIsCoordinationProgress } from "./predicates"; -import type { MessageStreamSemanticClassification } from "./types"; +import { threadStreamSemanticClassifications } from "./classify"; +import { threadStreamIsCoordinationProgress } from "./predicates"; +import type { ThreadStreamSemanticClassification } from "./types"; const ACTIVE_AGENT_PREVIEW_LIMIT = 96; type AgentRunState = "running" | "completed" | "failed"; export interface ActiveTurnItemsContext { activeTurnId: string | null; - items: readonly MessageStreamItem[]; - activeItems?: readonly MessageStreamItem[] | undefined; + items: readonly ThreadStreamItem[]; + activeItems?: readonly ThreadStreamItem[] | undefined; } export type ActiveTurnLiveItem = | { kind: "taskProgress"; - item: TaskProgressMessageStreamItem; + item: TaskProgressThreadStreamItem; } | { kind: "agentSummary"; @@ -36,13 +36,13 @@ export function activeTurnLiveItems( activeTurnId: string, ): ActiveTurnLiveItem[] { const items = input.activeItems ?? input.items; - const semanticItems = messageStreamSemanticClassifications(items); + const semanticItems = threadStreamSemanticClassifications(items); const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(semanticItems, activeTurnId); const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId) : null; return semanticItems.flatMap((classification): ActiveTurnLiveItem[] => { const { item } = classification; - if (messageStreamItemIsActiveTaskProgress(item, activeTurnId)) { + if (threadStreamItemIsActiveTaskProgress(item, activeTurnId)) { return [{ kind: "taskProgress", item }]; } if (item.id === agentSummaryAnchorId && agentSummary) { @@ -52,14 +52,11 @@ export function activeTurnLiveItems( }); } -export function messageStreamItemsWithoutActiveTaskProgress( - items: readonly MessageStreamItem[], - activeTurnId: string, -): MessageStreamItem[] { - return items.filter((item) => !messageStreamItemIsActiveTaskProgress(item, activeTurnId)); +export function threadStreamItemsWithoutActiveTaskProgress(items: readonly ThreadStreamItem[], activeTurnId: string): ThreadStreamItem[] { + return items.filter((item) => !threadStreamItemIsActiveTaskProgress(item, activeTurnId)); } -function activeAgentRunSummary(items: readonly MessageStreamItem[], activeTurnId: string | null): AgentRunSummary | null { +function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId: string | null): AgentRunSummary | null { if (!activeTurnId) return null; const agentStatuses = new Map(); @@ -99,7 +96,7 @@ function activeAgentRunSummary(items: readonly MessageStreamItem[], activeTurnId return summary; } -export function messageStreamReasoningIsActive(item: ReasoningMessageStreamItem, context: ActiveTurnItemsContext): boolean { +export function threadStreamReasoningIsActive(item: ReasoningThreadStreamItem, context: ActiveTurnItemsContext): boolean { const activeTurn = context.activeTurnId; if (!activeTurn || item.turnId !== activeTurn) return false; if (item.executionState === "completed") return false; @@ -107,13 +104,13 @@ export function messageStreamReasoningIsActive(item: ReasoningMessageStreamItem, return latestActiveTurnItem?.id === item.id; } -function messageStreamItemIsActiveTaskProgress(item: MessageStreamItem, activeTurnId: string): item is TaskProgressMessageStreamItem { +function threadStreamItemIsActiveTaskProgress(item: ThreadStreamItem, activeTurnId: string): item is TaskProgressThreadStreamItem { return item.kind === "taskProgress" && item.turnId === activeTurnId; } -function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClassification[], activeTurnId: string): string | null { +function activeAgentRunSummaryAnchorId(items: readonly ThreadStreamSemanticClassification[], activeTurnId: string): string | null { const firstActiveAgent = items.find( - (classification) => messageStreamIsCoordinationProgress(classification) && classification.item.turnId === activeTurnId, + (classification) => threadStreamIsCoordinationProgress(classification) && classification.item.turnId === activeTurnId, ); return firstActiveAgent?.item.id ?? null; } diff --git a/src/features/chat/domain/message-stream/semantics/classify.ts b/src/features/chat/domain/thread-stream/semantics/classify.ts similarity index 62% rename from src/features/chat/domain/message-stream/semantics/classify.ts rename to src/features/chat/domain/thread-stream/semantics/classify.ts index 17452449..7d9c918e 100644 --- a/src/features/chat/domain/message-stream/semantics/classify.ts +++ b/src/features/chat/domain/thread-stream/semantics/classify.ts @@ -1,38 +1,38 @@ -import type { MessageStreamItem } from "../items"; +import type { ThreadStreamItem } from "../items"; import { isLocalSteerMessageClientId } from "../local-message-ids"; import type { - MessageStreamLifecycle, - MessageStreamMeaning, - MessageStreamPlacement, - MessageStreamSemanticCapabilities, - MessageStreamSemanticClassification, + ThreadStreamLifecycle, + ThreadStreamMeaning, + ThreadStreamPlacement, + ThreadStreamSemanticCapabilities, + ThreadStreamSemanticClassification, } from "./types"; -export function messageStreamSemanticClassifications(items: readonly MessageStreamItem[]): MessageStreamSemanticClassification[] { +export function threadStreamSemanticClassifications(items: readonly ThreadStreamItem[]): ThreadStreamSemanticClassification[] { const seenUserMessagesByTurn = new Map(); - return items.map((item) => messageStreamSemanticClassification(item, seenUserMessagesByTurn)); + return items.map((item) => threadStreamSemanticClassification(item, seenUserMessagesByTurn)); } -function messageStreamSemanticClassification( - item: MessageStreamItem, +function threadStreamSemanticClassification( + item: ThreadStreamItem, seenUserMessagesByTurn: Map = new Map(), -): MessageStreamSemanticClassification { - const placement = placementForMessageStreamItem(item, seenUserMessagesByTurn); - const meaning = meaningForMessageStreamItem(item); - const lifecycle = lifecycleForMessageStreamItem(item); +): ThreadStreamSemanticClassification { + const placement = placementForThreadStreamItem(item, seenUserMessagesByTurn); + const meaning = meaningForThreadStreamItem(item); + const lifecycle = lifecycleForThreadStreamItem(item); return { item, ...definedProp("provenance", item.provenance), placement, meaning, ...definedProp("lifecycle", lifecycle), - capabilities: messageStreamSemanticCapabilities({ item, placement, meaning, ...definedProp("lifecycle", lifecycle) }), + capabilities: threadStreamSemanticCapabilities({ item, placement, meaning, ...definedProp("lifecycle", lifecycle) }), }; } -function messageStreamSemanticCapabilities( - classification: Pick, -): MessageStreamSemanticCapabilities { +function threadStreamSemanticCapabilities( + classification: Pick, +): ThreadStreamSemanticCapabilities { const { item, placement, meaning, lifecycle } = classification; const isDialogueOutcome = placement.scope === "turn" && @@ -44,22 +44,22 @@ function messageStreamSemanticCapabilities( return { canForkFromHere: isDialogueOutcome, canRollbackToPrompt: (placement.scope === "turn" || placement.scope === "pendingTurn") && placement.turnRole === "initiator", - canImplementPlan: item.kind === "message" && item.messageKind === "proposedPlan" && lifecycle?.state === "completed", + canImplementPlan: item.kind === "dialogue" && item.dialogueKind === "proposedPlan" && lifecycle?.state === "completed", isTurnOutcome: isDialogueOutcome, }; } -function placementForMessageStreamItem(item: MessageStreamItem, seenUserMessagesByTurn: Map): MessageStreamPlacement { +function placementForThreadStreamItem(item: ThreadStreamItem, seenUserMessagesByTurn: Map): ThreadStreamPlacement { if (item.kind === "goal") return { scope: "thread" }; if (item.kind === "system") return { scope: "panel" }; - if (item.kind === "message" && item.messageKind === "user") { + if (item.kind === "dialogue" && item.dialogueKind === "user") { const turnRole = isSteeringUserMessage(item, seenUserMessagesByTurn) ? "steer" : "initiator"; return item.turnId ? { scope: "turn", turnId: item.turnId, turnRole } : { scope: "pendingTurn", turnRole }; } - if (item.kind === "message") { - const turnRole = item.messageState === "completed" ? "outcome" : "detail"; + if (item.kind === "dialogue") { + const turnRole = item.dialogueState === "completed" ? "outcome" : "detail"; return item.turnId ? { scope: "turn", turnId: item.turnId, turnRole } : { scope: "panel" }; } @@ -69,11 +69,11 @@ function placementForMessageStreamItem(item: MessageStreamItem, seenUserMessages return { scope: "panel" }; } -function meaningForMessageStreamItem(item: MessageStreamItem): MessageStreamMeaning { +function meaningForThreadStreamItem(item: ThreadStreamItem): ThreadStreamMeaning { switch (item.kind) { - case "message": - if (item.messageKind === "user") return { plane: "dialogue", event: "request" }; - if (item.messageKind === "proposedPlan") return { plane: "dialogue", event: "proposal" }; + case "dialogue": + if (item.dialogueKind === "user") return { plane: "dialogue", event: "request" }; + if (item.dialogueKind === "proposedPlan") return { plane: "dialogue", event: "proposal" }; return { plane: "dialogue", event: "response" }; case "command": case "tool": @@ -102,7 +102,7 @@ function meaningForMessageStreamItem(item: MessageStreamItem): MessageStreamMean return { plane: "diagnostic", event: "notice" }; } -function reviewResultMeaning(item: Extract): MessageStreamMeaning { +function reviewResultMeaning(item: Extract): ThreadStreamMeaning { if (item.provenance?.source === "appServer" && item.provenance.channel === "notification" && item.provenance.event === "autoReview") { return { plane: "permission", event: "decision" }; } @@ -112,19 +112,19 @@ function reviewResultMeaning(item: Extract, + item: Extract, seenUserMessagesByTurn: Map, ): boolean { - if (item.messageKind !== "user") return false; + if (item.dialogueKind !== "user") return false; if (item.provenance?.source === "localUser" && item.provenance.interaction === "steer") return true; if (!item.turnId) return false; const seenCount = seenUserMessagesByTurn.get(item.turnId) ?? 0; diff --git a/src/features/chat/domain/thread-stream/semantics/predicates.ts b/src/features/chat/domain/thread-stream/semantics/predicates.ts new file mode 100644 index 00000000..5833d4bf --- /dev/null +++ b/src/features/chat/domain/thread-stream/semantics/predicates.ts @@ -0,0 +1,43 @@ +import type { ThreadStreamMeaningEvent, ThreadStreamMeaningPlane, ThreadStreamSemanticClassification } from "./types"; + +function threadStreamHasMeaning( + classification: ThreadStreamSemanticClassification, + plane: ThreadStreamMeaningPlane, + event: ThreadStreamMeaningEvent, +): boolean { + return classification.meaning.plane === plane && classification.meaning.event === event; +} + +export function threadStreamIsTurnInitiator(classification: ThreadStreamSemanticClassification): boolean { + return ( + (classification.placement.scope === "turn" || classification.placement.scope === "pendingTurn") && + classification.placement.turnRole === "initiator" + ); +} + +export function threadStreamIsTurnSteer(classification: ThreadStreamSemanticClassification): boolean { + return ( + (classification.placement.scope === "turn" || classification.placement.scope === "pendingTurn") && + classification.placement.turnRole === "steer" + ); +} + +export function threadStreamIsWorkspaceResult(classification: ThreadStreamSemanticClassification): boolean { + return threadStreamHasMeaning(classification, "workspace", "result"); +} + +export function threadStreamIsCoordinationProgress(classification: ThreadStreamSemanticClassification): boolean { + return threadStreamHasMeaning(classification, "coordination", "progress"); +} + +function threadStreamIsPermissionDecision(classification: ThreadStreamSemanticClassification): boolean { + return threadStreamHasMeaning(classification, "permission", "decision"); +} + +export function threadStreamIsAutoReviewDecision(classification: ThreadStreamSemanticClassification): boolean { + const { provenance } = classification; + if (!threadStreamIsPermissionDecision(classification) || !provenance) return false; + if (provenance.source === "appServer" && provenance.channel === "notification") return provenance.event === "autoReview"; + if (provenance.source === "panel" && provenance.channel === "notice") return provenance.reason === "parsedAutoReview"; + return false; +} diff --git a/src/features/chat/domain/thread-stream/semantics/types.ts b/src/features/chat/domain/thread-stream/semantics/types.ts new file mode 100644 index 00000000..5dbf7cb6 --- /dev/null +++ b/src/features/chat/domain/thread-stream/semantics/types.ts @@ -0,0 +1,73 @@ +import type { ExecutionState, ThreadStreamItem } from "../items"; +import type { ThreadStreamItemProvenance } from "../provenance"; + +type ThreadStreamTurnRole = "initiator" | "steer" | "detail" | "outcome"; + +export type ThreadStreamPlacement = + | { + scope: "thread"; + } + | { + scope: "turn"; + turnId: string; + turnRole: ThreadStreamTurnRole; + } + | { + scope: "pendingTurn"; + turnRole: Extract; + } + | { + scope: "item"; + parentItemId: string; + turnId?: string; + } + | { + scope: "panel"; + }; + +export type ThreadStreamMeaningPlane = + | "dialogue" + | "interaction" + | "execution" + | "workspace" + | "coordination" + | "permission" + | "review" + | "context" + | "diagnostic"; + +export type ThreadStreamMeaningEvent = + | "request" + | "response" + | "proposal" + | "progress" + | "evidence" + | "result" + | "decision" + | "stateChange" + | "notice"; + +export interface ThreadStreamMeaning { + plane: ThreadStreamMeaningPlane; + event: ThreadStreamMeaningEvent; +} + +export interface ThreadStreamLifecycle { + state: Exclude; +} + +export interface ThreadStreamSemanticCapabilities { + canForkFromHere: boolean; + canRollbackToPrompt: boolean; + canImplementPlan: boolean; + isTurnOutcome: boolean; +} + +export interface ThreadStreamSemanticClassification { + item: ThreadStreamItem; + provenance?: ThreadStreamItemProvenance; + placement: ThreadStreamPlacement; + meaning: ThreadStreamMeaning; + lifecycle?: ThreadStreamLifecycle; + capabilities: ThreadStreamSemanticCapabilities; +} diff --git a/src/features/chat/domain/message-stream/updates.ts b/src/features/chat/domain/thread-stream/updates.ts similarity index 64% rename from src/features/chat/domain/message-stream/updates.ts rename to src/features/chat/domain/thread-stream/updates.ts index afe34cdc..7d62e9bf 100644 --- a/src/features/chat/domain/message-stream/updates.ts +++ b/src/features/chat/domain/thread-stream/updates.ts @@ -1,8 +1,8 @@ -import type { MessageStreamFileChange, MessageStreamItem } from "./items"; -import { messageStreamSemanticClassifications } from "./semantics/classify"; -import { messageStreamIsTurnInitiator } from "./semantics/predicates"; +import type { ThreadStreamFileChange, ThreadStreamItem } from "./items"; +import { threadStreamSemanticClassifications } from "./semantics/classify"; +import { threadStreamIsTurnInitiator } from "./semantics/predicates"; -export function upsertMessageStreamItemById(items: readonly MessageStreamItem[], next: MessageStreamItem): MessageStreamItem[] { +export function upsertThreadStreamItemById(items: readonly ThreadStreamItem[], next: ThreadStreamItem): ThreadStreamItem[] { const index = items.findIndex((item) => item.id === next.id); if (index === -1) return [...items, next]; const copy = [...items]; @@ -13,25 +13,25 @@ export function upsertMessageStreamItemById(items: readonly MessageStreamItem[], ...next, output: mergeOutput(previous, next), changes: mergeChanges(previous, next), - } as MessageStreamItem; + } as ThreadStreamItem; return copy; } -function mergeOutput(previous: MessageStreamItem, next: MessageStreamItem): string | undefined { +function mergeOutput(previous: ThreadStreamItem, next: ThreadStreamItem): string | undefined { const previousOutput = "output" in previous ? previous.output : undefined; const nextOutput = "output" in next ? next.output : undefined; return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput; } -function mergeChanges(previous: MessageStreamItem, next: MessageStreamItem): readonly MessageStreamFileChange[] | undefined { +function mergeChanges(previous: ThreadStreamItem, next: ThreadStreamItem): readonly ThreadStreamFileChange[] | undefined { const previousChanges = previous.kind === "fileChange" ? previous.changes : undefined; const nextChanges = next.kind === "fileChange" ? next.changes : undefined; return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges; } -export function completeReasoningItems(items: readonly MessageStreamItem[], turnId: string): readonly MessageStreamItem[] { +export function completeReasoningItems(items: readonly ThreadStreamItem[], turnId: string): readonly ThreadStreamItem[] { let changed = false; - const nextItems: MessageStreamItem[] = []; + const nextItems: ThreadStreamItem[] = []; for (const item of items) { if (item.kind !== "reasoning" || item.turnId !== turnId) { nextItems.push(item); @@ -42,17 +42,17 @@ export function completeReasoningItems(items: readonly MessageStreamItem[], turn ...item, status: "completed", executionState: "completed", - } satisfies MessageStreamItem); + } satisfies ThreadStreamItem); } return changed ? nextItems : items; } export function attachHookRunsToTurn( - items: readonly MessageStreamItem[], + items: readonly ThreadStreamItem[], turnId: string, hookItemIds: readonly string[], afterItemId?: string | null, -): MessageStreamItem[] { +): ThreadStreamItem[] { const hookIdSet = new Set(hookItemIds); const attachedHooks = items.filter((item) => hookIdSet.has(item.id)).map((item) => ({ ...item, turnId })); if (attachedHooks.length === 0) return [...items]; @@ -65,12 +65,12 @@ export function attachHookRunsToTurn( return [...withoutAttachedHooks.slice(0, insertAfterIndex + 1), ...attachedHooks, ...withoutAttachedHooks.slice(insertAfterIndex + 1)]; } -function lastUserMessageAnchorId(items: readonly MessageStreamItem[], turnId: string): string | null { - const anchor = [...messageStreamSemanticClassifications(items)] +function lastUserMessageAnchorId(items: readonly ThreadStreamItem[], turnId: string): string | null { + const anchor = [...threadStreamSemanticClassifications(items)] .reverse() .find( (classification) => - messageStreamIsTurnInitiator(classification) && (!classification.item.turnId || classification.item.turnId === turnId), + threadStreamIsTurnInitiator(classification) && (!classification.item.turnId || classification.item.turnId === turnId), ); return anchor?.item.id ?? null; } diff --git a/src/features/chat/host/bundles/composer-bundle.ts b/src/features/chat/host/bundles/composer-bundle.ts index 54547327..49b7be79 100644 --- a/src/features/chat/host/bundles/composer-bundle.ts +++ b/src/features/chat/host/bundles/composer-bundle.ts @@ -5,8 +5,8 @@ import { runtimeSnapshotForChatState } from "../../application/runtime/snapshot" import type { ChatStateStore } from "../../application/state/store"; import { resolveRuntimeControls } from "../../domain/runtime/resolution"; import { ChatComposerController } from "../../panel/composer-controller"; -import type { ChatMessageStreamScrollBinding } from "../../panel/message-stream-scroll-binding"; import { chatPanelComposerProjection } from "../../panel/surface/composer-projection"; +import type { ChatThreadStreamScrollBinding } from "../../panel/thread-stream-scroll-binding"; import type { ChatPanelEnvironment } from "../contracts"; import { createVaultComposerAttachmentHandler } from "../obsidian/composer-attachments.obsidian"; import { VaultComposerContextReferenceProvider } from "../obsidian/vault-composer-context-reference-provider.obsidian"; @@ -16,7 +16,7 @@ import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; interface ChatPanelComposerHost { environment: ChatPanelEnvironment; stateStore: ChatStateStore; - messageScrollBinding: ChatMessageStreamScrollBinding; + threadStreamScrollBinding: ChatThreadStreamScrollBinding; } export function createChatComposerController( @@ -53,7 +53,7 @@ export function createChatComposerController( return resolveRuntimeControls(runtimeSnapshotForChatState(current), config).model.effective; }, threadScrollFromComposer: (action) => { - host.messageScrollBinding.scrollFromComposer(action); + host.threadStreamScrollBinding.scrollFromComposer(action); }, togglePlan: () => void input.runtimeSettings.toggleCollaborationMode(), toggleAutoReview: () => void input.runtimeSettings.toggleAutoReview(), diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index 95c6329a..bcf54cf0 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -4,11 +4,11 @@ import type { ChatStateStore } from "../../application/state/store"; import type { HistoryController } from "../../application/threads/history-controller"; import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions"; import type { ChatComposerController } from "../../panel/composer-controller"; -import type { ChatMessageStreamScrollBinding } from "../../panel/message-stream-scroll-binding"; import type { ChatPanelShellParts } from "../../panel/shell.dom"; import type { ChatPanelGoalSurface } from "../../panel/surface/goal-projection"; -import { MessageStreamPresenter } from "../../panel/surface/message-stream-presenter"; +import { ThreadStreamPresenter } from "../../panel/surface/thread-stream-presenter"; import type { ChatPanelToolbarSurface } from "../../panel/surface/toolbar-projection"; +import type { ChatThreadStreamScrollBinding } from "../../panel/thread-stream-scroll-binding"; import { createToolbarUiActions, type ToolbarPanelActions } from "../../panel/toolbar-actions"; import { toolbarOutsidePointerHit } from "../../panel/toolbar-hit-test.dom"; import type { ChatPanelEnvironment } from "../contracts"; @@ -19,7 +19,7 @@ import type { ChatPanelTurnBundle } from "./turn-bundle"; interface ChatPanelShellBundleHost { environment: ChatPanelEnvironment; stateStore: ChatStateStore; - messageScrollBinding: ChatMessageStreamScrollBinding; + threadStreamScrollBinding: ChatThreadStreamScrollBinding; } interface ChatPanelShellBundleInput { @@ -85,7 +85,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(), actions: goals, }; - const messageStreamPresenter = new MessageStreamPresenter({ + const threadStreamPresenter = new ThreadStreamPresenter({ obsidian: { app: environment.obsidian.app, owner: environment.obsidian.owner, @@ -97,9 +97,9 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan vaultPath: environment.plugin.settingsRef.vaultPath, }, scroll: { - portBinding: host.messageScrollBinding, + portBinding: host.threadStreamScrollBinding, dispose: () => { - host.messageScrollBinding.dispose(); + host.threadStreamScrollBinding.dispose(); }, }, history: { @@ -123,7 +123,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan actions: toolbarActions, }, goal: goalSurface, - messageStream: messageStreamPresenter, + threadStream: threadStreamPresenter, composer: { presenter: composerController, actions: { @@ -141,7 +141,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan }); }, dispose: () => { - messageStreamPresenter.dispose(); + threadStreamPresenter.dispose(); }, }; } diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 3783430e..2aef6e8f 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -5,8 +5,8 @@ import { createThreadOperations, type ThreadOperations } from "../../../threads/ import { createThreadTitleService, type ThreadTitleService } from "../../../threads/workflows/thread-title-service"; import type { ChatAppServerGateway } from "../../app-server/session-gateway"; import type { LocalIdSource } from "../../application/local-id-source"; -import { messageStreamItems } from "../../application/state/message-stream"; import type { ChatStateStore } from "../../application/state/store"; +import { threadStreamItems } from "../../application/state/thread-stream"; import { type ActiveThreadIdentitySync, createActiveThreadIdentitySync } from "../../application/threads/active-thread-identity-sync"; import { type AutoTitleCoordinator, createAutoTitleCoordinator } from "../../application/threads/auto-title-coordinator"; import { createGoalActions, createThreadGoalSyncActions } from "../../application/threads/goal-actions"; @@ -22,7 +22,7 @@ import type { ChatResumeWorkTracker } from "../../application/threads/resume-wor import { createThreadManagementActions, type ThreadManagementActionsHost } from "../../application/threads/thread-management-actions"; import { createThreadNavigationActions } from "../../application/threads/thread-navigation-actions"; import type { ThreadStartActions } from "../../application/threads/thread-start-actions"; -import { threadTitleContextFromMessageStreamItems } from "../../application/threads/title-context"; +import { threadTitleContextFromThreadStreamItems } from "../../application/threads/title-context"; import type { ChatComposerController } from "../../panel/composer-controller"; import { createToolbarPanelActions, type ToolbarPanelActions } from "../../panel/toolbar-actions"; import type { ChatPanelEnvironment } from "../contracts"; @@ -48,7 +48,7 @@ interface ChatPanelThreadHost { environment: ChatPanelEnvironment; stateStore: ChatStateStore; resumeWork: ChatResumeWorkTracker; - messageScrollBinding: { + threadStreamScrollBinding: { showLatest(): void; }; getClosing: () => boolean; @@ -118,7 +118,7 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan clientAccess: appServer.clientAccess, visibleContext: (threadId) => activeThreadRenameTitleContext(stateStore.getState(), threadId), visibleCompletedTurnContext: (turnId) => - threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)), + threadTitleContextFromThreadStreamItems(turnId, threadStreamItems(stateStore.getState().threadStream)), }); const threadOperations = createThreadOperations({ clientAccess: appServer.clientAccess, @@ -146,7 +146,7 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan historyTransport: appServer.threadHistory, addSystemMessage: status.addSystemMessage, showLatestPageAtBottom: () => { - host.messageScrollBinding.showLatest(); + host.threadStreamScrollBinding.showLatest(); }, setThreadTurnPresence: (hadTurns) => { autoTitleCoordinator.resetThreadTurnPresence(hadTurns); @@ -164,7 +164,7 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan status.addSystemMessage(text); }, addGoalEvent: (item) => { - stateStore.dispatch({ type: "message-stream/item-upserted", item }); + stateStore.dispatch({ type: "thread-stream/item-upserted", item }); }, refreshLiveState, }); @@ -203,7 +203,7 @@ export function createThreadLifecycleBundle( status.addSystemMessage(text); }, addGoalEvent: (item) => { - host.stateStore.dispatch({ type: "message-stream/item-upserted", item }); + host.stateStore.dispatch({ type: "thread-stream/item-upserted", item }); }, refreshLiveState, }); diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index d9ca77e3..9fdf5dc2 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -1,15 +1,12 @@ import type { ChatInboundHandler } from "../../app-server/inbound/handler"; import type { ChatAppServerGateway } from "../../app-server/session-gateway"; -import { - type ConversationTurnActions as ChatPanelConversationTurnActions, - createConversationTurnActions, -} from "../../application/conversation/composition"; import type { LocalIdSource } from "../../application/local-id-source"; import { createPendingRequestActions, type PendingRequestActions } from "../../application/pending-requests/pending-request-actions"; import type { ChatStateStore } from "../../application/state/store"; import type { AutoTitleCoordinator } from "../../application/threads/auto-title-coordinator"; import type { ThreadStartActions } from "../../application/threads/thread-start-actions"; -import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; +import { type TurnWorkflowActions as ChatPanelTurnWorkflowActions, createTurnWorkflowActions } from "../../application/turns/composition"; +import type { ThreadStreamNoticeSection } from "../../domain/thread-stream/items"; import type { ChatComposerController } from "../../panel/composer-controller"; import type { ChatPanelRuntimeProjection } from "../../panel/runtime-status-projection"; import type { ChatPanelEnvironment } from "../contracts"; @@ -25,20 +22,20 @@ import type { interface ChatPanelTurnStatus { set: (statusText: string) => void; addSystemMessage: (text: string) => void; - addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; + addStructuredSystemMessage: (text: string, details: ThreadStreamNoticeSection[]) => void; } interface ChatPanelTurnHost { environment: ChatPanelEnvironment; stateStore: ChatStateStore; - messageScrollBinding: { + threadStreamScrollBinding: { showLatest(): void; }; } export interface ChatPanelTurnBundle { pendingRequests: PendingRequestActions; - turnActions: ChatPanelConversationTurnActions; + turnActions: ChatPanelTurnWorkflowActions; } interface ChatPanelTurnInput { @@ -95,7 +92,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn addSystemMessage: status.addSystemMessage, setStatus: status.set, }); - const turnActions = createConversationTurnActions( + const turnActions = createTurnWorkflowActions( { stateStore: host.stateStore, localItemIds, @@ -148,7 +145,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn }, scroll: { showLatest: () => { - host.messageScrollBinding.showLatest(); + host.threadStreamScrollBinding.showLatest(); }, }, }, diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index d321f818..4e720003 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -13,10 +13,10 @@ import type { RestorationController } from "../application/threads/restoration-c import type { ResumeActions } from "../application/threads/resume-actions"; import type { ChatResumeWorkTracker } from "../application/threads/resume-work"; import { createThreadStartActions } from "../application/threads/thread-start-actions"; -import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items"; -import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; +import { createStructuredSystemItem, createSystemItem } from "../domain/thread-stream/factories/system-items"; +import type { ThreadStreamNoticeSection } from "../domain/thread-stream/items"; import type { ChatComposerController } from "../panel/composer-controller"; -import type { ChatMessageStreamScrollBinding } from "../panel/message-stream-scroll-binding"; +import type { ChatThreadStreamScrollBinding } from "../panel/thread-stream-scroll-binding"; import { createChatComposerController } from "./bundles/composer-bundle"; import { type ChatPanelConnectionBundle, createConnectionBundle } from "./bundles/connection-bundle"; import { createRuntimeBundle } from "./bundles/runtime-bundle"; @@ -57,7 +57,7 @@ export interface ChatPanelSessionGraph { interface ChatPanelSessionStatus { set: (statusText: string, phase?: ChatConnectionPhase) => void; addSystemMessage: (text: string) => void; - addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; + addStructuredSystemMessage: (text: string, details: ThreadStreamNoticeSection[]) => void; } interface ChatPanelSessionGraphHost { @@ -66,7 +66,7 @@ interface ChatPanelSessionGraphHost { deferredTasks: ChatViewDeferredTasks; resumeWork: ChatResumeWorkTracker; connectionWork: ConnectionWorkTracker; - messageScrollBinding: ChatMessageStreamScrollBinding; + threadStreamScrollBinding: ChatThreadStreamScrollBinding; getClosing: () => boolean; viewWindow: () => Window; } @@ -298,11 +298,11 @@ function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalIdSo dispatch(stateStore, { type: "connection/status-set", statusText, ...(phase ? { phase } : {}) }); }, addSystemMessage: (text) => { - dispatch(stateStore, { type: "message-stream/system-item-added", item: createSystemItem(localItemIds.next("system"), text) }); + dispatch(stateStore, { type: "thread-stream/system-item-added", item: createSystemItem(localItemIds.next("system"), text) }); }, addStructuredSystemMessage: (text, details) => { dispatch(stateStore, { - type: "message-stream/system-item-added", + type: "thread-stream/system-item-added", item: createStructuredSystemItem(localItemIds.next("system"), text, details), }); }, diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 8be275ca..f105d66b 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -7,8 +7,8 @@ import type { ChatState } from "../application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../application/state/store"; import { parseRestoredThreadState, type RestoredThreadPlaceholderState } from "../application/threads/restored-thread-lifecycle"; import { ChatResumeWorkTracker } from "../application/threads/resume-work"; -import { type ChatMessageStreamScrollBinding, createChatMessageStreamScrollBinding } from "../panel/message-stream-scroll-binding"; import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom"; +import { type ChatThreadStreamScrollBinding, createChatThreadStreamScrollBinding } from "../panel/thread-stream-scroll-binding"; import type { ChatPanelEnvironment, ChatPanelHandle, ChatWorkspacePanelSnapshot, ChatWorkspacePanelTurnLifecycle } from "./contracts"; import { type ChatViewDeferredTasks, createChatViewDeferredTasks } from "./session/deferred-work"; import { type ChatPanelSessionGraph, createChatPanelSessionGraph } from "./session-graph"; @@ -20,7 +20,7 @@ export class ChatPanelSession implements ChatPanelHandle { private readonly deferredTasks: ChatViewDeferredTasks; private readonly connectionWork = new ConnectionWorkTracker(); private readonly resumeWork = new ChatResumeWorkTracker(); - private readonly messageScrollBinding: ChatMessageStreamScrollBinding = createChatMessageStreamScrollBinding(); + private readonly threadStreamScrollBinding: ChatThreadStreamScrollBinding = createChatThreadStreamScrollBinding(); private observedAppServerContext: AppServerQueryContext; private opened = false; private closing = false; @@ -251,7 +251,7 @@ export class ChatPanelSession implements ChatPanelHandle { deferredTasks: this.deferredTasks, resumeWork: this.resumeWork, connectionWork: this.connectionWork, - messageScrollBinding: this.messageScrollBinding, + threadStreamScrollBinding: this.threadStreamScrollBinding, getClosing: () => this.closing, viewWindow: () => this.viewWindow(), }); diff --git a/src/features/chat/panel/runtime-status-projection.ts b/src/features/chat/panel/runtime-status-projection.ts index e44a3471..bb3ffa63 100644 --- a/src/features/chat/panel/runtime-status-projection.ts +++ b/src/features/chat/panel/runtime-status-projection.ts @@ -1,10 +1,10 @@ import { runtimeConfigOrDefault } from "../../../domain/runtime/config"; import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import type { ChatState } from "../application/state/root-reducer"; -import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels"; import { resolveRuntimeControls } from "../domain/runtime/resolution"; import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; +import type { ThreadStreamNoticeSection } from "../domain/thread-stream/items"; import { appServerDiagnosticSections } from "../presentation/runtime/diagnostic-sections"; import { runtimePermissionSections } from "../presentation/runtime/permission-sections"; import { @@ -15,12 +15,12 @@ import { import { toolInventoryDiagnosticSections } from "../presentation/runtime/tool-inventory-diagnostic-sections"; export interface ChatPanelRuntimeProjection { - connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; - permissionDetails: () => MessageStreamNoticeSection[]; + connectionDiagnosticDetails: () => ThreadStreamNoticeSection[]; + permissionDetails: () => ThreadStreamNoticeSection[]; modelStatusLines: () => string[]; effortStatusLines: () => string[]; statusSummaryLines: () => string[]; - toolInventoryDetails: () => MessageStreamNoticeSection[]; + toolInventoryDetails: () => ThreadStreamNoticeSection[]; } interface ChatPanelRuntimeProjectionInput { @@ -70,7 +70,7 @@ function effortStatusLines(input: ChatPanelRuntimeProjectionInput): string[] { }); } -function connectionDiagnosticDetails(input: ChatPanelRuntimeProjectionInput): MessageStreamNoticeSection[] { +function connectionDiagnosticDetails(input: ChatPanelRuntimeProjectionInput): ThreadStreamNoticeSection[] { const state = input.state(); const sections = appServerDiagnosticSections({ connected: input.connected(), @@ -81,11 +81,11 @@ function connectionDiagnosticDetails(input: ChatPanelRuntimeProjectionInput): Me return noticeSectionsFromDiagnostics(sections); } -function toolInventoryDetails(input: ChatPanelRuntimeProjectionInput): MessageStreamNoticeSection[] { +function toolInventoryDetails(input: ChatPanelRuntimeProjectionInput): ThreadStreamNoticeSection[] { return noticeSectionsFromDiagnostics(toolInventoryDiagnosticSections(input.state().connection.serverDiagnostics)); } -function permissionDetails(input: ChatPanelRuntimeProjectionInput): MessageStreamNoticeSection[] { +function permissionDetails(input: ChatPanelRuntimeProjectionInput): ThreadStreamNoticeSection[] { const state = input.state(); return noticeSectionsFromDiagnostics( runtimePermissionSections({ @@ -97,7 +97,7 @@ function permissionDetails(input: ChatPanelRuntimeProjectionInput): MessageStrea function noticeSectionsFromDiagnostics( sections: readonly { title: string; rows: readonly { label: string; value: string }[] }[], -): MessageStreamNoticeSection[] { +): ThreadStreamNoticeSection[] { return sections.map((section) => ({ title: section.title, auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })), diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index cf127601..5966cb03 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -1,19 +1,19 @@ import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals"; import { explicitThreadName } from "../../../domain/threads/model"; -import { implementPlanTargetFromState } from "../application/conversation/plan-implementation"; -import { activeTurnId, chatTurnBusy } from "../application/conversation/turn-state"; -import { messageItemsHaveThreadTurns, runtimeSnapshotForChatSlices } from "../application/runtime/snapshot"; -import { - type MessageStreamRollbackCandidate, - messageStreamActiveItems, - messageStreamItems, - messageStreamRollbackCandidateFromItems, - messageStreamStableItems, -} from "../application/state/message-stream"; +import { runtimeSnapshotForChatSlices, threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot"; import type { ChatState } from "../application/state/root-reducer"; -import type { MessageStreamItem } from "../domain/message-stream/items"; -import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../domain/message-stream/selectors"; +import { + type ThreadStreamRollbackCandidate, + threadStreamActiveItems, + threadStreamItems, + threadStreamRollbackCandidateFromItems, + threadStreamStableItems, +} from "../application/state/thread-stream"; +import { implementPlanTargetFromState } from "../application/turns/plan-implementation"; +import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state"; import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; +import type { ThreadStreamItem } from "../domain/thread-stream/items"; +import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../domain/thread-stream/selectors"; export interface ChatPanelShellReadModelBinding { readonly readModel: ChatPanelShellReadModel; @@ -23,7 +23,7 @@ export interface ChatPanelShellReadModelBinding { interface ChatPanelShellReadModel { readonly toolbar: ChatPanelToolbarReadModel; readonly goal: ChatPanelGoalReadModel; - readonly messageStream: ChatPanelMessageStreamReadModel; + readonly threadStream: ChatPanelThreadStreamReadModel; readonly composer: ChatPanelComposerReadModel; } @@ -33,7 +33,7 @@ interface ChatPanelShellSignals { activeThread: Signal; runtime: Signal; turn: Signal; - messageStream: Signal; + threadStream: Signal; requests: Signal; composer: Signal; ui: Signal; @@ -42,14 +42,14 @@ interface ChatPanelShellSignals { activeThreadId: ReadonlySignal; activeThreadCwd: ReadonlySignal; activeThreadGoal: ReadonlySignal; - messageStreamItems: ReadonlySignal; - messageStreamStableItems: ReadonlySignal; - messageStreamActiveItems: ReadonlySignal; - messageStreamRollbackCandidate: ReadonlySignal; - messageStreamForkCandidates: ReadonlySignal; - messageStreamImplementPlanTarget: ReadonlySignal; - messageStreamDisclosures: ReadonlySignal; - messageStreamForkMenuItemId: ReadonlySignal; + threadStreamItems: ReadonlySignal; + threadStreamStableItems: ReadonlySignal; + threadStreamActiveItems: ReadonlySignal; + threadStreamRollbackCandidate: ReadonlySignal; + threadStreamForkCandidates: ReadonlySignal; + threadStreamImplementPlanTarget: ReadonlySignal; + threadStreamDisclosures: ReadonlySignal; + threadStreamForkMenuItemId: ReadonlySignal; hasThreadTurns: ReadonlySignal; goalEditor: ReadonlySignal; goalObjectiveExpanded: ReadonlySignal; @@ -97,29 +97,29 @@ export interface ChatPanelGoalReadModel { readonly goalObjectiveExpanded: ReadonlySignal; } -// Message stream read model +// Thread stream read model -export interface ChatPanelMessageStreamReadModel { +export interface ChatPanelThreadStreamReadModel { readonly activeThreadId: ReadonlySignal; readonly activeThreadCwd: ReadonlySignal; readonly activeTurnId: ReadonlySignal; - readonly historyCursor: ReadonlySignal; - readonly loadingHistory: ReadonlySignal; - readonly turnDiffs: ReadonlySignal; - readonly items: ReadonlySignal; - readonly stableItems: ReadonlySignal; - readonly activeItems: ReadonlySignal; + readonly historyCursor: ReadonlySignal; + readonly loadingHistory: ReadonlySignal; + readonly turnDiffs: ReadonlySignal; + readonly items: ReadonlySignal; + readonly stableItems: ReadonlySignal; + readonly activeItems: ReadonlySignal; readonly requests: ReadonlySignal; - readonly disclosures: ReadonlySignal; + readonly disclosures: ReadonlySignal; readonly forkMenuItemId: ReadonlySignal; - readonly rollbackCandidate: ReadonlySignal; + readonly rollbackCandidate: ReadonlySignal; readonly forkCandidates: ReadonlySignal; readonly implementPlanTarget: ReadonlySignal; } -type ChatPanelMessageStreamDisclosureBucket = Exclude; +type ChatPanelThreadStreamDisclosureBucket = Exclude; -type ChatPanelMessageStreamDisclosureState = Pick; +type ChatPanelThreadStreamDisclosureState = Pick; // Composer read model @@ -147,13 +147,13 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C const activeThread = signal(initialState.activeThread); const runtime = signal(initialState.runtime); const turn = signal(initialState.turn); - const messageStream = signal(initialState.messageStream); + const threadStream = signal(initialState.threadStream); const requests = signal(initialState.requests); const composer = signal(initialState.composer); const ui = signal(initialState.ui); const turnBusy = computed(() => chatTurnBusy({ turn: turn.value })); - const messageItems = computed(() => messageStreamItems(messageStream.value)); - const hasThreadTurns = computed(() => messageItemsHaveThreadTurns(messageItems.value)); + const streamItems = computed(() => threadStreamItems(threadStream.value)); + const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(streamItems.value)); const activeThreadIdSignal = computed(() => activeThread.value.id); const activeThreadCwd = computed(() => activeThread.value.cwd); const activeThreadTokenUsage = computed(() => activeThread.value.tokenUsage); @@ -164,7 +164,7 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C activeThread, runtime, turn, - messageStream, + threadStream, requests, composer, ui, @@ -173,21 +173,21 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C activeThreadId: activeThreadIdSignal, activeThreadCwd, activeThreadGoal, - messageStreamItems: messageItems, - messageStreamStableItems: computed(() => messageStreamStableItems(messageStream.value)), - messageStreamActiveItems: computed(() => messageStreamActiveItems(messageStream.value)), - messageStreamRollbackCandidate: computed(() => (turnBusy.value ? null : messageStreamRollbackCandidateFromItems(messageItems.value))), - messageStreamForkCandidates: computed(() => (turnBusy.value ? [] : forkCandidatesFromItems(messageItems.value))), - messageStreamImplementPlanTarget: computed(() => + threadStreamItems: streamItems, + threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)), + threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)), + threadStreamRollbackCandidate: computed(() => (turnBusy.value ? null : threadStreamRollbackCandidateFromItems(streamItems.value))), + threadStreamForkCandidates: computed(() => (turnBusy.value ? [] : forkCandidatesFromItems(streamItems.value))), + threadStreamImplementPlanTarget: computed(() => implementPlanTargetFromState({ activeThread: { id: activeThreadIdSignal.value }, turn: turn.value, runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } }, - messageStream: messageStream.value, + threadStream: threadStream.value, }), ), - messageStreamDisclosures: createMessageStreamDisclosuresSignal(ui), - messageStreamForkMenuItemId: computed(() => ui.value.messageActionMenu.forkMenuItemId), + threadStreamDisclosures: createThreadStreamDisclosuresSignal(ui), + threadStreamForkMenuItemId: computed(() => ui.value.messageActionMenu.forkMenuItemId), hasThreadTurns, goalEditor: computed(() => ui.value.goalEditor), goalObjectiveExpanded: computed(() => ui.value.disclosures.goalObjectiveExpanded), @@ -228,7 +228,7 @@ function syncShellSignals(signals: ChatPanelShellSignals, nextState: ChatState): if (signals.activeThread.value !== nextState.activeThread) signals.activeThread.value = nextState.activeThread; if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime; if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn; - if (signals.messageStream.value !== nextState.messageStream) signals.messageStream.value = nextState.messageStream; + if (signals.threadStream.value !== nextState.threadStream) signals.threadStream.value = nextState.threadStream; if (signals.requests.value !== nextState.requests) signals.requests.value = nextState.requests; if (signals.composer.value !== nextState.composer) signals.composer.value = nextState.composer; if (signals.ui.value !== nextState.ui) signals.ui.value = nextState.ui; @@ -239,7 +239,7 @@ function shellReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelShe return { toolbar: toolbarReadModelFromSignals(signals), goal: goalReadModelFromSignals(signals), - messageStream: messageStreamReadModelFromSignals(signals), + threadStream: threadStreamReadModelFromSignals(signals), composer: composerReadModelFromSignals(signals), }; } @@ -289,23 +289,23 @@ function goalReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelGoal }; } -function messageStreamReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelMessageStreamReadModel { +function threadStreamReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelThreadStreamReadModel { return { activeThreadId: signals.activeThreadId, activeThreadCwd: signals.activeThreadCwd, activeTurnId: signals.activeTurnId, - historyCursor: computed(() => signals.messageStream.value.historyCursor), - loadingHistory: computed(() => signals.messageStream.value.loadingHistory), - turnDiffs: computed(() => signals.messageStream.value.turnDiffs), - items: signals.messageStreamItems, - stableItems: signals.messageStreamStableItems, - activeItems: signals.messageStreamActiveItems, + historyCursor: computed(() => signals.threadStream.value.historyCursor), + loadingHistory: computed(() => signals.threadStream.value.loadingHistory), + turnDiffs: computed(() => signals.threadStream.value.turnDiffs), + items: signals.threadStreamItems, + stableItems: signals.threadStreamStableItems, + activeItems: signals.threadStreamActiveItems, requests: signals.requests, - disclosures: signals.messageStreamDisclosures, - forkMenuItemId: signals.messageStreamForkMenuItemId, - rollbackCandidate: signals.messageStreamRollbackCandidate, - forkCandidates: signals.messageStreamForkCandidates, - implementPlanTarget: signals.messageStreamImplementPlanTarget, + disclosures: signals.threadStreamDisclosures, + forkMenuItemId: signals.threadStreamForkMenuItemId, + rollbackCandidate: signals.threadStreamRollbackCandidate, + forkCandidates: signals.threadStreamForkCandidates, + implementPlanTarget: signals.threadStreamImplementPlanTarget, }; } @@ -338,8 +338,8 @@ function projectedThreadName(signals: ChatPanelShellSignals, threadId: string): return thread ? explicitThreadName(thread) : null; } -function createMessageStreamDisclosuresSignal(ui: Signal): ReadonlySignal { - let previous: ChatPanelMessageStreamDisclosureState | null = null; +function createThreadStreamDisclosuresSignal(ui: Signal): ReadonlySignal { + let previous: ChatPanelThreadStreamDisclosureState | null = null; return computed(() => { const disclosures = ui.value.disclosures; if ( diff --git a/src/features/chat/panel/shell.dom.tsx b/src/features/chat/panel/shell.dom.tsx index 957229ee..232c583a 100644 --- a/src/features/chat/panel/shell.dom.tsx +++ b/src/features/chat/panel/shell.dom.tsx @@ -6,7 +6,7 @@ import type { ToolbarActions } from "../ui/toolbar"; import { type ChatPanelShellReadModelBinding, createChatPanelShellReadModelBinding } from "./shell-read-model"; import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerPresenter } from "./surface/composer-projection"; import { ChatPanelGoal, type ChatPanelGoalSurface } from "./surface/goal-projection"; -import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "./surface/message-stream-presenter"; +import { ChatPanelThreadStream, type ChatPanelThreadStreamPresenter } from "./surface/thread-stream-presenter"; import { ChatPanelToolbar, type ChatPanelToolbarSurface } from "./surface/toolbar-projection"; export interface ChatPanelShellParts { @@ -15,7 +15,7 @@ export interface ChatPanelShellParts { actions: ToolbarActions; }; goal: ChatPanelGoalSurface; - messageStream: ChatPanelMessageStreamPresenter; + threadStream: ChatPanelThreadStreamPresenter; composer: { presenter: ChatPanelComposerPresenter; actions: ChatPanelComposerActions; @@ -121,7 +121,7 @@ function ChatPanelShell({
- +
diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/thread-stream-presenter.ts similarity index 60% rename from src/features/chat/panel/surface/message-stream-presenter.ts rename to src/features/chat/panel/surface/thread-stream-presenter.ts index 10e55ea7..1f823faf 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/thread-stream-presenter.ts @@ -6,30 +6,30 @@ import type { TurnDiffViewState } from "../../../turn-diff/model"; import type { PendingRequestBlockActions } from "../../application/pending-requests/block"; import type { ChatAction } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; -import type { MessageStreamScrollPortBinding } from "../../ui/message-stream/flow-scroll.measure"; -import { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/message-stream/markdown-renderer.obsidian"; -import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/stream-blocks"; -import type { ChatPanelMessageStreamReadModel } from "../shell-read-model"; -import { type ChatMessageStreamSurfaceContext, messageStreamSurfaceProjectionFromModel } from "./message-stream-projection"; +import type { ThreadStreamScrollPortBinding } from "../../ui/thread-stream/flow-scroll.measure"; +import { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/thread-stream/markdown-renderer.obsidian"; +import { ThreadStreamViewport, type ThreadStreamViewportState } from "../../ui/thread-stream/stream-blocks"; +import type { ChatPanelThreadStreamReadModel } from "../shell-read-model"; +import { type ChatThreadStreamSurfaceContext, threadStreamSurfaceProjectionFromModel } from "./thread-stream-projection"; -export interface ChatPanelMessageStreamPresenter { - renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState; +export interface ChatPanelThreadStreamPresenter { + renderState(model: ChatPanelThreadStreamReadModel): ThreadStreamViewportState; } -export function ChatPanelMessageStream({ +export function ChatPanelThreadStream({ model, presenter, }: { - model: ChatPanelMessageStreamReadModel; - presenter: ChatPanelMessageStreamPresenter; + model: ChatPanelThreadStreamReadModel; + presenter: ChatPanelThreadStreamPresenter; }): UiNode { - return h(MessageStreamViewport, { + return h(ThreadStreamViewport, { state: presenter.renderState(model), rootAttributes: { "data-codex-panel-shell-region": "message-stream" }, }); } -interface ChatMessageStreamActions { +interface ChatThreadStreamActions { rollbackThread: (threadId: string) => void; forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void; implementPlan: (itemId: string) => void; @@ -37,48 +37,48 @@ interface ChatMessageStreamActions { openTurnDiff: (state: TurnDiffViewState) => void; } -interface ChatMessageStreamRequests { +interface ChatThreadStreamRequests { pendingActions: () => PendingRequestBlockActions; consumePendingAutoFocus: () => boolean; } -interface MessageStreamPresenterObsidianContext { +interface ThreadStreamPresenterObsidianContext { app: App; owner: Component; } -interface MessageStreamPresenterStateContext { +interface ThreadStreamPresenterStateContext { store: ChatStateStore; } -interface MessageStreamPresenterWorkspaceContext { +interface ThreadStreamPresenterWorkspaceContext { vaultPath: string; } -interface MessageStreamPresenterScrollContext { - portBinding: MessageStreamScrollPortBinding; +interface ThreadStreamPresenterScrollContext { + portBinding: ThreadStreamScrollPortBinding; dispose: () => void; } -interface MessageStreamPresenterHistoryContext { +interface ThreadStreamPresenterHistoryContext { loadOlderTurns: () => void; } -export interface MessageStreamPresenterOptions { - obsidian: MessageStreamPresenterObsidianContext; - state: MessageStreamPresenterStateContext; - workspace: MessageStreamPresenterWorkspaceContext; - scroll: MessageStreamPresenterScrollContext; - history: MessageStreamPresenterHistoryContext; - actions: ChatMessageStreamActions; - requests: ChatMessageStreamRequests; +export interface ThreadStreamPresenterOptions { + obsidian: ThreadStreamPresenterObsidianContext; + state: ThreadStreamPresenterStateContext; + workspace: ThreadStreamPresenterWorkspaceContext; + scroll: ThreadStreamPresenterScrollContext; + history: ThreadStreamPresenterHistoryContext; + actions: ChatThreadStreamActions; + requests: ChatThreadStreamRequests; } -export class MessageStreamPresenter { +export class ThreadStreamPresenter { private readonly obsidianMarkdownRenderer: MarkdownMessageRenderer; - private readonly surfaceContext: ChatMessageStreamSurfaceContext; + private readonly surfaceContext: ChatThreadStreamSurfaceContext; - constructor(private readonly options: MessageStreamPresenterOptions) { + constructor(private readonly options: ThreadStreamPresenterOptions) { this.obsidianMarkdownRenderer = new MarkdownMessageRenderer({ app: options.obsidian.app, owner: options.obsidian.owner, @@ -110,8 +110,8 @@ export class MessageStreamPresenter { }; } - renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState { - const projection = messageStreamSurfaceProjectionFromModel(model, this.surfaceContext); + renderState(model: ChatPanelThreadStreamReadModel): ThreadStreamViewportState { + const projection = threadStreamSurfaceProjectionFromModel(model, this.surfaceContext); return { blocks: projection.blocks, diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts similarity index 68% rename from src/features/chat/panel/surface/message-stream-projection.ts rename to src/features/chat/panel/surface/thread-stream-projection.ts index eae46461..7b7cff2f 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -1,16 +1,16 @@ import type { TurnDiffViewState } from "../../../turn-diff/model"; import { type PendingRequestBlockActions, pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block"; import type { ChatRequestState } from "../../application/pending-requests/state"; -import type { MessageStreamRollbackCandidate } from "../../application/state/message-stream"; -import { type ForkCandidate, messageStreamSegmentsEmpty, type PlanImplementationTarget } from "../../domain/message-stream/selectors"; +import type { ThreadStreamRollbackCandidate } from "../../application/state/thread-stream"; import { pendingRequestsSignature } from "../../domain/pending-requests/signatures"; -import type { MessageStreamTextActionTargets } from "../../presentation/message-stream/text-view"; -import { type MessageStreamViewBlock, messageStreamViewBlocks } from "../../presentation/message-stream/view-model"; +import { type ForkCandidate, type PlanImplementationTarget, threadStreamSegmentsEmpty } from "../../domain/thread-stream/selectors"; import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; -import type { MessageStreamContext, MessageStreamDisclosureBucket, MessageStreamDisclosureState } from "../../ui/message-stream/context"; -import type { ChatPanelMessageStreamReadModel } from "../shell-read-model"; +import type { ThreadStreamTextActionTargets } from "../../presentation/thread-stream/text-view"; +import { type ThreadStreamViewBlock, threadStreamViewBlocks } from "../../presentation/thread-stream/view-model"; +import type { ThreadStreamContext, ThreadStreamDisclosureBucket, ThreadStreamDisclosureState } from "../../ui/thread-stream/context"; +import type { ChatPanelThreadStreamReadModel } from "../shell-read-model"; -interface ChatMessageStreamActions { +interface ChatThreadStreamActions { rollbackThread: (threadId: string) => void; forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void; implementPlan: (itemId: string) => void; @@ -18,30 +18,30 @@ interface ChatMessageStreamActions { openTurnDiff: (state: TurnDiffViewState) => void; } -interface ChatMessageStreamRequests { +interface ChatThreadStreamRequests { pendingActions: () => PendingRequestBlockActions; consumePendingAutoFocus: () => boolean; } -export interface ChatMessageStreamSurfaceContext { +export interface ChatThreadStreamSurfaceContext { vaultPath: string; - setDisclosureOpen: (bucket: MessageStreamDisclosureBucket, id: string, open: boolean) => void; + setDisclosureOpen: (bucket: ThreadStreamDisclosureBucket, id: string, open: boolean) => void; setForkMenuItem: (itemId: string | null) => void; loadOlderTurns: () => void; renderObsidianMarkdown: (element: HTMLElement, text: string) => void; renderStreamMarkdown: (element: HTMLElement, text: string) => void; copyMessageText: (text: string) => void; - actions: ChatMessageStreamActions; - requests: ChatMessageStreamRequests; + actions: ChatThreadStreamActions; + requests: ChatThreadStreamRequests; } -interface MessageStreamStateProjection { +interface ThreadStreamStateProjection { activeThreadId: string | null; workspaceRoot: string; - disclosures: MessageStreamDisclosureState; + disclosures: ThreadStreamDisclosureState; forkMenuItemId: string | null; pendingRequests: { signature: string; snapshot: PendingRequestBlockSnapshot } | null; - viewBlocks: readonly MessageStreamViewBlock[]; + viewBlocks: readonly ThreadStreamViewBlock[]; } interface PendingRequestSurfaceProjection { @@ -49,26 +49,26 @@ interface PendingRequestSurfaceProjection { readonly snapshot: PendingRequestBlockSnapshot; } -export interface MessageStreamSurfaceProjection { - blocks: readonly MessageStreamViewBlock[]; - context: MessageStreamContext; +export interface ThreadStreamSurfaceProjection { + blocks: readonly ThreadStreamViewBlock[]; + context: ThreadStreamContext; } -export function messageStreamSurfaceProjectionFromModel( - model: ChatPanelMessageStreamReadModel, - context: ChatMessageStreamSurfaceContext, -): MessageStreamSurfaceProjection { - const projection = messageStreamStateProjection(model, context); +export function threadStreamSurfaceProjectionFromModel( + model: ChatPanelThreadStreamReadModel, + context: ChatThreadStreamSurfaceContext, +): ThreadStreamSurfaceProjection { + const projection = threadStreamStateProjection(model, context); return { blocks: projection.viewBlocks, - context: messageStreamContextFromProjection(projection, context), + context: threadStreamContextFromProjection(projection, context), }; } -function messageStreamContextFromProjection( - projection: MessageStreamStateProjection, - context: ChatMessageStreamSurfaceContext, -): MessageStreamContext { +function threadStreamContextFromProjection( + projection: ThreadStreamStateProjection, + context: ChatThreadStreamSurfaceContext, +): ThreadStreamContext { const pendingRequests = projection.pendingRequests; return { activeThreadId: projection.activeThreadId, @@ -111,20 +111,20 @@ function messageStreamContextFromProjection( }; } -function messageStreamStateProjection( - model: ChatPanelMessageStreamReadModel, - context: ChatMessageStreamSurfaceContext, -): MessageStreamStateProjection { +function threadStreamStateProjection( + model: ChatPanelThreadStreamReadModel, + context: ChatThreadStreamSurfaceContext, +): ThreadStreamStateProjection { const stableItems = model.stableItems.value; const activeItems = model.activeItems.value; const disclosures = model.disclosures.value; const workspaceRoot = model.activeThreadCwd.value ?? context.vaultPath; - const textActionTargetsByItemId = textActionTargetsForMessageStreamItems( + const textActionTargetsByItemId = textActionTargetsForThreadStreamItems( model.rollbackCandidate.value, model.forkCandidates.value, model.implementPlanTarget.value, ); - const pendingRequests = messageStreamSegmentsEmpty(stableItems, activeItems) + const pendingRequests = threadStreamSegmentsEmpty(stableItems, activeItems) ? null : pendingRequestSurfaceProjectionFromState(model.requests.value, disclosures.approvalDetails); @@ -134,7 +134,7 @@ function messageStreamStateProjection( disclosures, forkMenuItemId: model.forkMenuItemId.value, pendingRequests, - viewBlocks: messageStreamViewBlocks({ + viewBlocks: threadStreamViewBlocks({ activeThreadId: model.activeThreadId.value, activeTurnId: model.activeTurnId.value, historyCursor: model.historyCursor.value, @@ -150,12 +150,12 @@ function messageStreamStateProjection( }; } -function textActionTargetsForMessageStreamItems( - rollbackCandidate: MessageStreamRollbackCandidate | null, +function textActionTargetsForThreadStreamItems( + rollbackCandidate: ThreadStreamRollbackCandidate | null, forkCandidates: readonly ForkCandidate[], implementPlanTarget: PlanImplementationTarget | null, -): ReadonlyMap { - const byItemId = new Map(); +): ReadonlyMap { + const byItemId = new Map(); for (const candidate of forkCandidates) { patchTextActionTargets(byItemId, candidate.itemId, { fork: { itemId: candidate.itemId, turnId: candidate.turnId } }); } @@ -187,9 +187,9 @@ function pendingRequestSurfaceProjectionFromState( } function patchTextActionTargets( - byItemId: Map, + byItemId: Map, itemId: string, - patch: MessageStreamTextActionTargets, + patch: ThreadStreamTextActionTargets, ): void { byItemId.set(itemId, { ...byItemId.get(itemId), ...patch }); } diff --git a/src/features/chat/panel/message-stream-scroll-binding.ts b/src/features/chat/panel/thread-stream-scroll-binding.ts similarity index 59% rename from src/features/chat/panel/message-stream-scroll-binding.ts rename to src/features/chat/panel/thread-stream-scroll-binding.ts index 07130c7b..cd2ce596 100644 --- a/src/features/chat/panel/message-stream-scroll-binding.ts +++ b/src/features/chat/panel/thread-stream-scroll-binding.ts @@ -1,20 +1,20 @@ import type { ComposerBoundaryScrollAction } from "../application/composer/boundary-scroll"; import type { - MessageStreamScrollCommand, - MessageStreamScrollPort, - MessageStreamScrollPortBinding, -} from "../ui/message-stream/flow-scroll.measure"; + ThreadStreamScrollCommand, + ThreadStreamScrollPort, + ThreadStreamScrollPortBinding, +} from "../ui/thread-stream/flow-scroll.measure"; -export interface ChatMessageStreamScrollBinding extends MessageStreamScrollPortBinding { +export interface ChatThreadStreamScrollBinding extends ThreadStreamScrollPortBinding { showLatest(): void; scrollFromComposer(action: ComposerBoundaryScrollAction): void; dispose(): void; } -export function createChatMessageStreamScrollBinding(): ChatMessageStreamScrollBinding { - let scrollPort: MessageStreamScrollPort | null = null; +export function createChatThreadStreamScrollBinding(): ChatThreadStreamScrollBinding { + let scrollPort: ThreadStreamScrollPort | null = null; - const dispatch = (command: MessageStreamScrollCommand): void => { + const dispatch = (command: ThreadStreamScrollCommand): void => { scrollPort?.dispatchScrollCommand(command); }; diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index 429b8dfe..b2c2dfca 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -134,7 +134,7 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb startNewThread: () => { void deps.navigation.startNewThread(); }, - compactConversation: () => { + compactContext: () => { void deps.threadActions.compactActiveThread(); }, setGoal: () => { diff --git a/src/features/chat/presentation/message-stream/view-model.ts b/src/features/chat/presentation/message-stream/view-model.ts deleted file mode 100644 index 949592a9..00000000 --- a/src/features/chat/presentation/message-stream/view-model.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { AgentRunSummary, MessageStreamItem, TaskProgressMessageStreamItem } from "../../domain/message-stream/items"; -import { messageStreamItemsEmpty } from "../../domain/message-stream/selectors"; -import { activeTurnLiveItems, messageStreamItemsWithoutActiveTaskProgress } from "../../domain/message-stream/semantics/active-turn"; -import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics/types"; -import type { PendingRequestBlockSnapshot } from "../pending-requests/view-model"; -import { type DetailView, detailView } from "./detail-view"; -import { type MessageStreamItemAnnotations, type MessageStreamLayoutBlock, messageStreamLayoutBlocks } from "./layout"; -import { type AgentRunSummaryView, agentRunSummaryView, type MessageStreamStatusView, messageStreamStatusView } from "./status-view"; -import { type MessageStreamTextActionTargets, type MessageStreamTextView, messageStreamTextView } from "./text-view"; - -interface PendingRequestMessageStreamBlockInput { - signature: string; - snapshot: PendingRequestBlockSnapshot; -} - -export interface MessageStreamPresentationBlockInput { - activeThreadId: string | null; - activeTurnId: string | null; - historyCursor: string | null; - loadingHistory: boolean; - items: readonly MessageStreamItem[]; - stableItems?: readonly MessageStreamItem[] | undefined; - activeItems?: readonly MessageStreamItem[] | undefined; - workspaceRoot?: string | null | undefined; - turnDiffs?: ReadonlyMap | undefined; - textActionTargetsByItemId?: ReadonlyMap | undefined; - pendingRequests?: PendingRequestMessageStreamBlockInput | null | undefined; -} - -type MessageStreamPresentationBlock = - | { - kind: "historyBar"; - key: "history-bar"; - loadingHistory: boolean; - } - | { - kind: "empty"; - key: "empty"; - } - | { - kind: "item"; - key: string; - block: Extract; - } - | { - kind: "activityGroup"; - key: string; - block: Extract; - } - | { - kind: "liveTask"; - key: string; - item: TaskProgressMessageStreamItem; - } - | { - kind: "liveAgentSummary"; - key: string; - summary: AgentRunSummary; - } - | { - kind: "pendingRequests"; - key: "pending-requests"; - signature: string; - snapshot: PendingRequestBlockSnapshot; - }; - -type MessageStreamPresentationBlockSource = (input: MessageStreamPresentationBlockInput) => readonly MessageStreamPresentationBlock[]; -type MessageStreamRenderFamily = "text" | "detail" | "status"; - -export type MessageStreamRenderedItemView = - | { - kind: "text"; - view: MessageStreamTextView; - } - | { - kind: "detail"; - view: DetailView; - } - | { - kind: "status"; - view: MessageStreamStatusView; - }; - -export type MessageStreamActivityItemView = - | ({ - type: "item"; - id: string; - } & MessageStreamRenderedItemView) - | { - type: "steering"; - id: string; - label: string; - text: string; - sourceItemId: string; - }; - -export type MessageStreamViewBlock = - | { - kind: "historyBar"; - key: "history-bar"; - loadingHistory: boolean; - } - | { - kind: "empty"; - key: "empty"; - } - | ({ - key: string; - } & MessageStreamRenderedItemView) - | { - kind: "activityGroup"; - key: string; - id: string; - turnId: string; - summary: string; - items: MessageStreamActivityItemView[]; - } - | { - kind: "liveAgentSummary"; - key: string; - view: AgentRunSummaryView; - } - | { - kind: "pendingRequests"; - key: "pending-requests"; - signature: string; - snapshot: PendingRequestBlockSnapshot; - }; - -export function messageStreamViewBlocks(input: MessageStreamPresentationBlockInput): MessageStreamViewBlock[] { - return messageStreamPresentationBlocks(input).map((block) => messageStreamViewBlockFromPresentationBlock(block, input)); -} - -function messageStreamPresentationBlocks(input: MessageStreamPresentationBlockInput): MessageStreamPresentationBlock[] { - const headerBlocks = historyPresentationBlocks(input); - if (messageStreamItemsEmpty(input)) { - return [...headerBlocks, { kind: "empty", key: "empty" }]; - } - - return [ - ...headerBlocks, - ...collectPresentationBlocks(input, [layoutPresentationBlocks, activeTurnPresentationBlocks, pendingRequestPresentationBlocks]), - ]; -} - -function collectPresentationBlocks( - input: MessageStreamPresentationBlockInput, - sources: readonly MessageStreamPresentationBlockSource[], -): MessageStreamPresentationBlock[] { - return sources.flatMap((source) => source(input)); -} - -function historyPresentationBlocks(input: MessageStreamPresentationBlockInput): readonly MessageStreamPresentationBlock[] { - if (!input.activeThreadId || !input.historyCursor) return []; - return [{ kind: "historyBar", key: "history-bar", loadingHistory: input.loadingHistory }]; -} - -function layoutPresentationBlocks(input: MessageStreamPresentationBlockInput): readonly MessageStreamPresentationBlock[] { - return layoutBlocksForInput(input).map(presentationBlockFromLayoutBlock); -} - -function presentationBlockFromLayoutBlock(block: MessageStreamLayoutBlock): MessageStreamPresentationBlock { - if (block.type === "item") return { kind: "item", key: `item:${block.item.id}`, block }; - return { kind: "activityGroup", key: `activity:${block.id}`, block }; -} - -function activeTurnPresentationBlocks(input: MessageStreamPresentationBlockInput): readonly MessageStreamPresentationBlock[] { - if (!input.activeTurnId) return []; - return activeTurnLiveBlocks(input, input.activeTurnId); -} - -function pendingRequestPresentationBlocks(input: MessageStreamPresentationBlockInput): readonly MessageStreamPresentationBlock[] { - if (!input.pendingRequests?.signature) return []; - return [ - { - kind: "pendingRequests", - key: "pending-requests", - signature: input.pendingRequests.signature, - snapshot: input.pendingRequests.snapshot, - }, - ]; -} - -function layoutBlocksForInput(input: MessageStreamPresentationBlockInput): MessageStreamLayoutBlock[] { - const { activeTurnId } = input; - if (!activeTurnId || !input.stableItems || !input.activeItems) { - const streamItems = activeTurnId ? messageStreamItemsWithoutActiveTaskProgress(input.items, activeTurnId) : input.items; - return messageStreamLayoutBlocks(streamItems, activeTurnId, input.workspaceRoot, input.turnDiffs); - } - const stableBlocks = messageStreamLayoutBlocks(input.stableItems, activeTurnId, input.workspaceRoot, input.turnDiffs); - const activeBlocks = messageStreamLayoutBlocks( - messageStreamItemsWithoutActiveTaskProgress(input.activeItems, activeTurnId), - activeTurnId, - input.workspaceRoot, - input.turnDiffs, - ); - return [...stableBlocks, ...activeBlocks]; -} - -function activeTurnLiveBlocks( - input: Pick, - activeTurnId: string, -): MessageStreamPresentationBlock[] { - return activeTurnLiveItems(input, activeTurnId).map((item): MessageStreamPresentationBlock => { - if (item.kind === "taskProgress") { - return { - kind: "liveTask", - key: `live-task:${item.item.id}`, - item: item.item, - }; - } - return { kind: "liveAgentSummary", key: `live-agents:${activeTurnId}`, summary: item.summary }; - }); -} - -function messageStreamViewBlockFromPresentationBlock( - block: MessageStreamPresentationBlock, - input: MessageStreamPresentationBlockInput, -): MessageStreamViewBlock { - if (block.kind === "historyBar" || block.kind === "empty") return block; - if (block.kind === "pendingRequests") return block; - if (block.kind === "liveAgentSummary") return { kind: "liveAgentSummary", key: block.key, view: agentRunSummaryView(block.summary) }; - if (block.kind === "liveTask") { - return { kind: "status", key: block.key, view: messageStreamStatusView(block.item, statusViewContext(input)) }; - } - if (block.kind === "activityGroup") { - return { - kind: "activityGroup", - key: block.key, - id: block.block.id, - turnId: block.block.turnId, - summary: block.block.summary, - items: block.block.items.map((activity) => messageStreamActivityItemView(activity, input)), - }; - } - return { key: block.key, ...messageStreamRenderedItemView(block.block.classification, input, block.block.annotations) }; -} - -function messageStreamActivityItemView( - activity: Extract["items"][number], - input: MessageStreamPresentationBlockInput, -): MessageStreamActivityItemView { - if (activity.type === "steering") return activity; - return { type: "item", id: activity.id, ...messageStreamRenderedItemView(activity.classification, input) }; -} - -function messageStreamRenderedItemView( - classification: MessageStreamSemanticClassification, - input: MessageStreamPresentationBlockInput, - annotations?: MessageStreamItemAnnotations, -): MessageStreamRenderedItemView { - const renderFamily = messageStreamRenderFamily(classification); - switch (renderFamily) { - case "text": - return { - kind: "text", - view: messageStreamTextView(classification.item, annotations, { - activeTurnId: input.activeTurnId, - ...definedProp("actionTargets", input.textActionTargetsByItemId?.get(classification.item.id)), - }), - }; - case "detail": - return { kind: "detail", view: detailView(classification.item, input.workspaceRoot) }; - case "status": - return { kind: "status", view: messageStreamStatusView(classification.item, statusViewContext(input)) }; - } -} - -function messageStreamRenderFamily(classification: MessageStreamSemanticClassification): MessageStreamRenderFamily { - switch (classification.item.kind) { - case "message": - case "system": - case "userInputResult": - return "text"; - case "command": - case "fileChange": - case "tool": - case "hook": - case "goal": - case "approvalResult": - case "reviewResult": - case "agent": - return "detail"; - case "taskProgress": - case "reasoning": - case "wait": - case "contextCompaction": - return "status"; - } - return "status"; -} - -function statusViewContext(input: MessageStreamPresentationBlockInput): Parameters[1] { - return { - activeTurnId: input.activeTurnId, - items: input.items, - activeItems: input.activeItems, - }; -} - -function definedProp(key: Key, value: Value | undefined): Partial> { - return value === undefined ? {} : ({ [key]: value } as Partial>); -} diff --git a/src/features/chat/presentation/message-stream/detail-view.ts b/src/features/chat/presentation/thread-stream/detail-view.ts similarity index 78% rename from src/features/chat/presentation/message-stream/detail-view.ts rename to src/features/chat/presentation/thread-stream/detail-view.ts index e3f8c3ed..fb2fc258 100644 --- a/src/features/chat/presentation/message-stream/detail-view.ts +++ b/src/features/chat/presentation/thread-stream/detail-view.ts @@ -1,22 +1,22 @@ import { truncate } from "../../../../domain/display/text-preview"; import { shortThreadId } from "../../../../domain/threads/id"; import { pathRelativeToRoot } from "../../../../domain/vault/paths"; -import { agentMessagePreview } from "../../domain/message-stream/format/agent-message-preview"; +import { agentMessagePreview } from "../../domain/thread-stream/format/agent-message-preview"; import type { - AgentMessageStreamItem, - ApprovalResultMessageStreamItem, - CommandMessageStreamItem, - CommandMessageStreamTarget, + AgentThreadStreamItem, + ApprovalResultThreadStreamItem, + CommandThreadStreamItem, + CommandThreadStreamTarget, ExecutionState, - FileChangeMessageStreamItem, - GoalMessageStreamItem, - HookMessageStreamItem, - MessageStreamFileChange, - MessageStreamItem, - MessageStreamPrimaryTarget, - ReviewResultMessageStreamItem, - ToolCallMessageStreamItem, -} from "../../domain/message-stream/items"; + FileChangeThreadStreamItem, + GoalThreadStreamItem, + HookThreadStreamItem, + ReviewResultThreadStreamItem, + ThreadStreamFileChange, + ThreadStreamItem, + ThreadStreamPrimaryTarget, + ToolCallThreadStreamItem, +} from "../../domain/thread-stream/items"; const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120; const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96; @@ -36,11 +36,11 @@ export interface DetailView { state: ExecutionState; } -export function detailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView { +export function detailView(item: ThreadStreamItem, workspaceRoot?: string | null): DetailView { return codexDetailView(item, workspaceRoot) ?? genericDetailView(item, workspaceRoot); } -function codexDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView | null { +function codexDetailView(item: ThreadStreamItem, workspaceRoot?: string | null): DetailView | null { switch (item.kind) { case "command": return commandDetailView(item); @@ -63,7 +63,7 @@ function codexDetailView(item: MessageStreamItem, workspaceRoot?: string | null) } function detailViewBase( - item: MessageStreamItem, + item: ThreadStreamItem, className: string, label: string, detailsKey: string, @@ -82,7 +82,7 @@ function detailViewBase( }; } -function commandDetailView(item: CommandMessageStreamItem): DetailView { +function commandDetailView(item: CommandThreadStreamItem): DetailView { const rows = [ { key: "command", value: item.command }, { key: "cwd", value: item.cwd }, @@ -107,7 +107,7 @@ function commandDetailView(item: CommandMessageStreamItem): DetailView { ); } -function fileChangeDetailView(item: FileChangeMessageStreamItem, workspaceRoot?: string | null): DetailView { +function fileChangeDetailView(item: FileChangeThreadStreamItem, workspaceRoot?: string | null): DetailView { const displayChanges = item.changes.map((change) => ({ ...change, displayPath: change.path && change.path !== "(unknown)" ? pathRelativeToRoot(change.path, workspaceRoot) : change.path, @@ -137,7 +137,7 @@ function fileChangeDetailView(item: FileChangeMessageStreamItem, workspaceRoot?: ); } -function goalDetailView(item: GoalMessageStreamItem): DetailView { +function goalDetailView(item: GoalThreadStreamItem): DetailView { return detailViewBase( item, "codex-panel__detail-item codex-panel__detail-item--goal", @@ -147,7 +147,7 @@ function goalDetailView(item: GoalMessageStreamItem): DetailView { ); } -function agentDetailView(item: AgentMessageStreamItem): DetailView { +function agentDetailView(item: AgentThreadStreamItem): DetailView { return detailViewBase( item, "codex-panel__detail-item codex-panel__agent-activity", @@ -159,7 +159,7 @@ function agentDetailView(item: AgentMessageStreamItem): DetailView { ); } -function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): DetailView { +function genericToolDetailView(item: ToolCallThreadStreamItem | HookThreadStreamItem, workspaceRoot?: string | null): DetailView { return detailViewBase( item, "codex-panel__detail-item", @@ -170,7 +170,7 @@ function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStre ); } -function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView { +function reviewDetailView(item: ReviewResultThreadStreamItem): DetailView { return resultDetailView( item, "auto-review", @@ -179,7 +179,7 @@ function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView { ); } -function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView { +function approvalDetailView(item: ApprovalResultThreadStreamItem): DetailView { return resultDetailView( item, "approval", @@ -188,7 +188,7 @@ function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView { ); } -function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView { +function genericDetailView(item: ThreadStreamItem, workspaceRoot?: string | null): DetailView { return detailViewBase( item, "codex-panel__detail-item", @@ -204,7 +204,7 @@ function messageDetailKey(itemId: string, suffix: string): string { } function resultDetailView( - item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem, + item: ApprovalResultThreadStreamItem | ReviewResultThreadStreamItem, label: string, detailsKey: string, className: string, @@ -212,7 +212,7 @@ function resultDetailView( return detailViewBase(item, `codex-panel__detail-item ${className}`, label, detailsKey, resultDetails(item)); } -function goalDetails(item: GoalMessageStreamItem): DetailSection[] { +function goalDetails(item: GoalThreadStreamItem): DetailSection[] { return [ { kind: "kv", @@ -222,7 +222,7 @@ function goalDetails(item: GoalMessageStreamItem): DetailSection[] { ]; } -function agentDetailSections(item: AgentMessageStreamItem): DetailSection[] { +function agentDetailSections(item: AgentThreadStreamItem): DetailSection[] { const rows = [ { key: "tool", value: agentActivityMetaLabel(item.tool) }, { key: "status", value: item.status }, @@ -243,7 +243,7 @@ function agentDetailSections(item: AgentMessageStreamItem): DetailSection[] { ]; } -function agentStateSection(item: AgentMessageStreamItem): DetailSection[] { +function agentStateSection(item: AgentThreadStreamItem): DetailSection[] { const rows = item.agents.map((agent) => ({ key: shortThreadId(agent.threadId), value: agentStatusLabel(agent.status, agent.message), @@ -251,7 +251,7 @@ function agentStateSection(item: AgentMessageStreamItem): DetailSection[] { return rows.length > 0 ? [{ kind: "kv", title: "agents", rows }] : []; } -function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem): DetailSection[] { +function resultDetails(item: ApprovalResultThreadStreamItem | ReviewResultThreadStreamItem): DetailSection[] { if (item.kind === "approvalResult") { return [ { @@ -268,12 +268,12 @@ function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessa return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "kv", rows: item.review.auditFacts }] : []; } -function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): DetailSection[] { +function genericToolDetails(item: ToolCallThreadStreamItem | HookThreadStreamItem): DetailSection[] { if (item.kind === "hook") return hookRunDetails(item); return [...diagnosticDetails(item), ...webSearchDetails(item), ...imageGenerationDetails(item)]; } -function genericDetailSections(item: MessageStreamItem, workspaceRoot?: string | null): DetailSection[] { +function genericDetailSections(item: ThreadStreamItem, workspaceRoot?: string | null): DetailSection[] { const rows = [ ...metaRow("kind", item.kind), ...metaRow("status", stringField(item, "status")), @@ -284,11 +284,11 @@ function genericDetailSections(item: MessageStreamItem, workspaceRoot?: string | return [...(rows.length > 0 ? [{ kind: "kv" as const, rows }] : []), ...outputSection("Output", outputField(item))]; } -function diagnosticDetails(item: ToolCallMessageStreamItem): DetailSection[] { +function diagnosticDetails(item: ToolCallThreadStreamItem): DetailSection[] { return item.diagnostics?.map((section) => ({ kind: "output" as const, title: section.title, body: section.body })) ?? []; } -function webSearchDetails(item: ToolCallMessageStreamItem): DetailSection[] { +function webSearchDetails(item: ToolCallThreadStreamItem): DetailSection[] { const details = item.webSearch; if (!details) return []; const rows = [ @@ -300,7 +300,7 @@ function webSearchDetails(item: ToolCallMessageStreamItem): DetailSection[] { return rows.length > 0 ? [{ kind: "kv", title: "web search", rows }] : []; } -function imageGenerationDetails(item: ToolCallMessageStreamItem): DetailSection[] { +function imageGenerationDetails(item: ToolCallThreadStreamItem): DetailSection[] { const details = item.imageGeneration; if (!details) return []; return [ @@ -310,7 +310,7 @@ function imageGenerationDetails(item: ToolCallMessageStreamItem): DetailSection[ ]; } -function hookRunDetails(item: HookMessageStreamItem): DetailSection[] { +function hookRunDetails(item: HookThreadStreamItem): DetailSection[] { const details = item.hookRun; if (!details) return []; const rows = [ @@ -331,21 +331,21 @@ function metaRow(key: string, value: string | null | undefined): { key: string; return value ? [{ key, value }] : []; } -function primaryTargetSummary(target: MessageStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null { +function primaryTargetSummary(target: ThreadStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null { if (!target) return null; if (target.kind === "path") return pathRelativeToRoot(target.path, workspaceRoot); return target.value; } -function textField(item: MessageStreamItem): string | null { +function textField(item: ThreadStreamItem): string | null { return "text" in item && typeof item.text === "string" && item.text.trim().length > 0 ? item.text : null; } -function outputField(item: MessageStreamItem): string | null { +function outputField(item: ThreadStreamItem): string | null { return "output" in item && typeof item.output === "string" && item.output.trim().length > 0 ? item.output : null; } -function stringField(item: MessageStreamItem, key: "failureReason" | "operation" | "status" | "toolName"): string | null { +function stringField(item: ThreadStreamItem, key: "failureReason" | "operation" | "status" | "toolName"): string | null { if (!(key in item)) return null; const value = (item as unknown as Record)[key]; return typeof value === "string" && value.trim().length > 0 ? value : null; @@ -363,22 +363,22 @@ function statusQualifier(status: unknown, failure?: string | null): string | nul return null; } -function fallbackSummary(item: MessageStreamItem): string { +function fallbackSummary(item: ThreadStreamItem): string { return textField(item) ?? "details"; } -function commandActionLabel(action: CommandMessageStreamItem["commandAction"]): string { +function commandActionLabel(action: CommandThreadStreamItem["commandAction"]): string { if (action === "read") return "read"; if (action === "search") return "search"; if (action === "listFiles") return "list files"; return "command"; } -function commandSummary(item: CommandMessageStreamItem): string { +function commandSummary(item: CommandThreadStreamItem): string { return compactSummary(null, commandTargetSummary(item.commandTarget, item.cwd), commandQualifier(item)); } -function commandTargetSummary(target: CommandMessageStreamTarget, cwd: string): string { +function commandTargetSummary(target: CommandThreadStreamTarget, cwd: string): string { if (target.kind === "read") return target.path ? pathRelativeToRoot(target.path, cwd) : target.name; if (target.kind === "search") { const query = target.query ? quoteInline(target.query) : null; @@ -392,32 +392,32 @@ function commandTargetSummary(target: CommandMessageStreamTarget, cwd: string): return target.commandLine; } -function commandQualifier(item: CommandMessageStreamItem): string | null { +function commandQualifier(item: CommandThreadStreamItem): string | null { if (typeof item.exitCode === "number" && item.exitCode !== 0) return `exit ${String(item.exitCode)}`; return statusQualifier(item.status, failedStatusLabel(item.status)); } -function genericDetailSummary(item: MessageStreamItem, workspaceRoot?: string | null): string { +function genericDetailSummary(item: ThreadStreamItem, workspaceRoot?: string | null): string { const target = primaryTargetSummary(primaryTargetField(item), workspaceRoot); return compactSummary(null, target ?? textField(item) ?? outputField(item) ?? stringField(item, "status") ?? item.kind); } -function detailLabel(item: MessageStreamItem): string { +function detailLabel(item: ThreadStreamItem): string { return stringField(item, "toolName") ?? item.kind; } -function primaryTargetField(item: MessageStreamItem): MessageStreamPrimaryTarget | undefined { +function primaryTargetField(item: ThreadStreamItem): ThreadStreamPrimaryTarget | undefined { if (!("primaryTarget" in item)) return undefined; return item.primaryTarget; } -function genericToolSummary(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): string { +function genericToolSummary(item: ToolCallThreadStreamItem | HookThreadStreamItem, workspaceRoot?: string | null): string { const target = primaryTargetSummary(item.primaryTarget, workspaceRoot); if (!target) return item.text ?? "details"; return compactSummary(toolOperationLabel(item.operation), target, statusQualifier(item.status, item.failureReason)); } -function fileChangeSummary(item: FileChangeMessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string { +function fileChangeSummary(item: FileChangeThreadStreamItem, changes: (ThreadStreamFileChange & { displayPath: string })[]): string { const target = fileChangeTargetSummary(changes); return compactSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))); } @@ -428,13 +428,13 @@ function failedStatusLabel(status: unknown): string | null { return null; } -function agentSummaryText(item: AgentMessageStreamItem): string { +function agentSummaryText(item: AgentThreadStreamItem): string { const target = item.receiverThreadIds.length === 0 ? "" : ` ${item.receiverThreadIds.map(shortThreadId).join(", ")}`; const promptPreview = agentPromptPreview(item.prompt); return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`; } -function agentThreadIds(item: AgentMessageStreamItem): readonly string[] { +function agentThreadIds(item: AgentThreadStreamItem): readonly string[] { return [...new Set([...item.receiverThreadIds, ...item.agents.map((agent) => agent.threadId)])].sort((a, b) => a.localeCompare(b)); } @@ -462,7 +462,7 @@ function isLongAgentMessage(message: string): boolean { return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n"); } -function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPath: string })[]): string { +function fileChangeTargetSummary(changes: (ThreadStreamFileChange & { displayPath: string })[]): string { if (changes.length === 0) return "no files"; if (changes.length === 1) return changes[0]?.displayPath ?? "1 file"; return `${String(changes.length)} files`; diff --git a/src/features/chat/presentation/message-stream/layout.ts b/src/features/chat/presentation/thread-stream/layout.ts similarity index 67% rename from src/features/chat/presentation/message-stream/layout.ts rename to src/features/chat/presentation/thread-stream/layout.ts index f2916441..75f73447 100644 --- a/src/features/chat/presentation/message-stream/layout.ts +++ b/src/features/chat/presentation/thread-stream/layout.ts @@ -1,28 +1,28 @@ import { pathRelativeToRoot } from "../../../../domain/vault/paths"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; -import { messageStreamSemanticClassifications } from "../../domain/message-stream/semantics/classify"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import { threadStreamSemanticClassifications } from "../../domain/thread-stream/semantics/classify"; import { - messageStreamIsAutoReviewDecision, - messageStreamIsTurnInitiator, - messageStreamIsTurnSteer, - messageStreamIsWorkspaceResult, -} from "../../domain/message-stream/semantics/predicates"; -import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics/types"; + threadStreamIsAutoReviewDecision, + threadStreamIsTurnInitiator, + threadStreamIsTurnSteer, + threadStreamIsWorkspaceResult, +} from "../../domain/thread-stream/semantics/predicates"; +import type { ThreadStreamSemanticClassification } from "../../domain/thread-stream/semantics/types"; const STEERING_ACTIVITY_LABEL = "steer"; -export interface MessageStreamItemAnnotations { +export interface ThreadStreamItemAnnotations { editedFiles?: string[]; turnDiff?: { diff: string }; autoReviewSummaries?: string[]; } -type MessageStreamActivityGroupItem = +type ThreadStreamActivityGroupItem = | { type: "item"; id: string; - item: MessageStreamItem; - classification: MessageStreamSemanticClassification; + item: ThreadStreamItem; + classification: ThreadStreamSemanticClassification; } | { type: "steering"; @@ -32,28 +32,28 @@ type MessageStreamActivityGroupItem = sourceItemId: string; }; -export type MessageStreamLayoutBlock = +export type ThreadStreamLayoutBlock = | { type: "item"; - item: MessageStreamItem; - classification: MessageStreamSemanticClassification; - annotations?: MessageStreamItemAnnotations; + item: ThreadStreamItem; + classification: ThreadStreamSemanticClassification; + annotations?: ThreadStreamItemAnnotations; } | { type: "activityGroup"; id: string; turnId: string; summary: string; - items: MessageStreamActivityGroupItem[]; + items: ThreadStreamActivityGroupItem[]; }; -export function messageStreamLayoutBlocks( - items: readonly MessageStreamItem[], +export function threadStreamLayoutBlocks( + items: readonly ThreadStreamItem[], activeTurnId: string | null, workspaceRoot?: string | null, turnDiffs?: ReadonlyMap, -): MessageStreamLayoutBlock[] { - const visibleItems = messageStreamSemanticClassifications(items).filter(shouldShowPresentationItem); +): ThreadStreamLayoutBlock[] { + const visibleItems = threadStreamSemanticClassifications(items).filter(shouldShowPresentationItem); const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot); const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems); const turnOutcomeIdByTurn = turnOutcomeItemsByTurn(visibleItems); @@ -65,7 +65,7 @@ export function messageStreamLayoutBlocks( const { item } = classification; const turnId = item.turnId; if (!turnId || !groupedTurnIds.has(turnId)) continue; - if (messageStreamIsTurnSteer(classification) && item.kind === "message") { + if (threadStreamIsTurnSteer(classification) && item.kind === "dialogue") { const group = groupedActivities.get(turnId) ?? []; group.push(steeringActivityGroupItem(classification)); groupedActivities.set(turnId, group); @@ -77,7 +77,7 @@ export function messageStreamLayoutBlocks( groupedActivities.set(turnId, group); } - const blocks: MessageStreamLayoutBlock[] = []; + const blocks: ThreadStreamLayoutBlock[] = []; for (const classification of visibleItems) { const { item } = classification; const turnId = item.turnId; @@ -108,22 +108,22 @@ export function messageStreamLayoutBlocks( return blocks; } -type GroupedActivity = MessageStreamActivityGroupItem; +type GroupedActivity = ThreadStreamActivityGroupItem; -function shouldShowPresentationItem(classification: MessageStreamSemanticClassification): boolean { +function shouldShowPresentationItem(classification: ThreadStreamSemanticClassification): boolean { return !isEmptyCompletedReasoningItem(classification.item); } -function isEmptyCompletedReasoningItem(item: MessageStreamItem): boolean { - return item.kind === "reasoning" && item.executionState === "completed" && textForMessageStreamItem(item).trim().length === 0; +function isEmptyCompletedReasoningItem(item: ThreadStreamItem): boolean { + return item.kind === "reasoning" && item.executionState === "completed" && textForThreadStreamItem(item).trim().length === 0; } -function steeringActivityGroupItem(classification: MessageStreamSemanticClassification): MessageStreamActivityGroupItem { +function steeringActivityGroupItem(classification: ThreadStreamSemanticClassification): ThreadStreamActivityGroupItem { return { type: "steering", id: steerActivityGroupId(classification.item.id), label: STEERING_ACTIVITY_LABEL, - text: textForMessageStreamItem(classification.item), + text: textForThreadStreamItem(classification.item), sourceItemId: classification.item.sourceItemId ?? classification.item.id, }; } @@ -136,13 +136,13 @@ function steerActivityGroupId(itemId: string): string { return `steer-activity-${itemId}`; } -function isCompletedTurnDetailItem(classification: MessageStreamSemanticClassification, turnOutcomeIdByTurn: Map): boolean { +function isCompletedTurnDetailItem(classification: ThreadStreamSemanticClassification, turnOutcomeIdByTurn: Map): boolean { const turnId = classification.item.turnId; - if (!turnId || messageStreamIsTurnInitiator(classification) || messageStreamIsTurnSteer(classification)) return false; + if (!turnId || threadStreamIsTurnInitiator(classification) || threadStreamIsTurnSteer(classification)) return false; return turnOutcomeIdByTurn.get(turnId) !== classification.item.id; } -function turnOutcomeItemsByTurn(items: readonly MessageStreamSemanticClassification[]): Map { +function turnOutcomeItemsByTurn(items: readonly ThreadStreamSemanticClassification[]): Map { const turnOutcomeIdByTurn = new Map(); for (const { item, capabilities } of items) { if (!item.turnId || !capabilities.isTurnOutcome) continue; @@ -152,14 +152,14 @@ function turnOutcomeItemsByTurn(items: readonly MessageStreamSemanticClassificat } function annotationsForTurnOutcome( - item: MessageStreamItem, + item: ThreadStreamItem, editedFilesByTurn: Map, autoReviewSummariesByTurn: Map, turnOutcomeIdByTurn: Map, turnDiffs?: ReadonlyMap, -): MessageStreamItemAnnotations | undefined { +): ThreadStreamItemAnnotations | undefined { if (!item.turnId || turnOutcomeIdByTurn.get(item.turnId) !== item.id) return undefined; - if (item.kind !== "message") return undefined; + if (item.kind !== "dialogue") return undefined; const editedFiles = editedFilesByTurn.get(item.turnId); const autoReviewSummaries = autoReviewSummariesByTurn.get(item.turnId); const diff = turnDiffs?.get(item.turnId); @@ -174,11 +174,11 @@ function annotationsForTurnOutcome( }; } -function editedFilesForTurns(items: readonly MessageStreamSemanticClassification[], workspaceRoot?: string | null): Map { +function editedFilesForTurns(items: readonly ThreadStreamSemanticClassification[], workspaceRoot?: string | null): Map { const byTurn = new Map>(); for (const classification of items) { const { item } = classification; - if (!item.turnId || !messageStreamIsWorkspaceResult(classification)) continue; + if (!item.turnId || !threadStreamIsWorkspaceResult(classification)) continue; const files = editedFilesForItem(item, workspaceRoot); if (files.length === 0) continue; const set = byTurn.get(item.turnId) ?? new Set(); @@ -189,21 +189,21 @@ function editedFilesForTurns(items: readonly MessageStreamSemanticClassification return new Map([...byTurn].map(([turnId, files]) => [turnId, [...files].sort((a, b) => a.localeCompare(b))])); } -function editedFilesForItem(item: MessageStreamItem, workspaceRoot?: string | null): string[] { +function editedFilesForItem(item: ThreadStreamItem, workspaceRoot?: string | null): string[] { if (item.kind !== "fileChange") return []; return item.changes.flatMap((change) => change.path && change.path !== "(unknown)" ? [pathRelativeToRoot(change.path, workspaceRoot)] : [], ); } -function autoReviewSummariesForTurns(items: readonly MessageStreamSemanticClassification[]): Map { +function autoReviewSummariesForTurns(items: readonly ThreadStreamSemanticClassification[]): Map { const byTurn = new Map(); for (const classification of items) { const { item } = classification; - if (!item.turnId || !messageStreamIsAutoReviewDecision(classification)) { + if (!item.turnId || !threadStreamIsAutoReviewDecision(classification)) { continue; } - const summary = textForMessageStreamItem(item).trim(); + const summary = textForThreadStreamItem(item).trim(); if (!summary) continue; const summaries = byTurn.get(item.turnId) ?? []; summaries.push(summary); @@ -212,7 +212,7 @@ function autoReviewSummariesForTurns(items: readonly MessageStreamSemanticClassi return byTurn; } -function textForMessageStreamItem(item: MessageStreamItem): string { +function textForThreadStreamItem(item: ThreadStreamItem): string { return "text" in item && typeof item.text === "string" ? item.text : ""; } diff --git a/src/features/chat/presentation/message-stream/status-view.ts b/src/features/chat/presentation/thread-stream/status-view.ts similarity index 75% rename from src/features/chat/presentation/message-stream/status-view.ts rename to src/features/chat/presentation/thread-stream/status-view.ts index a5243358..cf1606ee 100644 --- a/src/features/chat/presentation/message-stream/status-view.ts +++ b/src/features/chat/presentation/thread-stream/status-view.ts @@ -3,15 +3,15 @@ import type { AgentRunSummary, AgentRunSummaryAgent, ExecutionState, - MessageStreamItem, - ReasoningMessageStreamItem, - TaskProgressMessageStreamItem, -} from "../../domain/message-stream/items"; -import { messageStreamReasoningIsActive } from "../../domain/message-stream/semantics/active-turn"; + ReasoningThreadStreamItem, + TaskProgressThreadStreamItem, + ThreadStreamItem, +} from "../../domain/thread-stream/items"; +import { threadStreamReasoningIsActive } from "../../domain/thread-stream/semantics/active-turn"; -type StatusChecklistItem = TaskProgressMessageStreamItem["steps"][number]; +type StatusChecklistItem = TaskProgressThreadStreamItem["steps"][number]; -export type MessageStreamStatusView = +export type ThreadStreamStatusView = | { kind: "taskProgress"; label: "tasks"; @@ -41,10 +41,10 @@ export type MessageStreamStatusView = text: string; }; -export interface MessageStreamStatusViewContext { +export interface ThreadStreamStatusViewContext { activeTurnId: string | null; - items: readonly MessageStreamItem[]; - activeItems?: readonly MessageStreamItem[] | undefined; + items: readonly ThreadStreamItem[]; + activeItems?: readonly ThreadStreamItem[] | undefined; } export interface AgentRunSummaryView { @@ -56,7 +56,7 @@ export interface AgentRunSummaryView { additionalAgents: number; } -export function messageStreamStatusView(item: MessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView { +export function threadStreamStatusView(item: ThreadStreamItem, context: ThreadStreamStatusViewContext): ThreadStreamStatusView { if (item.kind === "taskProgress") { return { kind: "taskProgress", @@ -83,7 +83,7 @@ export function agentRunSummaryView(summary: AgentRunSummary): AgentRunSummaryVi }; } -function contextCompactionStatusView(item: MessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView { +function contextCompactionStatusView(item: ThreadStreamItem, context: ThreadStreamStatusViewContext): ThreadStreamStatusView { const active = context.activeTurnId === item.turnId; return { kind: "contextCompaction", @@ -94,8 +94,8 @@ function contextCompactionStatusView(item: MessageStreamItem, context: MessageSt }; } -function reasoningStatusView(item: ReasoningMessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView { - const active = messageStreamReasoningIsActive(item, context); +function reasoningStatusView(item: ReasoningThreadStreamItem, context: ThreadStreamStatusViewContext): ThreadStreamStatusView { + const active = threadStreamReasoningIsActive(item, context); return { kind: "reasoning", active, @@ -104,7 +104,7 @@ function reasoningStatusView(item: ReasoningMessageStreamItem, context: MessageS }; } -function genericStatusView(item: MessageStreamItem): MessageStreamStatusView { +function genericStatusView(item: ThreadStreamItem): ThreadStreamStatusView { return { kind: "generic", label: item.kind, @@ -136,7 +136,7 @@ function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; th }; } -function stringField(item: MessageStreamItem, key: "failureReason" | "operation" | "output" | "status" | "text"): string | null { +function stringField(item: ThreadStreamItem, key: "failureReason" | "operation" | "output" | "status" | "text"): string | null { if (!(key in item)) return null; const value = (item as unknown as Record)[key]; return typeof value === "string" && value.trim().length > 0 ? value : null; diff --git a/src/features/chat/presentation/message-stream/text-view.ts b/src/features/chat/presentation/thread-stream/text-view.ts similarity index 60% rename from src/features/chat/presentation/message-stream/text-view.ts rename to src/features/chat/presentation/thread-stream/text-view.ts index fda5551f..0f699c8a 100644 --- a/src/features/chat/presentation/message-stream/text-view.ts +++ b/src/features/chat/presentation/thread-stream/text-view.ts @@ -1,19 +1,19 @@ import type { ExecutionState, - MessageStreamItem, - MessageStreamNoticeSection, - MessageStreamUserInputQuestionResult, -} from "../../domain/message-stream/items"; -import type { PlanImplementationTarget } from "../../domain/message-stream/selectors"; -import type { MessageStreamItemAnnotations } from "./layout"; + ThreadStreamItem, + ThreadStreamNoticeSection, + ThreadStreamUserInputQuestionResult, +} from "../../domain/thread-stream/items"; +import type { PlanImplementationTarget } from "../../domain/thread-stream/selectors"; +import type { ThreadStreamItemAnnotations } from "./layout"; -export interface MessageStreamForkTarget { +export interface ThreadStreamForkTarget { itemId: string; turnId: string; } -export interface MessageStreamTextActionTargets { - fork?: MessageStreamForkTarget; +export interface ThreadStreamTextActionTargets { + fork?: ThreadStreamForkTarget; rollback?: true; implementPlan?: PlanImplementationTarget; } @@ -43,7 +43,7 @@ export interface TextItemDetailSectionView { body?: string; } -interface MessageStreamTextMetadataView { +interface ThreadStreamTextMetadataView { editedFiles?: EditedFilesTextView; referencedThread?: ReferencedThreadTextView; mentionedFiles?: { @@ -55,26 +55,26 @@ interface MessageStreamTextMetadataView { userInputDetails: readonly TextItemDetailSectionView[]; } -export interface MessageStreamTextView { +export interface ThreadStreamTextView { id: string; roleLabel: string; body: string; className: string; contentKey: string; - renderMode: MessageStreamTextRenderMode; + renderMode: ThreadStreamTextRenderMode; collapsible: boolean; copyText?: string; - actionTargets: MessageStreamTextActionTargets; - metadata: MessageStreamTextMetadataView; + actionTargets: ThreadStreamTextActionTargets; + metadata: ThreadStreamTextMetadataView; } -type MessageStreamTextRenderMode = "text" | "streamMarkdown" | "obsidianMarkdown"; +type ThreadStreamTextRenderMode = "text" | "streamMarkdown" | "obsidianMarkdown"; -export function messageStreamTextView( - item: MessageStreamItem, - annotations?: MessageStreamItemAnnotations, - options: { activeTurnId?: string | null; actionTargets?: MessageStreamTextActionTargets } = {}, -): MessageStreamTextView { +export function threadStreamTextView( + item: ThreadStreamItem, + annotations?: ThreadStreamItemAnnotations, + options: { activeTurnId?: string | null; actionTargets?: ThreadStreamTextActionTargets } = {}, +): ThreadStreamTextView { const renderMode = textRenderMode(item); const body = bodyForTextItem(item); return { @@ -84,66 +84,66 @@ export function messageStreamTextView( className: `${textItemClass(item)}${executionClassName(item.executionState ?? null)}`, contentKey: `${item.id}\u001f${renderMode}`, renderMode, - collapsible: item.kind === "message" && item.role === "user", + collapsible: item.kind === "dialogue" && item.role === "user", ...definedProp("copyText", copyTextForTextItem(item, options.activeTurnId ?? null)), actionTargets: options.actionTargets ?? {}, metadata: textMetadataView(item, annotations), }; } -function textRenderMode(item: MessageStreamItem): MessageStreamTextRenderMode { - if (item.kind !== "message") return "text"; - if (item.messageKind === "assistantResponse" && item.messageState === "streaming") return "streamMarkdown"; - return item.messageKind !== "proposedPlan" || item.messageState === "completed" ? "obsidianMarkdown" : "text"; +function textRenderMode(item: ThreadStreamItem): ThreadStreamTextRenderMode { + if (item.kind !== "dialogue") return "text"; + if (item.dialogueKind === "assistantResponse" && item.dialogueState === "streaming") return "streamMarkdown"; + return item.dialogueKind !== "proposedPlan" || item.dialogueState === "completed" ? "obsidianMarkdown" : "text"; } -function bodyForTextItem(item: MessageStreamItem): string { +function bodyForTextItem(item: ThreadStreamItem): string { return "text" in item && typeof item.text === "string" ? item.text : ""; } -function roleLabelForTextItem(item: MessageStreamItem): string { +function roleLabelForTextItem(item: ThreadStreamItem): string { if (item.kind === "userInputResult") return "Input"; if (item.role === "user") return "You"; if (item.role === "assistant") return "Codex"; return "System"; } -function copyTextForTextItem(item: MessageStreamItem, activeTurnId: string | null): string | undefined { - if (item.kind !== "message" || item.copyText === undefined) return undefined; +function copyTextForTextItem(item: ThreadStreamItem, activeTurnId: string | null): string | undefined { + if (item.kind !== "dialogue" || item.copyText === undefined) return undefined; if (activeTurnId && item.role === "assistant" && item.turnId === activeTurnId) return undefined; return item.copyText; } -function textMetadataView(item: MessageStreamItem, annotations?: MessageStreamItemAnnotations): MessageStreamTextMetadataView { +function textMetadataView(item: ThreadStreamItem, annotations?: ThreadStreamItemAnnotations): ThreadStreamTextMetadataView { return { ...definedProp("editedFiles", editedFilesView(item, annotations)), ...definedProp("referencedThread", referencedThreadView(item)), ...definedProp("mentionedFiles", mentionedFilesView(item)), - autoReviewSummaries: item.kind === "message" ? (annotations?.autoReviewSummaries ?? []) : [], + autoReviewSummaries: item.kind === "dialogue" ? (annotations?.autoReviewSummaries ?? []) : [], systemDetails: item.kind === "system" ? systemDetailViews(item.noticeSections ?? []) : [], userInputDetails: item.kind === "userInputResult" ? userInputQuestionDetailViews(item.questions) : [], }; } -function editedFilesView(item: MessageStreamItem, annotations?: MessageStreamItemAnnotations): EditedFilesTextView | undefined { - if (item.kind !== "message" || !annotations?.editedFiles || annotations.editedFiles.length === 0) return undefined; +function editedFilesView(item: ThreadStreamItem, annotations?: ThreadStreamItemAnnotations): EditedFilesTextView | undefined { + if (item.kind !== "dialogue" || !annotations?.editedFiles || annotations.editedFiles.length === 0) return undefined; return { files: annotations.editedFiles, ...definedProp("turnDiff", item.turnId && annotations.turnDiff ? { turnId: item.turnId, diff: annotations.turnDiff.diff } : undefined), }; } -function referencedThreadView(item: MessageStreamItem): ReferencedThreadTextView | undefined { - if (item.kind !== "message" || !item.referencedThread) return undefined; +function referencedThreadView(item: ThreadStreamItem): ReferencedThreadTextView | undefined { + if (item.kind !== "dialogue" || !item.referencedThread) return undefined; return item.referencedThread; } -function mentionedFilesView(item: MessageStreamItem): MessageStreamTextMetadataView["mentionedFiles"] | undefined { - if (item.kind !== "message" || !item.mentionedFiles || item.mentionedFiles.length === 0) return undefined; +function mentionedFilesView(item: ThreadStreamItem): ThreadStreamTextMetadataView["mentionedFiles"] | undefined { + if (item.kind !== "dialogue" || !item.mentionedFiles || item.mentionedFiles.length === 0) return undefined; return { itemId: item.id, files: item.mentionedFiles }; } -function systemDetailViews(sections: readonly MessageStreamNoticeSection[]): readonly TextItemDetailSectionView[] { +function systemDetailViews(sections: readonly ThreadStreamNoticeSection[]): readonly TextItemDetailSectionView[] { return sections.map((section) => ({ ...(section.title !== undefined ? { title: section.title } : {}), ...(section.auditFacts !== undefined ? { facts: section.auditFacts } : {}), @@ -151,7 +151,7 @@ function systemDetailViews(sections: readonly MessageStreamNoticeSection[]): rea })); } -function userInputQuestionDetailViews(questions: readonly MessageStreamUserInputQuestionResult[]): readonly TextItemDetailSectionView[] { +function userInputQuestionDetailViews(questions: readonly ThreadStreamUserInputQuestionResult[]): readonly TextItemDetailSectionView[] { return questions.map((question) => ({ title: `Question: ${question.header}`, facts: [ @@ -168,7 +168,7 @@ function executionClassName(state: ExecutionState): string { return ""; } -function textItemClass(item: MessageStreamItem): string { +function textItemClass(item: ThreadStreamItem): string { const classes = ["codex-panel__message", messageRoleClassName(item.role)]; if (item.kind === "approvalResult") classes.push("codex-panel__message--approval-result"); if (item.kind === "userInputResult") classes.push("codex-panel__message--user-input-result"); @@ -176,7 +176,7 @@ function textItemClass(item: MessageStreamItem): string { return classes.join(" "); } -function messageRoleClassName(role: MessageStreamItem["role"]): string { +function messageRoleClassName(role: ThreadStreamItem["role"]): string { if (role === "assistant") return "codex-panel__message--assistant"; if (role === "system") return "codex-panel__message--system"; if (role === "tool") return "codex-panel__message--tool"; diff --git a/src/features/chat/presentation/thread-stream/view-model.ts b/src/features/chat/presentation/thread-stream/view-model.ts new file mode 100644 index 00000000..6ed89f32 --- /dev/null +++ b/src/features/chat/presentation/thread-stream/view-model.ts @@ -0,0 +1,303 @@ +import type { AgentRunSummary, TaskProgressThreadStreamItem, ThreadStreamItem } from "../../domain/thread-stream/items"; +import { threadStreamItemsEmpty } from "../../domain/thread-stream/selectors"; +import { activeTurnLiveItems, threadStreamItemsWithoutActiveTaskProgress } from "../../domain/thread-stream/semantics/active-turn"; +import type { ThreadStreamSemanticClassification } from "../../domain/thread-stream/semantics/types"; +import type { PendingRequestBlockSnapshot } from "../pending-requests/view-model"; +import { type DetailView, detailView } from "./detail-view"; +import { type ThreadStreamItemAnnotations, type ThreadStreamLayoutBlock, threadStreamLayoutBlocks } from "./layout"; +import { type AgentRunSummaryView, agentRunSummaryView, type ThreadStreamStatusView, threadStreamStatusView } from "./status-view"; +import { type ThreadStreamTextActionTargets, type ThreadStreamTextView, threadStreamTextView } from "./text-view"; + +interface PendingRequestThreadStreamBlockInput { + signature: string; + snapshot: PendingRequestBlockSnapshot; +} + +export interface ThreadStreamPresentationBlockInput { + activeThreadId: string | null; + activeTurnId: string | null; + historyCursor: string | null; + loadingHistory: boolean; + items: readonly ThreadStreamItem[]; + stableItems?: readonly ThreadStreamItem[] | undefined; + activeItems?: readonly ThreadStreamItem[] | undefined; + workspaceRoot?: string | null | undefined; + turnDiffs?: ReadonlyMap | undefined; + textActionTargetsByItemId?: ReadonlyMap | undefined; + pendingRequests?: PendingRequestThreadStreamBlockInput | null | undefined; +} + +type ThreadStreamPresentationBlock = + | { + kind: "historyBar"; + key: "history-bar"; + loadingHistory: boolean; + } + | { + kind: "empty"; + key: "empty"; + } + | { + kind: "item"; + key: string; + block: Extract; + } + | { + kind: "activityGroup"; + key: string; + block: Extract; + } + | { + kind: "liveTask"; + key: string; + item: TaskProgressThreadStreamItem; + } + | { + kind: "liveAgentSummary"; + key: string; + summary: AgentRunSummary; + } + | { + kind: "pendingRequests"; + key: "pending-requests"; + signature: string; + snapshot: PendingRequestBlockSnapshot; + }; + +type ThreadStreamPresentationBlockSource = (input: ThreadStreamPresentationBlockInput) => readonly ThreadStreamPresentationBlock[]; +type ThreadStreamRenderFamily = "text" | "detail" | "status"; + +export type ThreadStreamRenderedItemView = + | { + kind: "text"; + view: ThreadStreamTextView; + } + | { + kind: "detail"; + view: DetailView; + } + | { + kind: "status"; + view: ThreadStreamStatusView; + }; + +export type ThreadStreamActivityItemView = + | ({ + type: "item"; + id: string; + } & ThreadStreamRenderedItemView) + | { + type: "steering"; + id: string; + label: string; + text: string; + sourceItemId: string; + }; + +export type ThreadStreamViewBlock = + | { + kind: "historyBar"; + key: "history-bar"; + loadingHistory: boolean; + } + | { + kind: "empty"; + key: "empty"; + } + | ({ + key: string; + } & ThreadStreamRenderedItemView) + | { + kind: "activityGroup"; + key: string; + id: string; + turnId: string; + summary: string; + items: ThreadStreamActivityItemView[]; + } + | { + kind: "liveAgentSummary"; + key: string; + view: AgentRunSummaryView; + } + | { + kind: "pendingRequests"; + key: "pending-requests"; + signature: string; + snapshot: PendingRequestBlockSnapshot; + }; + +export function threadStreamViewBlocks(input: ThreadStreamPresentationBlockInput): ThreadStreamViewBlock[] { + return threadStreamPresentationBlocks(input).map((block) => threadStreamViewBlockFromPresentationBlock(block, input)); +} + +function threadStreamPresentationBlocks(input: ThreadStreamPresentationBlockInput): ThreadStreamPresentationBlock[] { + const headerBlocks = historyPresentationBlocks(input); + if (threadStreamItemsEmpty(input)) { + return [...headerBlocks, { kind: "empty", key: "empty" }]; + } + + return [ + ...headerBlocks, + ...collectPresentationBlocks(input, [layoutPresentationBlocks, activeTurnPresentationBlocks, pendingRequestPresentationBlocks]), + ]; +} + +function collectPresentationBlocks( + input: ThreadStreamPresentationBlockInput, + sources: readonly ThreadStreamPresentationBlockSource[], +): ThreadStreamPresentationBlock[] { + return sources.flatMap((source) => source(input)); +} + +function historyPresentationBlocks(input: ThreadStreamPresentationBlockInput): readonly ThreadStreamPresentationBlock[] { + if (!input.activeThreadId || !input.historyCursor) return []; + return [{ kind: "historyBar", key: "history-bar", loadingHistory: input.loadingHistory }]; +} + +function layoutPresentationBlocks(input: ThreadStreamPresentationBlockInput): readonly ThreadStreamPresentationBlock[] { + return layoutBlocksForInput(input).map(presentationBlockFromLayoutBlock); +} + +function presentationBlockFromLayoutBlock(block: ThreadStreamLayoutBlock): ThreadStreamPresentationBlock { + if (block.type === "item") return { kind: "item", key: `item:${block.item.id}`, block }; + return { kind: "activityGroup", key: `activity:${block.id}`, block }; +} + +function activeTurnPresentationBlocks(input: ThreadStreamPresentationBlockInput): readonly ThreadStreamPresentationBlock[] { + if (!input.activeTurnId) return []; + return activeTurnLiveBlocks(input, input.activeTurnId); +} + +function pendingRequestPresentationBlocks(input: ThreadStreamPresentationBlockInput): readonly ThreadStreamPresentationBlock[] { + if (!input.pendingRequests?.signature) return []; + return [ + { + kind: "pendingRequests", + key: "pending-requests", + signature: input.pendingRequests.signature, + snapshot: input.pendingRequests.snapshot, + }, + ]; +} + +function layoutBlocksForInput(input: ThreadStreamPresentationBlockInput): ThreadStreamLayoutBlock[] { + const { activeTurnId } = input; + if (!activeTurnId || !input.stableItems || !input.activeItems) { + const streamItems = activeTurnId ? threadStreamItemsWithoutActiveTaskProgress(input.items, activeTurnId) : input.items; + return threadStreamLayoutBlocks(streamItems, activeTurnId, input.workspaceRoot, input.turnDiffs); + } + const stableBlocks = threadStreamLayoutBlocks(input.stableItems, activeTurnId, input.workspaceRoot, input.turnDiffs); + const activeBlocks = threadStreamLayoutBlocks( + threadStreamItemsWithoutActiveTaskProgress(input.activeItems, activeTurnId), + activeTurnId, + input.workspaceRoot, + input.turnDiffs, + ); + return [...stableBlocks, ...activeBlocks]; +} + +function activeTurnLiveBlocks( + input: Pick, + activeTurnId: string, +): ThreadStreamPresentationBlock[] { + return activeTurnLiveItems(input, activeTurnId).map((item): ThreadStreamPresentationBlock => { + if (item.kind === "taskProgress") { + return { + kind: "liveTask", + key: `live-task:${item.item.id}`, + item: item.item, + }; + } + return { kind: "liveAgentSummary", key: `live-agents:${activeTurnId}`, summary: item.summary }; + }); +} + +function threadStreamViewBlockFromPresentationBlock( + block: ThreadStreamPresentationBlock, + input: ThreadStreamPresentationBlockInput, +): ThreadStreamViewBlock { + if (block.kind === "historyBar" || block.kind === "empty") return block; + if (block.kind === "pendingRequests") return block; + if (block.kind === "liveAgentSummary") return { kind: "liveAgentSummary", key: block.key, view: agentRunSummaryView(block.summary) }; + if (block.kind === "liveTask") { + return { kind: "status", key: block.key, view: threadStreamStatusView(block.item, statusViewContext(input)) }; + } + if (block.kind === "activityGroup") { + return { + kind: "activityGroup", + key: block.key, + id: block.block.id, + turnId: block.block.turnId, + summary: block.block.summary, + items: block.block.items.map((activity) => threadStreamActivityItemView(activity, input)), + }; + } + return { key: block.key, ...threadStreamRenderedItemView(block.block.classification, input, block.block.annotations) }; +} + +function threadStreamActivityItemView( + activity: Extract["items"][number], + input: ThreadStreamPresentationBlockInput, +): ThreadStreamActivityItemView { + if (activity.type === "steering") return activity; + return { type: "item", id: activity.id, ...threadStreamRenderedItemView(activity.classification, input) }; +} + +function threadStreamRenderedItemView( + classification: ThreadStreamSemanticClassification, + input: ThreadStreamPresentationBlockInput, + annotations?: ThreadStreamItemAnnotations, +): ThreadStreamRenderedItemView { + const renderFamily = threadStreamRenderFamily(classification); + switch (renderFamily) { + case "text": + return { + kind: "text", + view: threadStreamTextView(classification.item, annotations, { + activeTurnId: input.activeTurnId, + ...definedProp("actionTargets", input.textActionTargetsByItemId?.get(classification.item.id)), + }), + }; + case "detail": + return { kind: "detail", view: detailView(classification.item, input.workspaceRoot) }; + case "status": + return { kind: "status", view: threadStreamStatusView(classification.item, statusViewContext(input)) }; + } +} + +function threadStreamRenderFamily(classification: ThreadStreamSemanticClassification): ThreadStreamRenderFamily { + switch (classification.item.kind) { + case "dialogue": + case "system": + case "userInputResult": + return "text"; + case "command": + case "fileChange": + case "tool": + case "hook": + case "goal": + case "approvalResult": + case "reviewResult": + case "agent": + return "detail"; + case "taskProgress": + case "reasoning": + case "wait": + case "contextCompaction": + return "status"; + } + return "status"; +} + +function statusViewContext(input: ThreadStreamPresentationBlockInput): Parameters[1] { + return { + activeTurnId: input.activeTurnId, + items: input.items, + activeItems: input.activeItems, + }; +} + +function definedProp(key: Key, value: Value | undefined): Partial> { + return value === undefined ? {} : ({ [key]: value } as Partial>); +} diff --git a/src/features/chat/ui/message-stream/agent-thread-action.tsx b/src/features/chat/ui/thread-stream/agent-thread-action.tsx similarity index 100% rename from src/features/chat/ui/message-stream/agent-thread-action.tsx rename to src/features/chat/ui/thread-stream/agent-thread-action.tsx diff --git a/src/features/chat/ui/message-stream/content-rendered-event.dom.ts b/src/features/chat/ui/thread-stream/content-rendered-event.dom.ts similarity index 100% rename from src/features/chat/ui/message-stream/content-rendered-event.dom.ts rename to src/features/chat/ui/thread-stream/content-rendered-event.dom.ts diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/thread-stream/context.ts similarity index 76% rename from src/features/chat/ui/message-stream/context.ts rename to src/features/chat/ui/thread-stream/context.ts index acd3bd4a..8858f2b4 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/thread-stream/context.ts @@ -1,12 +1,12 @@ import type { ApprovalAction, McpElicitationAction, PendingRequestId } from "../../../../domain/pending-requests/model"; import type { TurnDiffViewState } from "../../../turn-diff/model"; -import type { PlanImplementationTarget } from "../../domain/message-stream/selectors"; -import type { MessageStreamForkTarget } from "../../presentation/message-stream/text-view"; +import type { PlanImplementationTarget } from "../../domain/thread-stream/selectors"; import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/view-model"; +import type { ThreadStreamForkTarget } from "../../presentation/thread-stream/text-view"; -export type MessageStreamDisclosureBucket = "details" | "activityGroups" | "textDetails" | "userMessageExpanded" | "approvalDetails"; +export type ThreadStreamDisclosureBucket = "details" | "activityGroups" | "textDetails" | "userMessageExpanded" | "approvalDetails"; -export interface MessageStreamDisclosureState { +export interface ThreadStreamDisclosureState { details: ReadonlySet; activityGroups: ReadonlySet; textDetails: ReadonlySet; @@ -25,8 +25,8 @@ export interface PendingRequestBlockActions { } export interface TextItemDetailStateContext { - disclosures: MessageStreamDisclosureState; - onDisclosureToggle?: (bucket: MessageStreamDisclosureBucket, id: string, open: boolean) => void; + disclosures: ThreadStreamDisclosureState; + onDisclosureToggle?: (bucket: ThreadStreamDisclosureBucket, id: string, open: boolean) => void; } export interface TextItemContentContext extends TextItemDetailStateContext { @@ -40,7 +40,7 @@ export interface TextItemActionContext extends TextItemDetailStateContext { copyText?: (text: string) => void; onImplementPlan?: (target: PlanImplementationTarget) => void; onRollback?: () => void; - onFork?: (target: MessageStreamForkTarget, archiveSource: boolean) => void; + onFork?: (target: ThreadStreamForkTarget, archiveSource: boolean) => void; } export interface TextItemMetadataContext extends TextItemDetailStateContext { @@ -49,7 +49,7 @@ export interface TextItemMetadataContext extends TextItemDetailStateContext { openTurnDiff?: (state: TurnDiffViewState) => void; } -interface MessageStreamRenderContext { +interface ThreadStreamRenderContext { activeThreadId: string | null; workspaceRoot?: string | null; loadOlderTurns: () => void; @@ -59,7 +59,7 @@ interface MessageStreamRenderContext { export interface TextItemContext extends TextItemContentContext, TextItemActionContext, TextItemMetadataContext {} -export interface MessageStreamContext extends MessageStreamRenderContext, TextItemContext {} +export interface ThreadStreamContext extends ThreadStreamRenderContext, TextItemContext {} export interface PendingRequestBlockContext { signature: string; diff --git a/src/features/chat/ui/message-stream/detail.tsx b/src/features/chat/ui/thread-stream/detail.tsx similarity index 97% rename from src/features/chat/ui/message-stream/detail.tsx rename to src/features/chat/ui/thread-stream/detail.tsx index d6417bb5..811a485c 100644 --- a/src/features/chat/ui/message-stream/detail.tsx +++ b/src/features/chat/ui/thread-stream/detail.tsx @@ -1,12 +1,12 @@ import type { ComponentChild as UiNode } from "preact"; import { RawDiffView } from "../../../../shared/ui/diff-view"; -import type { DetailSection, DetailView } from "../../presentation/message-stream/detail-view"; +import type { DetailSection, DetailView } from "../../presentation/thread-stream/detail-view"; import { OpenAgentThreadAction } from "./agent-thread-action"; -import type { MessageStreamDisclosureState } from "./context"; +import type { ThreadStreamDisclosureState } from "./context"; export interface DetailRenderContext { - disclosures: MessageStreamDisclosureState; + disclosures: ThreadStreamDisclosureState; onDisclosureToggle?: (bucket: "details", id: string, open: boolean) => void; openThreadInNewView?: (threadId: string) => void; } diff --git a/src/features/chat/ui/message-stream/flow-scroll.measure.ts b/src/features/chat/ui/thread-stream/flow-scroll.measure.ts similarity index 91% rename from src/features/chat/ui/message-stream/flow-scroll.measure.ts rename to src/features/chat/ui/thread-stream/flow-scroll.measure.ts index ea8f8715..04eec2ed 100644 --- a/src/features/chat/ui/message-stream/flow-scroll.measure.ts +++ b/src/features/chat/ui/thread-stream/flow-scroll.measure.ts @@ -8,27 +8,27 @@ type MessageScrollDirection = -1 | 1; const MESSAGE_FLOW_TEXT_LINE_SCROLL_LINES = 4; const MESSAGE_FLOW_REPEATED_TEXT_LINE_SCROLL_LINES = 4; -export type MessageStreamScrollCommand = +export type ThreadStreamScrollCommand = | { kind: "show-latest" } | { kind: "scroll-to"; edge: "start" | "end" } | { kind: "scroll-by"; amount: "text-lines" | "page"; direction: MessageScrollDirection; repeated?: boolean }; -export interface MessageStreamScrollPort { - dispatchScrollCommand(command: MessageStreamScrollCommand): void; +export interface ThreadStreamScrollPort { + dispatchScrollCommand(command: ThreadStreamScrollCommand): void; } -export interface MessageStreamScrollPortBinding { - mountScrollPort(port: MessageStreamScrollPort): () => void; +export interface ThreadStreamScrollPortBinding { + mountScrollPort(port: ThreadStreamScrollPort): () => void; } -export interface MessageStreamFlowBlockIdentity { +export interface ThreadStreamFlowBlockIdentity { key: string; } -export interface MessageStreamFlowFrameProps { +export interface ThreadStreamFlowFrameProps { blocks: readonly Block[]; rootAttributes?: Partial>; - scrollPortBinding: MessageStreamScrollPortBinding; + scrollPortBinding: ThreadStreamScrollPortBinding; renderBlockContent: (block: Block) => UiNode; } @@ -46,9 +46,9 @@ interface MessageFlowRuntime { resizeObserver: ResizeObserver | null; } -export class MessageStreamFlowFrame extends Component> { +export class ThreadStreamFlowFrame extends Component> { private readonly runtime = createMessageFlowRuntime(); - private readonly scrollPort: MessageStreamScrollPort = { + private readonly scrollPort: ThreadStreamScrollPort = { dispatchScrollCommand: (command) => { applyMessageFlowScrollCommand(this.runtime, command); }, @@ -62,12 +62,12 @@ export class MessageStreamFlowFrame>): MessageFlowSnapshot | null { + override getSnapshotBeforeUpdate(previousProps: Readonly>): MessageFlowSnapshot | null { return captureMessageFlowSnapshot(this.runtime, this.scrollElement, previousProps.blocks, this.props.blocks); } override componentDidUpdate( - previousProps: Readonly>, + previousProps: Readonly>, _previousState: Readonly>, snapshot: MessageFlowSnapshot | null, ): void { @@ -82,7 +82,7 @@ export class MessageStreamFlowFrame): UiNode { + override render({ blocks, renderBlockContent, rootAttributes }: ThreadStreamFlowFrameProps): UiNode { return h( "div", { @@ -135,8 +135,8 @@ function createMessageFlowRuntime(): MessageFlowRuntime { function captureMessageFlowSnapshot( runtime: MessageFlowRuntime, container: HTMLElement | null, - previousBlocks: readonly MessageStreamFlowBlockIdentity[], - nextBlocks: readonly MessageStreamFlowBlockIdentity[], + previousBlocks: readonly ThreadStreamFlowBlockIdentity[], + nextBlocks: readonly ThreadStreamFlowBlockIdentity[], ): MessageFlowSnapshot | null { if (!container || runtime.followingEnd || !messageFlowBlocksShifted(previousBlocks, nextBlocks)) return null; return captureMessageFlowReadingAnchor(container); @@ -206,7 +206,7 @@ function disposeMessageFlowRuntime(runtime: MessageFlowRuntime): void { runtime.followingEnd = false; } -function applyMessageFlowScrollCommand(runtime: MessageFlowRuntime, command: MessageStreamScrollCommand): void { +function applyMessageFlowScrollCommand(runtime: MessageFlowRuntime, command: ThreadStreamScrollCommand): void { switch (command.kind) { case "show-latest": runtime.followingEnd = true; @@ -312,8 +312,8 @@ function cancelMessageFlowEndRestore(runtime: MessageFlowRuntime): void { } function messageFlowBlocksShifted( - previous: readonly MessageStreamFlowBlockIdentity[], - next: readonly MessageStreamFlowBlockIdentity[], + previous: readonly ThreadStreamFlowBlockIdentity[], + next: readonly ThreadStreamFlowBlockIdentity[], ): boolean { if (previous.length === 0 || next.length === 0) return false; if (previous.length !== next.length) return true; diff --git a/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts b/src/features/chat/ui/thread-stream/markdown-renderer.obsidian.ts similarity index 100% rename from src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts rename to src/features/chat/ui/thread-stream/markdown-renderer.obsidian.ts diff --git a/src/features/chat/ui/message-stream/pending-request-block.dom.ts b/src/features/chat/ui/thread-stream/pending-request-block.dom.ts similarity index 100% rename from src/features/chat/ui/message-stream/pending-request-block.dom.ts rename to src/features/chat/ui/thread-stream/pending-request-block.dom.ts diff --git a/src/features/chat/ui/message-stream/pending-request-block.tsx b/src/features/chat/ui/thread-stream/pending-request-block.tsx similarity index 100% rename from src/features/chat/ui/message-stream/pending-request-block.tsx rename to src/features/chat/ui/thread-stream/pending-request-block.tsx diff --git a/src/features/chat/ui/message-stream/status.tsx b/src/features/chat/ui/thread-stream/status.tsx similarity index 87% rename from src/features/chat/ui/message-stream/status.tsx rename to src/features/chat/ui/thread-stream/status.tsx index 32b65c66..e13863b2 100644 --- a/src/features/chat/ui/message-stream/status.tsx +++ b/src/features/chat/ui/thread-stream/status.tsx @@ -1,14 +1,14 @@ import type { ComponentChild as UiNode } from "preact"; -import type { ExecutionState } from "../../domain/message-stream/items"; -import type { AgentRunSummaryView, MessageStreamStatusView } from "../../presentation/message-stream/status-view"; -import type { MessageStreamContext } from "./context"; +import type { ExecutionState } from "../../domain/thread-stream/items"; +import type { AgentRunSummaryView, ThreadStreamStatusView } from "../../presentation/thread-stream/status-view"; +import type { ThreadStreamContext } from "./context"; -export function agentRunSummaryNode(view: AgentRunSummaryView, context: Pick): UiNode { +export function agentRunSummaryNode(view: AgentRunSummaryView, context: Pick): UiNode { return ; } -export function statusNode(view: MessageStreamStatusView): UiNode { +export function statusNode(view: ThreadStreamStatusView): UiNode { if (view.kind === "taskProgress") return ; if (view.kind === "contextCompaction") return ; if (view.kind === "reasoning") return ; @@ -42,7 +42,7 @@ function AgentRunSummary({ ); } -function TaskProgress({ view }: { view: Extract }): UiNode { +function TaskProgress({ view }: { view: Extract }): UiNode { return ( {view.summary ?
{view.summary}
: null} @@ -73,7 +73,7 @@ function TaskProgress({ view }: { view: Extract }): UiNode { +function ContextCompaction({ view }: { view: Extract }): UiNode { return (
{view.text}
@@ -81,7 +81,7 @@ function ContextCompaction({ view }: { view: Extract }): UiNode { +function GenericStatus({ view }: { view: Extract }): UiNode { return (
{view.text}
@@ -89,7 +89,7 @@ function GenericStatus({ view }: { view: Extract }): UiNode { +function Reasoning({ view }: { view: Extract }): UiNode { return (
{view.label}
diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/thread-stream/stream-blocks.tsx similarity index 70% rename from src/features/chat/ui/message-stream/stream-blocks.tsx rename to src/features/chat/ui/thread-stream/stream-blocks.tsx index 515e7996..fb1c5aec 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/thread-stream/stream-blocks.tsx @@ -1,51 +1,51 @@ import { Fragment, type ComponentChild as UiNode } from "preact"; import type { - MessageStreamActivityItemView, - MessageStreamRenderedItemView, - MessageStreamViewBlock, -} from "../../presentation/message-stream/view-model"; -import type { MessageStreamContext, PendingRequestBlockContext } from "./context"; + ThreadStreamActivityItemView, + ThreadStreamRenderedItemView, + ThreadStreamViewBlock, +} from "../../presentation/thread-stream/view-model"; +import type { PendingRequestBlockContext, ThreadStreamContext } from "./context"; import { detailNode } from "./detail"; -import { MessageStreamFlowFrame, type MessageStreamScrollPortBinding } from "./flow-scroll.measure"; +import { ThreadStreamFlowFrame, type ThreadStreamScrollPortBinding } from "./flow-scroll.measure"; import { pendingRequestBlockNode } from "./pending-request-block"; import { agentRunSummaryNode, statusNode } from "./status"; import { textNode } from "./text"; -export interface MessageStreamViewportState { - blocks: readonly MessageStreamViewBlock[]; - context: MessageStreamContext; - scrollPortBinding: MessageStreamScrollPortBinding; +export interface ThreadStreamViewportState { + blocks: readonly ThreadStreamViewBlock[]; + context: ThreadStreamContext; + scrollPortBinding: ThreadStreamScrollPortBinding; } -interface MessageStreamViewportProps { - state: MessageStreamViewportState; +interface ThreadStreamViewportProps { + state: ThreadStreamViewportState; rootAttributes?: Partial>; } -export function MessageStreamViewport({ state, rootAttributes }: MessageStreamViewportProps): UiNode { +export function ThreadStreamViewport({ state, rootAttributes }: ThreadStreamViewportProps): UiNode { const { blocks, context, scrollPortBinding } = state; return ( - } + renderBlockContent={(block) => } {...(rootAttributes ? { rootAttributes } : {})} /> ); } -function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStreamContext): UiNode { +function streamItemNode(item: ThreadStreamRenderedItemView, context: ThreadStreamContext): UiNode { if (item.kind === "text") return textNode(item.view, context); if (item.kind === "detail") return detailNode(item.view, context); return statusNode(item.view); } -function MessageStreamBlockContent({ block, context }: { block: MessageStreamViewBlock; context: MessageStreamContext }): UiNode { +function ThreadStreamBlockContent({ block, context }: { block: ThreadStreamViewBlock; context: ThreadStreamContext }): UiNode { return presentationBlockNode(block, context); } -function presentationBlockNode(block: MessageStreamViewBlock, context: MessageStreamContext): UiNode { +function presentationBlockNode(block: ThreadStreamViewBlock, context: ThreadStreamContext): UiNode { if (block.kind === "historyBar") { return ; } @@ -76,7 +76,7 @@ function presentationBlockNode(block: MessageStreamViewBlock, context: MessageSt return streamItemNode(block, context); } -function pendingRequestContext(context: MessageStreamContext): PendingRequestBlockContext { +function pendingRequestContext(context: ThreadStreamContext): PendingRequestBlockContext { if (!context.pendingRequests) throw new Error("Expected pending request context for pending request block."); return context.pendingRequests; } @@ -99,8 +99,8 @@ function ActivityGroup({ group, context, }: { - group: Extract; - context: MessageStreamContext; + group: Extract; + context: ThreadStreamContext; }): UiNode { const open = context.disclosures.activityGroups.has(group.turnId); @@ -122,7 +122,7 @@ function ActivityGroup({ ); } -function SteeringActivity({ activity }: { activity: Extract }): UiNode { +function SteeringActivity({ activity }: { activity: Extract }): UiNode { return (
diff --git a/src/features/chat/ui/message-stream/text-content.dom.tsx b/src/features/chat/ui/thread-stream/text-content.dom.tsx similarity index 96% rename from src/features/chat/ui/message-stream/text-content.dom.tsx rename to src/features/chat/ui/thread-stream/text-content.dom.tsx index 80b7a6e9..e64f73a8 100644 --- a/src/features/chat/ui/message-stream/text-content.dom.tsx +++ b/src/features/chat/ui/thread-stream/text-content.dom.tsx @@ -2,13 +2,13 @@ import type { Ref, ComponentChild as UiNode } from "preact"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { listenDomEvent, listenOutsideDomEvent } from "../../../../shared/dom/events.dom"; -import type { MessageStreamTextView } from "../../presentation/message-stream/text-view"; +import type { ThreadStreamTextView } from "../../presentation/thread-stream/text-view"; import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-rendered-event.dom"; import type { TextItemContentContext } from "./context"; const USER_MESSAGE_COLLAPSE_HEIGHT_PX = 360; -export function CollapsibleTextContent({ view, context }: { view: MessageStreamTextView; context: TextItemContentContext }): UiNode { +export function CollapsibleTextContent({ view, context }: { view: ThreadStreamTextView; context: TextItemContentContext }): UiNode { const collapseRef = useRef(null); const contentRef = useRef(null); const [overflows, setOverflows] = useState(false); @@ -68,7 +68,7 @@ export function CollapsibleTextContent({ view, context }: { view: MessageStreamT } interface TextContentProps { - view: MessageStreamTextView; + view: ThreadStreamTextView; context: TextItemContentContext; contentRef?: Ref | undefined; collapsed?: boolean; diff --git a/src/features/chat/ui/message-stream/text.dom.ts b/src/features/chat/ui/thread-stream/text.dom.ts similarity index 100% rename from src/features/chat/ui/message-stream/text.dom.ts rename to src/features/chat/ui/thread-stream/text.dom.ts diff --git a/src/features/chat/ui/message-stream/text.tsx b/src/features/chat/ui/thread-stream/text.tsx similarity index 96% rename from src/features/chat/ui/message-stream/text.tsx rename to src/features/chat/ui/thread-stream/text.tsx index 786fccbf..56cfee7b 100644 --- a/src/features/chat/ui/message-stream/text.tsx +++ b/src/features/chat/ui/thread-stream/text.tsx @@ -4,19 +4,19 @@ import { IconButton } from "../../../../shared/obsidian/components.obsidian"; import type { EditedFilesTextView, MentionedFileTextView, - MessageStreamTextView, ReferencedThreadTextView, TextItemDetailSectionView, -} from "../../presentation/message-stream/text-view"; + ThreadStreamTextView, +} from "../../presentation/thread-stream/text-view"; import type { TextItemActionContext, TextItemContext, TextItemDetailStateContext, TextItemMetadataContext } from "./context"; import { closeMessageRoleMenuOnOutsidePointer } from "./text.dom"; import { CollapsibleTextContent, TextContent } from "./text-content.dom"; -export function textNode(view: MessageStreamTextView, context: TextItemContext): UiNode { +export function textNode(view: ThreadStreamTextView, context: TextItemContext): UiNode { return ; } -function Text({ view, context }: { view: MessageStreamTextView; context: TextItemContext }): UiNode { +function Text({ view, context }: { view: ThreadStreamTextView; context: TextItemContext }): UiNode { return (
@@ -39,7 +39,7 @@ function Text({ view, context }: { view: MessageStreamTextView; context: TextIte ); } -function TextHeader({ view, context }: { view: MessageStreamTextView; context: TextItemActionContext }): UiNode { +function TextHeader({ view, context }: { view: ThreadStreamTextView; context: TextItemActionContext }): UiNode { const forkMenuOpen = context.forkMenuItemId === view.id; const roleRef = useRef(null); const { fork, implementPlan, rollback } = view.actionTargets; diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index c95cf987..8746dc41 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -55,7 +55,7 @@ interface ToolbarPrimaryActions { interface ToolbarChatActions { startNewThread: () => void; - compactConversation: () => void; + compactContext: () => void; setGoal: () => void; } @@ -169,7 +169,7 @@ function ChatActionsPanel({ model, actions }: { model: ToolbarViewModel; actions className="codex-panel__chat-actions-panel-item" disabled={model.newChatDisabled} /> - +
); diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index b4a9aae1..2abbaa72 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -8,14 +8,14 @@ import { type ChatInboundHandlerActions, createChatInboundHandler, } from "../../../../../src/features/chat/app-server/inbound/handler"; -import { pendingTurnStart } from "../../../../../src/features/chat/application/conversation/turn-state"; import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source"; import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; import type { ChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/message-stream/updates"; +import { pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state"; +import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/thread-stream/updates"; import type { ThreadCatalogEvent } from "../../../../../src/features/threads/catalog/thread-catalog"; -import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream"; import { chatStateFixture, chatStateWith } from "../../support/state"; +import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; type ThreadStartedNotification = Extract; @@ -88,12 +88,12 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toMatchObject([{ id: "a1", text: "hello" }]); + expect(chatStateThreadStreamItems(handler.currentState())).toMatchObject([{ id: "a1", text: "hello" }]); }); it("marks active reasoning completed when assistant text starts", () => { let state = activeRunningState(); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }, ]); const handler = handlerForState(state); @@ -103,10 +103,10 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "answer" }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual( + expect(chatStateThreadStreamItems(handler.currentState())).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "r1", kind: "reasoning", status: "completed", executionState: "completed" }), - expect.objectContaining({ id: "a1", kind: "message", text: "answer" }), + expect.objectContaining({ id: "a1", kind: "dialogue", text: "answer" }), ]), ); }); @@ -120,8 +120,8 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-active", itemId: "p1", delta: "\n# Plan" }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toMatchObject([ - { id: "p1", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "# Plan", messageState: "streaming" }, + expect(chatStateThreadStreamItems(handler.currentState())).toMatchObject([ + { id: "p1", kind: "dialogue", dialogueKind: "proposedPlan", role: "assistant", text: "# Plan", dialogueState: "streaming" }, ]); }); @@ -151,14 +151,14 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([ + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([ expect.objectContaining({ id: "p1", - kind: "message", + kind: "dialogue", role: "assistant", text: "# Plan", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", }), ]); }); @@ -177,7 +177,7 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toMatchObject([ + expect(chatStateThreadStreamItems(handler.currentState())).toMatchObject([ { id: "plan-progress-turn-active", kind: "taskProgress", @@ -215,7 +215,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-active", diff: "@@\n-old\n+second" }, } satisfies Extract); - expect(handler.currentState().messageStream.turnDiffs.get("turn-active")).toBe("@@\n-old\n+second"); + expect(handler.currentState().threadStream.turnDiffs.get("turn-active")).toBe("@@\n-old\n+second"); }); it("ignores aggregated turn diffs outside the active scope", () => { @@ -231,7 +231,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-other", diff: "@@\n-wrong\n+wrong" }, } satisfies Extract); - expect(handler.currentState().messageStream.turnDiffs.size).toBe(0); + expect(handler.currentState().threadStream.turnDiffs.size).toBe(0); }); it("formats hook runs as compact summaries with details", () => { @@ -262,7 +262,7 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toMatchObject([ + expect(chatStateThreadStreamItems(handler.currentState())).toMatchObject([ { id: "hook-hook-1-1", kind: "hook", @@ -310,7 +310,7 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ kind: "hook", hookRun: { eventName: "postToolUse", @@ -347,7 +347,7 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active", @@ -382,11 +382,11 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook" }); - expect(chatStateMessageStreamItems(handler.currentState())[0]?.turnId).toBeUndefined(); + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ id: "hook-hook-1-1", kind: "hook" }); + expect(chatStateThreadStreamItems(handler.currentState())[0]?.turnId).toBeUndefined(); }); - it("keeps repeated hook runs with the same run id as separate message stream items", () => { + it("keeps repeated hook runs with the same run id as separate thread stream items", () => { const state = activeRunningState(); const handler = handlerForState(state); const baseRun: Extract["params"]["run"] = { @@ -415,7 +415,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-active", run: { ...baseRun, startedAt: 3n } }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["hook-hook-1-1", "hook-hook-1-3"]); + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["hook-hook-1-1", "hook-hook-1-3"]); }); it("attaches pre-turn prompt submit hook runs when the turn starts", () => { @@ -429,8 +429,8 @@ describe("ChatInboundHandler", () => { }, }, }); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }, { id: "hook-hook-1-1", kind: "hook", @@ -461,8 +461,8 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); - expect(chatStateMessageStreamItems(handler.currentState())[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" }); + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(chatStateThreadStreamItems(handler.currentState())[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" }); expect(pendingTurnStart(handler.currentState())).toBeNull(); expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-touched", @@ -491,7 +491,7 @@ describe("ChatInboundHandler", () => { toolName: "hook", status: "completed", }, - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }, + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }, ], "turn-active", ["hook-hook-1-1"], @@ -535,8 +535,8 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(expectPresent(chatStateMessageStreamItems(handler.currentState())[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" }); - expect(expectPresent(chatStateMessageStreamItems(handler.currentState())[0]).turnId).toBeUndefined(); + expect(expectPresent(chatStateThreadStreamItems(handler.currentState())[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" }); + expect(expectPresent(chatStateThreadStreamItems(handler.currentState())[0]).turnId).toBeUndefined(); expect(expectPresent(pendingTurnStart(handler.currentState())).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); }); @@ -546,8 +546,8 @@ describe("ChatInboundHandler", () => { state = chatStateWith(state, { turn: { lifecycle: { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } } }, }); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }, ]); const handler = handlerForState(state); @@ -556,8 +556,8 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: null, run: promptSubmitHookRun("hook-1", 1n) }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); - expect(expectPresent(chatStateMessageStreamItems(handler.currentState())[1]).turnId).toBeUndefined(); + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(expectPresent(chatStateThreadStreamItems(handler.currentState())[1]).turnId).toBeUndefined(); expect(expectPresent(pendingTurnStart(handler.currentState())).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); handler.handleNotification({ @@ -577,9 +577,9 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); - expect(chatStateMessageStreamItems(handler.currentState()).find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId"); - expect(chatStateMessageStreamItems(handler.currentState()).find((item) => item.id === "hook-hook-1-1")).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(chatStateThreadStreamItems(handler.currentState()).find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId"); + expect(chatStateThreadStreamItems(handler.currentState()).find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active", }); expect(pendingTurnStart(handler.currentState())).toBeNull(); @@ -604,15 +604,15 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual( + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "u1", text: "hello", turnId: "turn-active" }), expect.objectContaining({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" }), expect.objectContaining({ id: "a1", text: "done", turnId: "turn-active" }), ]), ); - expect(chatStateMessageStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false); + expect(chatStateThreadStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false); }); it("ignores completed turn notifications while a new turn is still starting", () => { @@ -626,8 +626,8 @@ describe("ChatInboundHandler", () => { }, }, }); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }, { id: "hook-hook-1-1", kind: "hook", @@ -662,7 +662,7 @@ describe("ChatInboundHandler", () => { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"], }); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); expect(maybeNameThread).not.toHaveBeenCalled(); expect(refreshActiveThreads).not.toHaveBeenCalled(); }); @@ -717,7 +717,7 @@ describe("ChatInboundHandler", () => { status: "failed", message: "missing token", }); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("refreshes tool inventory diagnostics when the app list changes", () => { @@ -730,7 +730,7 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(refreshServerDiagnostics).toHaveBeenCalledWith({ forceResourceProbes: false }); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("refreshes resource probes after MCP OAuth login completes", () => { @@ -743,7 +743,7 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(refreshServerDiagnostics).toHaveBeenCalledWith({ forceResourceProbes: true }); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); }); @@ -786,7 +786,7 @@ describe("ChatInboundHandler", () => { handler.resolveUserInput(42, { scope: "Narrow" }); expect(respondToServerRequest).toHaveBeenCalledWith(42, { answers: { scope: { answers: ["Narrow"] } } }); expect(handler.currentState().requests.pendingUserInputs).toEqual([]); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "userInputResult", role: "tool", text: "Input submitted for 1 question.", @@ -815,7 +815,7 @@ describe("ChatInboundHandler", () => { handler.cancelUserInput(43); expect(rejectServerRequest).toHaveBeenCalledWith(43, -32000, "User cancelled input request."); expect(handler.currentState().requests.pendingUserInputs).toEqual([]); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "userInputResult", role: "tool", text: "Input request cancelled for 1 question.", @@ -866,7 +866,7 @@ describe("ChatInboundHandler", () => { _meta: null, }); expect(handler.currentState().requests.pendingMcpElicitations).toEqual([]); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "userInputResult", role: "tool", text: "MCP request from github accepted.", @@ -901,7 +901,7 @@ describe("ChatInboundHandler", () => { expect(respondToServerRequest).toHaveBeenCalledWith(46, { action: "decline", content: null, _meta: null }); expect(handler.currentState().requests.pendingMcpElicitations).toEqual([]); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "userInputResult", text: "MCP request from github declined.", executionState: "failed", @@ -923,7 +923,7 @@ describe("ChatInboundHandler", () => { expect(respondToServerRequest).not.toHaveBeenCalled(); expect(rejectServerRequest).not.toHaveBeenCalled(); expect(handler.currentState().requests.pendingUserInputs).toEqual([current]); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("ignores missing MCP elicitation ids", () => { @@ -938,7 +938,7 @@ describe("ChatInboundHandler", () => { expect(respondToServerRequest).not.toHaveBeenCalled(); expect(handler.currentState().requests.pendingMcpElicitations).toEqual([current]); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("records manual permission approvals as colored result items", () => { @@ -954,7 +954,7 @@ describe("ChatInboundHandler", () => { permissions: {}, }); expect(handler.currentState().requests.approvals).toEqual([]); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ id: "approval-12", kind: "approvalResult", role: "tool", @@ -982,7 +982,7 @@ describe("ChatInboundHandler", () => { expect(respondToServerRequest).not.toHaveBeenCalled(); expect(handler.currentState().requests.approvals).toEqual([current]); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("handles known server request families and rejects unsupported requests by default", () => { @@ -1020,17 +1020,17 @@ describe("ChatInboundHandler", () => { -32601, "Rejected unknown app-server request: appServer/newFutureRequest", ); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => ("text" in item ? item.text : ""))).toEqual( + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => ("text" in item ? item.text : ""))).toEqual( unsupportedMessages, ); expect( - chatStateMessageStreamItems(handler.currentState()) + chatStateThreadStreamItems(handler.currentState()) .map((item) => ("text" in item ? item.text : "")) .join("\n"), ).not.toContain("do-not-render"); }); - it("keeps unknown server request fallback out of the normal message stream", () => { + it("keeps unknown server request fallback out of the normal thread stream", () => { const state = chatStateFixture(); const rejectServerRequest = vi.fn(() => true); const handler = handlerForState(state, { rejectServerRequest }); @@ -1038,7 +1038,7 @@ describe("ChatInboundHandler", () => { handler.handleServerRequest(unknownRequest()); expect(rejectServerRequest).toHaveBeenCalledWith(27, -32601, "Rejected unknown app-server request: appServer/newFutureRequest"); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("rejects server requests scoped to a different active thread or turn", () => { @@ -1117,7 +1117,7 @@ describe("ChatInboundHandler", () => { handler.resolveUserInput(55, { note: "Later" }); expect(handler.currentState().requests.pendingUserInputs).toHaveLength(1); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([ + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([ expect.objectContaining({ kind: "system", text: "Could not send user input because Codex app-server is not connected." }), ]); }); @@ -1205,7 +1205,7 @@ describe("ChatInboundHandler", () => { }); describe("thread lifecycle and reconciliation", () => { - it("keeps user-visible app-server notices in the message stream", () => { + it("keeps user-visible app-server notices in the thread stream", () => { const state = chatStateFixture(); const handler = handlerForState(state); @@ -1214,7 +1214,7 @@ describe("ChatInboundHandler", () => { params: { threadId: null, message: "careful" }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([ + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([ expect.objectContaining({ kind: "system", text: 'warning: {\n "threadId": null,\n "message": "careful"\n}', @@ -1222,7 +1222,7 @@ describe("ChatInboundHandler", () => { ]); }); - it("keeps Windows world-writable warnings in the message stream", () => { + it("keeps Windows world-writable warnings in the thread stream", () => { const handler = handlerForState(chatStateFixture()); handler.handleNotification({ @@ -1230,20 +1230,20 @@ describe("ChatInboundHandler", () => { params: { samplePaths: ["C:\\tmp\\open"], extraCount: 2, failedScan: false }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([ + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([ expect.objectContaining({ kind: "system", text: expect.stringContaining("windows/worldWritableWarning"), }), ]); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toEqual( + expect(chatStateThreadStreamItems(handler.currentState())[0]).toEqual( expect.objectContaining({ text: expect.stringContaining("open"), }), ); }); - it("keeps failed Windows sandbox setup notices in the message stream", () => { + it("keeps failed Windows sandbox setup notices in the thread stream", () => { const handler = handlerForState(chatStateFixture()); handler.handleNotification({ @@ -1251,13 +1251,13 @@ describe("ChatInboundHandler", () => { params: { mode: "unelevated", success: false, error: "setup failed" }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([ + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([ expect.objectContaining({ kind: "system", text: expect.stringContaining("windowsSandbox/setupCompleted"), }), ]); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toEqual( + expect(chatStateThreadStreamItems(handler.currentState())[0]).toEqual( expect.objectContaining({ text: expect.stringContaining("setup failed"), }), @@ -1272,7 +1272,7 @@ describe("ChatInboundHandler", () => { params: { mode: "unelevated", success: true, error: null }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("clears all active-thread scoped state when the active thread is archived", () => { @@ -1288,12 +1288,19 @@ describe("ChatInboundHandler", () => { }, }, }); - state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } }); - state = chatStateWith(state, { messageStream: { loadingHistory: true } }); - state = withChatStateMessageStreamItems(state, [ - { id: "message", kind: "message", role: "assistant", text: "stale", messageKind: "assistantResponse", messageState: "completed" }, + state = chatStateWith(state, { threadStream: { historyCursor: "cursor" } }); + state = chatStateWith(state, { threadStream: { loadingHistory: true } }); + state = withChatStateThreadStreamItems(state, [ + { + id: "message", + kind: "dialogue", + role: "assistant", + text: "stale", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, ]); - state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["turn-active", "@@\n-stale\n+stale"]]) } }); + state = chatStateWith(state, { threadStream: { turnDiffs: new Map([["turn-active", "@@\n-stale\n+stale"]]) } }); state = chatStateWith(state, { composer: { draft: "thread draft" } }); state = chatStateWith(state, { requests: { @@ -1415,15 +1422,15 @@ describe("ChatInboundHandler", () => { it("replaces optimistic user echoes when completed turns are reconciled", () => { let state = activeRunningState(); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "turn-active" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello", turnId: "turn-active" }, { id: "a1", sourceItemId: "a1", - kind: "message", + kind: "dialogue", role: "assistant", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "partial", turnId: "turn-active", }, @@ -1450,21 +1457,21 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).filter((item) => item.kind === "message" && item.role === "user")).toEqual( + expect(chatStateThreadStreamItems(handler.currentState()).filter((item) => item.kind === "dialogue" && item.role === "user")).toEqual( [expect.objectContaining({ id: "u1", text: "hello" })], ); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual( + expect(chatStateThreadStreamItems(handler.currentState())).toEqual( expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]), ); - expect(chatStateMessageStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false); + expect(chatStateThreadStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false); }); it("reconciles optimistic user echoes by client id before falling back to same-turn text only when client ids are absent", () => { let state = activeRunningState(); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" }, - { id: "local-steer-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" }, - { id: "local-user-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-other" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "same text", turnId: "turn-active" }, + { id: "local-steer-2", kind: "dialogue", dialogueKind: "user", role: "user", text: "same text", turnId: "turn-active" }, + { id: "local-user-2", kind: "dialogue", dialogueKind: "user", role: "user", text: "same text", turnId: "turn-other" }, ]); const handler = handlerForState(state); @@ -1493,33 +1500,33 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual( + expect(chatStateThreadStreamItems(handler.currentState())).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "same text" }), expect.objectContaining({ id: "local-steer-2", text: "same text" }), expect.objectContaining({ id: "local-user-2", text: "same text" }), ]), ); - expect(chatStateMessageStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false); + expect(chatStateThreadStreamItems(handler.currentState()).some((item) => item.id === "local-user-1")).toBe(false); let fallbackStateWithoutClientId = chatStateFixture(); fallbackStateWithoutClientId = chatStateWith(fallbackStateWithoutClientId, { activeThread: { id: "thread-active" } }); fallbackStateWithoutClientId = chatStateWith(fallbackStateWithoutClientId, { turn: { lifecycle: { kind: "running", turnId: "turn-active" } }, }); - fallbackStateWithoutClientId = withChatStateMessageStreamItems(fallbackStateWithoutClientId, [ + fallbackStateWithoutClientId = withChatStateThreadStreamItems(fallbackStateWithoutClientId, [ { id: "local-user-without-client-id", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "fallback text", turnId: "turn-active", }, { id: "local-user-other-turn", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "fallback text", turnId: "turn-other", @@ -1551,14 +1558,14 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(fallbackHandlerWithoutClientId.currentState())).toEqual( + expect(chatStateThreadStreamItems(fallbackHandlerWithoutClientId.currentState())).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "server-u1", text: "fallback text" }), expect.objectContaining({ id: "local-user-other-turn", text: "fallback text" }), ]), ); expect( - chatStateMessageStreamItems(fallbackHandlerWithoutClientId.currentState()).some( + chatStateThreadStreamItems(fallbackHandlerWithoutClientId.currentState()).some( (item) => item.id === "local-user-without-client-id", ), ).toBe(false); @@ -1566,19 +1573,19 @@ describe("ChatInboundHandler", () => { it("keeps the observed steer message order when completed turns reconcile by client id", () => { let state = activeRunningState(); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "start", turnId: "turn-active" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "start", turnId: "turn-active" }, { id: "a1", sourceItemId: "a1", - kind: "message", + kind: "dialogue", role: "assistant", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "first partial", turnId: "turn-active", }, - { id: "local-steer-1", kind: "message", messageKind: "user", role: "user", text: "steer", turnId: "turn-active" }, + { id: "local-steer-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "steer", turnId: "turn-active" }, ]); const handler = handlerForState(state); @@ -1604,8 +1611,8 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual( + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "start" }), expect.objectContaining({ id: "a1", text: "first done" }), @@ -1704,7 +1711,7 @@ describe("ChatInboundHandler", () => { expect(handler.currentState().runtime.active.approvalPolicy).toBe("on-request"); expect(handler.currentState().runtime.active.sandboxPolicy).toEqual({ type: "readOnly", networkAccess: false }); expect(handler.currentState().runtime.active.activePermissionProfile).toBeNull(); - expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); }); it("ignores settings notifications for inactive threads", () => { @@ -1808,25 +1815,25 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(handler.currentState().activeThread.goal).toEqual(goal); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "set: Finish", objective: "Finish", }); - const afterSetMessageCount = chatStateMessageStreamItems(handler.currentState()).length; + const afterSetMessageCount = chatStateThreadStreamItems(handler.currentState()).length; handler.handleNotification({ method: "thread/goal/updated", params: { threadId: "thread-active", turnId: null, goal }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(afterSetMessageCount); + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(afterSetMessageCount); const updatedGoal = { ...goal, objective: "Finish well", updatedAt: 2 }; handler.handleNotification({ method: "thread/goal/updated", params: { threadId: "thread-active", turnId: null, goal: updatedGoal }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "updated: Finish well", objective: "Finish well", @@ -1840,7 +1847,7 @@ describe("ChatInboundHandler", () => { method: "thread/goal/updated", params: { threadId: "thread-active", turnId: null, goal: pausedGoal }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "paused: Finish well", objective: "Finish well", @@ -1854,18 +1861,18 @@ describe("ChatInboundHandler", () => { method: "thread/goal/updated", params: { threadId: "thread-active", turnId: null, goal: resumedGoal }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "resumed: Finish well", objective: "Finish well", }); - const messageCount = chatStateMessageStreamItems(handler.currentState()).length; + const messageCount = chatStateThreadStreamItems(handler.currentState()).length; handler.handleNotification({ method: "thread/goal/updated", params: { threadId: "thread-active", turnId: null, goal: { ...resumedGoal, tokensUsed: 10, timeUsedSeconds: 20 } }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(messageCount); + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(messageCount); handler.handleNotification({ method: "thread/goal/cleared", @@ -1873,7 +1880,7 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(handler.currentState().activeThread.goal).toBeNull(); - expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState()).at(-1)).toMatchObject({ kind: "goal", text: "cleared: Finish well", objective: "Finish well", @@ -1912,8 +1919,8 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(handler.currentState().activeThread.goal).toEqual(completedGoal); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(1); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1); + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ kind: "goal", text: "completed: Finish", objective: "Finish", @@ -1924,7 +1931,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", turnId: "turn-1", goal: { ...completedGoal, tokensUsed: 43 } }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(1); + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1); }); it("ignores goal notifications that do not match the active thread", () => { @@ -1972,7 +1979,7 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", message: "Auto-review denied this command." }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toMatchObject([ + expect(chatStateThreadStreamItems(handler.currentState())).toMatchObject([ { kind: "reviewResult", role: "tool", @@ -2012,14 +2019,14 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(1); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1); + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ id: "review-review-1", kind: "reviewResult", text: "Auto-review approved: npm test", executionState: "completed", }); - const reviewItem = expectPresent(chatStateMessageStreamItems(handler.currentState())[0]); + const reviewItem = expectPresent(chatStateThreadStreamItems(handler.currentState())[0]); expect(reviewItem).toMatchObject({ review: { auditFacts: expect.arrayContaining([{ key: "status", value: "approved" }]) } }); }); @@ -2046,8 +2053,8 @@ describe("ChatInboundHandler", () => { }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(1); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1); + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ id: "review-review-1", kind: "reviewResult", text: "Auto-review approved: npm test", @@ -2078,8 +2085,8 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active", message: "Auto-review approved: npm test" }, } satisfies Extract); - expect(chatStateMessageStreamItems(handler.currentState())).toHaveLength(1); - expect(chatStateMessageStreamItems(handler.currentState())[0]).toMatchObject({ id: "review-review-1" }); + expect(chatStateThreadStreamItems(handler.currentState())).toHaveLength(1); + expect(chatStateThreadStreamItems(handler.currentState())[0]).toMatchObject({ id: "review-review-1" }); }); }); }); diff --git a/tests/features/chat/app-server/inbound/routing.test.ts b/tests/features/chat/app-server/inbound/routing.test.ts index b2f48cc4..72cfbb81 100644 --- a/tests/features/chat/app-server/inbound/routing.test.ts +++ b/tests/features/chat/app-server/inbound/routing.test.ts @@ -70,7 +70,7 @@ describe("chat inbound routing", () => { expectNotificationRouteKind(notification, "threadLifecycle", { activeThreadId: "thread-other", activeTurnId: "turn-active" }); }); - it("translates run-started runtime outcomes to thread catalog events at the inbound boundary", () => { + it("translates turn-started runtime outcomes to thread catalog events at the inbound boundary", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-active" } }); state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn-active" } } }); @@ -85,7 +85,7 @@ describe("chat inbound routing", () => { ]); }); - it("translates run-completed runtime outcomes to thread follow-up effects at the inbound boundary", () => { + it("translates turn-completed runtime outcomes to thread follow-up effects at the inbound boundary", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-active" } }); state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn-active" } } }); diff --git a/tests/features/chat/app-server/inbound/runtime-events.test.ts b/tests/features/chat/app-server/inbound/runtime-events.test.ts index c5139c6b..8a0bad7f 100644 --- a/tests/features/chat/app-server/inbound/runtime-events.test.ts +++ b/tests/features/chat/app-server/inbound/runtime-events.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ServerNotification } from "../../../../../src/app-server/connection/rpc-messages"; -import { conversationRuntimeEventsFromNotification } from "../../../../../src/features/chat/app-server/inbound/runtime-events"; +import { turnRuntimeEventsFromNotification } from "../../../../../src/features/chat/app-server/inbound/runtime-events"; describe("app-server conversation runtime event mapping", () => { it("maps assistant deltas to panel-owned runtime events", () => { @@ -9,12 +9,12 @@ describe("app-server conversation runtime event mapping", () => { params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" }, } satisfies Extract; - expect(conversationRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`)).toEqual([ - { type: "assistantDelta", runId: "turn-active", itemId: "a1", delta: "hello", completeReasoning: true }, + expect(turnRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`)).toEqual([ + { type: "assistantDelta", turnId: "turn-active", itemId: "a1", delta: "hello", completeReasoning: true }, ]); }); - it("maps completed turns to completed run snapshots", () => { + it("maps completed turns to completed turn snapshots", () => { const notification = { method: "turn/completed", params: { @@ -35,21 +35,21 @@ describe("app-server conversation runtime event mapping", () => { }, } satisfies Extract; - const events = conversationRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`); + const events = turnRuntimeEventsFromNotification(notification, (prefix) => `${prefix}-1`); expect(events).toEqual([ expect.objectContaining({ - type: "runCompleted", + type: "turnCompleted", threadId: "thread-active", - runId: "turn-active", + turnId: "turn-active", status: "completed", completedSummary: { userText: "hello", assistantText: "done" }, }), ]); expect(events[0]).toMatchObject({ completedItems: [ - expect.objectContaining({ id: "u1", kind: "message", role: "user", text: "hello" }), - expect.objectContaining({ id: "a1", kind: "message", role: "assistant", text: "done" }), + expect.objectContaining({ id: "u1", kind: "dialogue", role: "user", text: "hello" }), + expect.objectContaining({ id: "a1", kind: "dialogue", role: "assistant", text: "done" }), ], }); }); diff --git a/tests/features/chat/app-server/mappers/message-stream.test.ts b/tests/features/chat/app-server/mappers/thread-stream.test.ts similarity index 82% rename from tests/features/chat/app-server/mappers/message-stream.test.ts rename to tests/features/chat/app-server/mappers/thread-stream.test.ts index 7d913edc..29f6e1a9 100644 --- a/tests/features/chat/app-server/mappers/message-stream.test.ts +++ b/tests/features/chat/app-server/mappers/thread-stream.test.ts @@ -3,24 +3,24 @@ import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protoco import type { Thread } from "../../../../../src/domain/threads/model"; import { referencedThreadPromptBundle } from "../../../../../src/domain/threads/reference"; import { pathRelativeToRoot } from "../../../../../src/domain/vault/paths"; -import { collabAgentStateExecutionState } from "../../../../../src/features/chat/app-server/mappers/message-stream/execution-state"; -import { hookRunMessageStreamItem } from "../../../../../src/features/chat/app-server/mappers/message-stream/hook-run-items"; -import { autoReviewPermissionRows } from "../../../../../src/features/chat/app-server/mappers/message-stream/permission-rows"; +import { collabAgentStateExecutionState } from "../../../../../src/features/chat/app-server/mappers/thread-stream/execution-state"; +import { hookRunThreadStreamItem } from "../../../../../src/features/chat/app-server/mappers/thread-stream/hook-run-items"; +import { autoReviewPermissionRows } from "../../../../../src/features/chat/app-server/mappers/thread-stream/permission-rows"; import { createAutoReviewResultItem, createReviewResultItem, -} from "../../../../../src/features/chat/app-server/mappers/message-stream/review-result-items"; -import { taskProgressMessageStreamItem } from "../../../../../src/features/chat/app-server/mappers/message-stream/task-progress"; +} from "../../../../../src/features/chat/app-server/mappers/thread-stream/review-result-items"; +import { taskProgressThreadStreamItem } from "../../../../../src/features/chat/app-server/mappers/thread-stream/task-progress"; import { - messageStreamItemFromTurnItem, - messageStreamItemsFromTurns, -} from "../../../../../src/features/chat/app-server/mappers/message-stream/turn-items"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import { activeTurnLiveItems } from "../../../../../src/features/chat/domain/message-stream/semantics/active-turn"; -import { upsertMessageStreamItemById } from "../../../../../src/features/chat/domain/message-stream/updates"; -import { messageStreamLayoutBlocks } from "../../../../../src/features/chat/presentation/message-stream/layout"; + threadStreamItemFromTurnItem, + threadStreamItemsFromTurns, +} from "../../../../../src/features/chat/app-server/mappers/thread-stream/turn-items"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { activeTurnLiveItems } from "../../../../../src/features/chat/domain/thread-stream/semantics/active-turn"; +import { upsertThreadStreamItemById } from "../../../../../src/features/chat/domain/thread-stream/updates"; +import { threadStreamLayoutBlocks } from "../../../../../src/features/chat/presentation/thread-stream/layout"; -function commandItem(id: string, text: string, turnId: string): MessageStreamItem { +function commandItem(id: string, text: string, turnId: string): ThreadStreamItem { return { id, kind: "command", @@ -35,7 +35,7 @@ function commandItem(id: string, text: string, turnId: string): MessageStreamIte }; } -function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): MessageStreamItem { +function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): ThreadStreamItem { return { id, kind: "fileChange", @@ -47,7 +47,7 @@ function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): Messa }; } -function autoReviewResultItem(id: string, turnId: string, text = "Auto-review approved: npm test"): MessageStreamItem { +function autoReviewResultItem(id: string, turnId: string, text = "Auto-review approved: npm test"): ThreadStreamItem { return { id, kind: "reviewResult", @@ -59,7 +59,7 @@ function autoReviewResultItem(id: string, turnId: string, text = "Auto-review ap }; } -function activeAgentSummary(items: readonly MessageStreamItem[], activeTurnId: string | null) { +function activeAgentSummary(items: readonly ThreadStreamItem[], activeTurnId: string | null) { if (!activeTurnId) return null; return activeTurnLiveItems({ items }, activeTurnId).find((item) => item.kind === "agentSummary")?.summary ?? null; } @@ -88,12 +88,12 @@ describe("turn item conversion preserves app-server semantics", () => { ]; expect( - messageStreamItemsFromTurns(turns) - .filter((item) => item.kind === "message") + threadStreamItemsFromTurns(turns) + .filter((item) => item.kind === "dialogue") .map((item) => item.text), ).toEqual(["hello", "world"]); - expect(messageStreamItemFromTurnItem(userMessage)).toMatchObject({ role: "user", copyText: "hello" }); - expect(messageStreamItemFromTurnItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world" }); + expect(threadStreamItemFromTurnItem(userMessage)).toMatchObject({ role: "user", copyText: "hello" }); + expect(threadStreamItemFromTurnItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world" }); }); it("keeps resolved file mentions visible as user message metadata", () => { @@ -109,9 +109,9 @@ describe("turn item conversion preserves app-server semantics", () => { ], }; - expect(messageStreamItemFromTurnItem(userMessage)).toMatchObject({ - kind: "message", - messageKind: "user", + expect(threadStreamItemFromTurnItem(userMessage)).toMatchObject({ + kind: "dialogue", + dialogueKind: "user", role: "user", text: "Read [[Alpha]] and [[Beta]].", mentionedFiles: [ @@ -134,7 +134,7 @@ describe("turn item conversion preserves app-server semantics", () => { ], }; - expect(messageStreamItemFromTurnItem(userMessage)).toMatchObject({ + expect(threadStreamItemFromTurnItem(userMessage)).toMatchObject({ mentionedFiles: [ { name: "Alpha", path: "thoughts/Alpha.md" }, { name: "Active file", path: "thoughts/Alpha.md" }, @@ -153,7 +153,7 @@ describe("turn item conversion preserves app-server semantics", () => { ); expect( - messageStreamItemFromTurnItem({ + threadStreamItemFromTurnItem({ type: "userMessage", id: "u1", clientId: null, @@ -183,7 +183,7 @@ describe("turn item conversion preserves app-server semantics", () => { ], }; - expect(messageStreamItemFromTurnItem(item)).toMatchObject({ + expect(threadStreamItemFromTurnItem(item)).toMatchObject({ text: "Use `$obsidian-codex-panel-maintain` and $missing.", copyText: "Use $obsidian-codex-panel-maintain and $missing.", }); @@ -204,38 +204,38 @@ describe("turn item conversion preserves app-server semantics", () => { ], }; - expect(messageStreamItemFromTurnItem(item)).toMatchObject({ + expect(threadStreamItemFromTurnItem(item)).toMatchObject({ text: "Use `$obsidian-codex-panel-maintain`.\n\n```\n$obsidian-codex-panel-maintain\n```\n\nThen `$obsidian-codex-panel-maintain`.", }); }); it("preserves reasoning text", () => { const item: TurnItem = { type: "reasoning", id: "r1", summary: ["summary"], content: ["detail"] }; - expect(messageStreamItemFromTurnItem(item)).toMatchObject({ kind: "reasoning", text: "summary\n\ndetail" }); + expect(threadStreamItemFromTurnItem(item)).toMatchObject({ kind: "reasoning", text: "summary\n\ndetail" }); }); it("keeps empty reasoning text empty so completed placeholders can be hidden", () => { const item: TurnItem = { type: "reasoning", id: "r1", summary: [], content: [] }; - expect(messageStreamItemFromTurnItem(item)).toMatchObject({ kind: "reasoning", text: "" }); + expect(threadStreamItemFromTurnItem(item)).toMatchObject({ kind: "reasoning", text: "" }); }); it("renders completed proposed plans as copyable assistant markdown", () => { const item: TurnItem = { type: "plan", id: "p1", text: "\n# Plan\n" }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ id: "p1", - kind: "message", + kind: "dialogue", role: "assistant", text: "# Plan", copyText: "# Plan", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", turnId: "t1", }); }); it("formats structured plan progress as task progress", () => { expect( - taskProgressMessageStreamItem("t1", "Working plan", [ + taskProgressThreadStreamItem("t1", "Working plan", [ { step: "Inspect code", status: "completed" }, { step: "Patch UI", status: "inProgress" }, { step: "Run tests", status: "pending" }, @@ -268,7 +268,7 @@ describe("turn item conversion preserves app-server semantics", () => { agentsStates: { "child-thread": { status: "completed", message: "Done" } }, }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ id: "agent-1", kind: "agent", role: "tool", @@ -298,7 +298,7 @@ describe("turn item conversion preserves app-server semantics", () => { agentsStates: {}, }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "agent", status: "completed", executionState: "completed", @@ -588,7 +588,7 @@ describe("turn item conversion preserves app-server semantics", () => { path: "/vault/project/assets/image.png", }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "tool", toolName: "imageView", primaryTarget: { kind: "path", path: "/vault/project/assets/image.png" }, @@ -602,7 +602,7 @@ describe("turn item conversion preserves app-server semantics", () => { durationMs: 2500, }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "wait", text: "Waited 2.5s", executionState: "completed", @@ -619,7 +619,7 @@ describe("turn item conversion preserves app-server semantics", () => { savedPath: "/vault/project/assets/generated.png", }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "tool", toolName: "imageGeneration", primaryTarget: { kind: "path", path: "/vault/project/assets/generated.png" }, @@ -642,7 +642,7 @@ describe("turn item conversion preserves app-server semantics", () => { result: "image result", }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "tool", toolName: "imageGeneration", primaryTarget: { kind: "value", value: "image result" }, @@ -661,7 +661,7 @@ describe("turn item conversion preserves app-server semantics", () => { action: { type: "search", query: "codex app-server", queries: ["obsidian codex panel"] }, }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "tool", toolName: "web search", operation: "search", @@ -681,7 +681,7 @@ describe("turn item conversion preserves app-server semantics", () => { action: null, }; - expect(messageStreamItemFromTurnItem(item, "t1")).toEqual({ + expect(threadStreamItemFromTurnItem(item, "t1")).toEqual({ id: "search-empty", kind: "tool", role: "tool", @@ -703,13 +703,13 @@ describe("turn item conversion preserves app-server semantics", () => { const entered: TurnItem = { type: "enteredReviewMode", id: "review-entered", review: "Review started" }; const exited: TurnItem = { type: "exitedReviewMode", id: "review-exited", review: "Review finished" }; - expect(messageStreamItemFromTurnItem(entered, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(entered, "t1")).toMatchObject({ kind: "tool", toolName: "enteredReviewMode", primaryTarget: { kind: "value", value: "Entered review mode" }, output: "Review started", }); - expect(messageStreamItemFromTurnItem(exited, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(exited, "t1")).toMatchObject({ kind: "tool", toolName: "exitedReviewMode", primaryTarget: { kind: "value", value: "Exited review mode" }, @@ -720,7 +720,7 @@ describe("turn item conversion preserves app-server semantics", () => { it("preserves context compaction items as short status items", () => { const item: TurnItem = { type: "contextCompaction", id: "compact-1" }; - expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({ + expect(threadStreamItemFromTurnItem(item, "t1")).toMatchObject({ kind: "contextCompaction", turnId: "t1", sourceItemId: "compact-1", @@ -803,31 +803,31 @@ describe("auto-review permission detail rows", () => { }); }); -describe("display block grouping keeps message stream details subordinate to conversation messages", () => { +describe("display block grouping keeps thread stream details subordinate to conversation messages", () => { it("groups completed turn activities before the final assistant message", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, commandItem("c1", "npm test", "t1"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); expect(blocks[1]).toMatchObject({ summary: "Work details" }); }); it("groups completed hook and review logs before the final assistant message", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "hook-1", kind: "hook", @@ -843,16 +843,16 @@ describe("display block grouping keeps message stream details subordinate to con autoReviewResultItem("review-2", "t1"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "turn-t1-activity", "a1"]); expect(blocks[1]).toMatchObject({ @@ -872,59 +872,59 @@ describe("display block grouping keeps message stream details subordinate to con }); it("hides empty completed reasoning items", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed", executionState: "completed" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "a1"]); }); it("collapses earlier assistant responses with their turn details", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "I'll inspect it.", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, commandItem("c1", "rg issue", "t1"), { id: "a2", - kind: "message", + kind: "dialogue", role: "assistant", text: "I found the cause.", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, fileChangeItem("f1", "t1"), { id: "a3", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); expect(blocks[1]).toMatchObject({ summary: "Work details", @@ -934,44 +934,44 @@ describe("display block grouping keeps message stream details subordinate to con }); it("keeps completed turn work details attached to the final response even when system messages are interleaved", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ { id: "s1", kind: "system", role: "system", text: "debug before hook" }, commandItem("c1", "npm test", "t1"), { id: "s2", kind: "system", role: "system", text: "debug after hook" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["s1", "s2", "turn-t1-activity", "a1"]); }); it("adds steering markers to completed turn work details without hiding the steering message", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, commandItem("c1", "rg issue", "t1"), - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" }, commandItem("c2", "npm test", "t1"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "u2", "turn-t1-activity", "a1"]); expect(blocks[2]).toMatchObject({ @@ -991,22 +991,22 @@ describe("display block grouping keeps message stream details subordinate to con }); it("keeps multiple steering markers in completed turn work details", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "also check tests", turnId: "t1" }, - { id: "u3", kind: "message", messageKind: "user", role: "user", text: "and update docs", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1" }, + { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "and update docs", turnId: "t1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); const activityGroup = blocks.find((block) => block.type === "activityGroup"); expect(activityGroup).toMatchObject({ @@ -1019,25 +1019,25 @@ describe("display block grouping keeps message stream details subordinate to con }); it("keeps active turn activities expanded", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - expect(messageStreamLayoutBlocks(items, "t1").map((block) => block.type)).toEqual(["item", "item"]); + expect(threadStreamLayoutBlocks(items, "t1").map((block) => block.type)).toEqual(["item", "item"]); }); it("keeps active task progress chronological in the display model", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "plan-progress-t1", kind: "taskProgress", @@ -1050,23 +1050,23 @@ describe("display block grouping keeps message stream details subordinate to con }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "working", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, "t1"); + const blocks = threadStreamLayoutBlocks(items, "t1"); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "plan-progress-t1", "a1"]); }); it("groups task progress and agent activity inside completed turn work details", () => { - const items: MessageStreamItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "plan-progress-t1", kind: "taskProgress", @@ -1094,16 +1094,16 @@ describe("display block grouping keeps message stream details subordinate to con }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const blocks = messageStreamLayoutBlocks(items, null); + const blocks = threadStreamLayoutBlocks(items, null); expect(blocks[1]).toMatchObject({ summary: "Work details", items: [{ item: { id: "plan-progress-t1" } }, { item: { id: "agent-1" } }], @@ -1111,7 +1111,7 @@ describe("display block grouping keeps message stream details subordinate to con }); it("summarizes active subagent states while a turn is running", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ { id: "agent-1", kind: "agent", @@ -1144,7 +1144,7 @@ describe("display block grouping keeps message stream details subordinate to con }); it("summarizes active subagent previews and fallback receiver states", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ { id: "agent-1", kind: "agent", @@ -1199,7 +1199,7 @@ describe("display block grouping keeps message stream details subordinate to con }); it("omits active subagent summaries once every subagent is complete", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ { id: "agent-1", kind: "agent", @@ -1221,116 +1221,116 @@ describe("display block grouping keeps message stream details subordinate to con }); it("adds edited files to the final assistant message", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "src/main.ts"), fileChangeItem("f2", "t1", "styles.css"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "intermediate", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, { id: "a2", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const assistantBlock = messageStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); + const assistantBlock = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["src/main.ts", "styles.css"] } }); }); it("adds turn diff metadata to the final assistant message only when aggregated diff exists", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "src/main.ts"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const withoutDiff = messageStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); + const withoutDiff = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); expect(withoutDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"] } }); expect(withoutDiff?.type === "item" ? withoutDiff.annotations : null).not.toHaveProperty("turnDiff"); - const withDiff = messageStreamLayoutBlocks(items, null, null, new Map([["t1", "@@\n-old\n+new"]])).find( + const withDiff = threadStreamLayoutBlocks(items, null, null, new Map([["t1", "@@\n-old\n+new"]])).find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(withDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"], turnDiff: { diff: "@@\n-old\n+new" } } }); }); it("adds auto-review summaries to the final assistant message", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ autoReviewResultItem("review-1", "t1"), autoReviewResultItem("review-2", "t1"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const assistantBlock = messageStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); + const assistantBlock = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); expect(assistantBlock).toMatchObject({ annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, }); }); it("does not add edited file or auto-review summaries to active turn messages", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "src/main.ts"), autoReviewResultItem("review-1", "t1"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "still working", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const assistantBlock = messageStreamLayoutBlocks(items, "t1").find((block) => block.type === "item" && block.item.id === "a1"); + const assistantBlock = threadStreamLayoutBlocks(items, "t1").find((block) => block.type === "item" && block.item.id === "a1"); expect(assistantBlock?.type === "item" ? assistantBlock.annotations : null).toBeUndefined(); }); }); describe("workspace path summaries stay readable without hiding audit paths", () => { it("shows edited files relative to the workspace root", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "/vault/project/src/main.ts"), fileChangeItem("f2", "t1", "/vault/project/styles.css"), fileChangeItem("f3", "t1", "/tmp/outside.txt"), { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; - const assistantBlock = messageStreamLayoutBlocks(items, null, "/vault/project").find( + const assistantBlock = threadStreamLayoutBlocks(items, null, "/vault/project").find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["/tmp/outside.txt", "src/main.ts", "styles.css"] } }); @@ -1366,15 +1366,15 @@ describe("execution state uses typed status adapters before rendered text", () = expect(fileChangeStreamItem({ status: "declined" })).toMatchObject({ executionState: "failed" }); expect(mcpToolCallItem({ status: "completed" })).toMatchObject({ executionState: "completed" }); expect( - taskProgressMessageStreamItem("turn", "Planning", [ + taskProgressThreadStreamItem("turn", "Planning", [ { step: "Read context", status: "pending" }, { step: "Patch code", status: "completed" }, ]), ).toMatchObject({ executionState: "running" }); expect(autoReviewItem("approved")).toMatchObject({ executionState: "completed" }); expect(autoReviewItem("timedOut")).toMatchObject({ executionState: "failed" }); - expect(hookRunMessageStreamItem(hookRun(), "turn", "completed")).toMatchObject({ executionState: "completed" }); - expect(hookRunMessageStreamItem(hookRun(), "turn", "stopped")).toMatchObject({ executionState: "failed" }); + expect(hookRunThreadStreamItem(hookRun(), "turn", "completed")).toMatchObject({ executionState: "completed" }); + expect(hookRunThreadStreamItem(hookRun(), "turn", "stopped")).toMatchObject({ executionState: "failed" }); expect(collabAgentStateExecutionState("inProgress")).toBe("running"); expect(collabAgentStateExecutionState("failed")).toBe("failed"); }); @@ -1390,7 +1390,7 @@ describe("execution state uses typed status adapters before rendered text", () = }); it("does not overwrite streamed output with an empty completed item", () => { - const streamed: MessageStreamItem = { + const streamed: ThreadStreamItem = { id: "c1", sourceItemId: "c1", kind: "command", @@ -1402,7 +1402,7 @@ describe("execution state uses typed status adapters before rendered text", () = status: "running", output: "partial output", }; - const completed: MessageStreamItem = { + const completed: ThreadStreamItem = { id: "c1", sourceItemId: "c1", kind: "command", @@ -1415,7 +1415,7 @@ describe("execution state uses typed status adapters before rendered text", () = output: "", }; - expect(upsertMessageStreamItemById([streamed], completed)[0]).toMatchObject({ output: "partial output", status: "completed" }); + expect(upsertThreadStreamItemById([streamed], completed)[0]).toMatchObject({ output: "partial output", status: "completed" }); }); }); @@ -1424,8 +1424,8 @@ type FileChangeOverrides = Omit>, "status"> & { status?: string }; type DynamicToolCallOverrides = Omit>, "status"> & { status?: string }; -function commandExecutionItem(overrides: CommandExecutionOverrides = {}): MessageStreamItem | null { - return messageStreamItemFromTurnItem( +function commandExecutionItem(overrides: CommandExecutionOverrides = {}): ThreadStreamItem | null { + return threadStreamItemFromTurnItem( { type: "commandExecution", id: "command-1", @@ -1444,8 +1444,8 @@ function commandExecutionItem(overrides: CommandExecutionOverrides = {}): Messag ); } -function fileChangeStreamItem(overrides: FileChangeOverrides = {}): MessageStreamItem | null { - return messageStreamItemFromTurnItem( +function fileChangeStreamItem(overrides: FileChangeOverrides = {}): ThreadStreamItem | null { + return threadStreamItemFromTurnItem( { type: "fileChange", id: "patch-1", @@ -1457,8 +1457,8 @@ function fileChangeStreamItem(overrides: FileChangeOverrides = {}): MessageStrea ); } -function mcpToolCallItem(overrides: McpToolCallOverrides = {}): MessageStreamItem | null { - return messageStreamItemFromTurnItem( +function mcpToolCallItem(overrides: McpToolCallOverrides = {}): ThreadStreamItem | null { + return threadStreamItemFromTurnItem( { type: "mcpToolCall", id: "mcp-1", @@ -1477,8 +1477,8 @@ function mcpToolCallItem(overrides: McpToolCallOverrides = {}): MessageStreamIte ); } -function dynamicToolCallItem(overrides: DynamicToolCallOverrides = {}): MessageStreamItem | null { - return messageStreamItemFromTurnItem( +function dynamicToolCallItem(overrides: DynamicToolCallOverrides = {}): ThreadStreamItem | null { + return threadStreamItemFromTurnItem( { type: "dynamicToolCall", id: "dynamic-1", @@ -1495,7 +1495,7 @@ function dynamicToolCallItem(overrides: DynamicToolCallOverrides = {}): MessageS ); } -function autoReviewItem(status: string): MessageStreamItem { +function autoReviewItem(status: string): ThreadStreamItem { return createAutoReviewResultItem({ threadId: "thread", turnId: "turn", diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 7dca0c5b..ec8c376e 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -180,7 +180,7 @@ describe("chat app-server transports", () => { await expect(forking).resolves.toBeNull(); }); - it("projects rollback turns into message stream items", async () => { + it("projects rollback turns into thread stream items", async () => { const request = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) }); const client = { request } as unknown as AppServerClient; const transport = createTestGateway({ @@ -193,10 +193,10 @@ describe("chat app-server transports", () => { expect(request).toHaveBeenCalledWith("thread/rollback", { threadId: "thread", numTurns: 1 }); expect(snapshot?.thread.id).toBe("thread"); expect(snapshot?.cwd).toBe("/vault"); - expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "message", role: "user", text: "prompt" })]); + expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "dialogue", role: "user", text: "prompt" })]); }); - it("reads thread history pages as message stream items", async () => { + it("reads thread history pages as thread stream items", async () => { const request = vi.fn().mockResolvedValue({ data: [turn([userMessage("u1", "prompt"), agentMessage("a1", "answer")])], nextCursor: "older", @@ -219,8 +219,8 @@ describe("chat app-server transports", () => { expect(page?.nextCursor).toBe("older"); expect(page?.hadTurns).toBe(true); expect(page?.items).toEqual([ - expect.objectContaining({ kind: "message", role: "user", text: "prompt" }), - expect.objectContaining({ kind: "message", role: "assistant", text: "answer" }), + expect.objectContaining({ kind: "dialogue", role: "user", text: "prompt" }), + expect.objectContaining({ kind: "dialogue", role: "assistant", text: "answer" }), ]); }); @@ -274,7 +274,7 @@ describe("chat app-server transports", () => { expect(snapshot?.initialHistoryPage).toMatchObject({ nextCursor: "older", hadTurns: true, - items: [expect.objectContaining({ kind: "message", role: "user", text: "prompt" })], + items: [expect.objectContaining({ kind: "dialogue", role: "user", text: "prompt" })], }); }); diff --git a/tests/features/chat/application/state/message-stream.test.ts b/tests/features/chat/application/state/message-stream.test.ts deleted file mode 100644 index 4b123143..00000000 --- a/tests/features/chat/application/state/message-stream.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - initialChatMessageStreamState, - messageStreamRollbackCandidate, - messageStreamTurnsAfterTurnId, - messageStreamWithActiveTurnItems, -} from "../../../../../src/features/chat/application/state/message-stream"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; - -describe("message stream selectors", () => { - it("counts turns after a turn id from message stream state", () => { - const state = initialChatMessageStreamState(items()); - - expect(messageStreamTurnsAfterTurnId(state, "turn-1")).toBe(2); - expect(messageStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); - expect(messageStreamTurnsAfterTurnId(state, "turn-3")).toBe(0); - expect(messageStreamTurnsAfterTurnId(state, "missing")).toBeNull(); - }); - - it("includes the active segment when counting turns", () => { - const state = messageStreamWithActiveTurnItems(initialChatMessageStreamState(items()), "turn-3", items()); - - expect(messageStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); - }); - - it("selects the latest turn user message for rollback restoration", () => { - const state = initialChatMessageStreamState(items()); - - expect(messageStreamRollbackCandidate(state)).toEqual({ turnId: "turn-3", itemId: "u3", text: "third" }); - }); - - it("uses the semantic prompt instead of steering messages for rollback restoration", () => { - const state = initialChatMessageStreamState([ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "initial", turnId: "turn-1" }, - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "steer", turnId: "turn-1", clientId: "local-steer-1" }, - { - id: "a1", - kind: "message", - role: "assistant", - text: "done", - turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", - }, - ]); - - expect(messageStreamRollbackCandidate(state)).toEqual({ turnId: "turn-1", itemId: "u1", text: "initial" }); - }); - - it("restores the raw user message text instead of rendered display text", () => { - const state = initialChatMessageStreamState([ - { - id: "u1", - kind: "message", - messageKind: "user", - role: "user", - text: "Use `$obsidian-codex-panel-maintain`.", - copyText: "Use $obsidian-codex-panel-maintain.", - turnId: "turn-1", - }, - ]); - - expect(messageStreamRollbackCandidate(state)).toEqual({ - turnId: "turn-1", - itemId: "u1", - text: "Use $obsidian-codex-panel-maintain.", - }); - }); - - it("returns null when rollback has no user message candidate", () => { - expect(messageStreamRollbackCandidate(initialChatMessageStreamState([]))).toBeNull(); - expect( - messageStreamRollbackCandidate(initialChatMessageStreamState([{ id: "system", kind: "system", role: "system", text: "Idle" }])), - ).toBeNull(); - expect( - messageStreamRollbackCandidate( - initialChatMessageStreamState([ - { - id: "a1", - kind: "message", - role: "assistant", - text: "answer", - turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", - }, - ]), - ), - ).toBeNull(); - }); -}); - -function items(): MessageStreamItem[] { - return [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "first", turnId: "turn-1" }, - { - id: "a1", - kind: "message", - role: "assistant", - text: "first answer", - turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", - }, - { id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" }, - { - id: "a2", - kind: "message", - role: "assistant", - text: "second answer", - turnId: "turn-2", - messageKind: "assistantResponse", - messageState: "completed", - }, - { id: "u3", kind: "message", messageKind: "user", role: "user", text: "third", turnId: "turn-3" }, - { - id: "a3", - kind: "message", - role: "assistant", - text: "third answer", - turnId: "turn-3", - messageKind: "assistantResponse", - messageState: "completed", - }, - ]; -} diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index 0d634425..d58d68d5 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src/features/chat/application/conversation/turn-state"; -import { messageStreamItems } from "../../../../../src/features/chat/application/state/message-stream"; import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import { threadStreamItems } from "../../../../../src/features/chat/application/state/thread-stream"; +import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state"; import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent"; -import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { chatStateFixture, chatStateWith } from "../../support/state"; +import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; describe("chatReducer", () => { it("clears active turn and thread-scoped state", () => { @@ -131,7 +131,7 @@ describe("chatReducer", () => { it("starts resumed threads with empty display state when no history items are supplied", () => { let state = chatStateFixture(); - state = withChatStateMessageStreamItems(state, [message("previous-message")]); + state = withChatStateThreadStreamItems(state, [message("previous-message")]); const next = chatReducer(state, { type: "active-thread/resumed", @@ -149,42 +149,42 @@ describe("chatReducer", () => { approvalsReviewer: null, }); - expect(chatStateMessageStreamItems(next)).toEqual([]); + expect(chatStateThreadStreamItems(next)).toEqual([]); }); it("updates map-backed turn diffs and toolbar panel state", () => { let state = chatStateFixture(); - state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["turn", "old"]]) } }); + state = chatStateWith(state, { threadStream: { turnDiffs: new Map([["turn", "old"]]) } }); state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } }); - const withDiff = chatReducer(state, { type: "message-stream/turn-diff-updated", turnId: "turn", diff: "new" }); + const withDiff = chatReducer(state, { type: "thread-stream/turn-diff-updated", turnId: "turn", diff: "new" }); const withHistoryPanel = chatReducer(withDiff, { type: "ui/panel-set", panel: "history" }); - expect(withDiff.messageStream.turnDiffs.get("turn")).toBe("new"); + expect(withDiff.threadStream.turnDiffs.get("turn")).toBe("new"); expect(withHistoryPanel.ui.toolbarPanel).toBe("history"); }); it("deduplicates reported logs", () => { const state = chatStateFixture(); - const item = { id: "log", kind: "system", role: "system", text: "once" } satisfies MessageStreamItem; + const item = { id: "log", kind: "system", role: "system", text: "once" } satisfies ThreadStreamItem; - const first = chatReducer(state, { type: "message-stream/deduped-log-added", text: "once", item }); - const second = chatReducer(first, { type: "message-stream/deduped-log-added", text: "once", item }); + const first = chatReducer(state, { type: "thread-stream/deduped-log-added", text: "once", item }); + const second = chatReducer(first, { type: "thread-stream/deduped-log-added", text: "once", item }); - expect(chatStateMessageStreamItems(first)).toEqual([item]); - expect(chatStateMessageStreamItems(second)).toEqual([item]); + expect(chatStateThreadStreamItems(first)).toEqual([item]); + expect(chatStateThreadStreamItems(second)).toEqual([item]); }); it("keeps turn lifecycle fields synchronized through start and completion", () => { const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: [] }; const optimisticItem = { id: "local-user", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "hello", - } satisfies MessageStreamItem; - const acknowledgedItem = { ...optimisticItem, turnId: "turn" } satisfies MessageStreamItem; + } satisfies ThreadStreamItem; + const acknowledgedItem = { ...optimisticItem, turnId: "turn" } satisfies ThreadStreamItem; const optimistic = chatReducer(chatStateFixture(), { type: "turn/optimistic-started", @@ -217,28 +217,25 @@ describe("chatReducer", () => { it("appends streaming assistant deltas after stable history", () => { let state = chatStateFixture(); - state = withChatStateMessageStreamItems(state, [message("history")]); + state = withChatStateThreadStreamItems(state, [message("history")]); const running = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" }); const next = chatReducer(running, { - type: "message-stream/assistant-delta-appended", + type: "thread-stream/assistant-delta-appended", itemId: "assistant", turnId: "turn", delta: "hello", }); - expect(next.messageStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "hello", turnId: "turn" })]); - expect(messageStreamItems(next.messageStream)).toEqual([ - message("history"), - expect.objectContaining({ id: "assistant", text: "hello" }), - ]); + expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "hello", turnId: "turn" })]); + expect(threadStreamItems(next.threadStream)).toEqual([message("history"), expect.objectContaining({ id: "assistant", text: "hello" })]); }); it("updates repeated streaming output through the active source-item index", () => { let state = chatStateFixture(); state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" }); state = chatReducer(state, { - type: "message-stream/item-output-appended", + type: "thread-stream/item-output-appended", itemId: "cmd", turnId: "turn", delta: "one", @@ -247,7 +244,7 @@ describe("chatReducer", () => { }); const next = chatReducer(state, { - type: "message-stream/item-output-appended", + type: "thread-stream/item-output-appended", itemId: "cmd", turnId: "turn", delta: "two", @@ -255,31 +252,31 @@ describe("chatReducer", () => { fallbackText: "Command running", }); - expect(next.messageStream.activeSegment?.items).toHaveLength(1); - expect(next.messageStream.activeSegment?.indexBySourceItemId.get("cmd")).toBe(0); - expect(next.messageStream.activeSegment?.items[0]).toMatchObject({ id: "cmd", output: "onetwo" }); + expect(next.threadStream.activeSegment?.items).toHaveLength(1); + expect(next.threadStream.activeSegment?.indexBySourceItemId.get("cmd")).toBe(0); + expect(next.threadStream.activeSegment?.items[0]).toMatchObject({ id: "cmd", output: "onetwo" }); }); it("ignores streaming deltas for a different active turn", () => { let state = chatStateFixture(); state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn-active" }); state = chatReducer(state, { - type: "message-stream/assistant-delta-appended", + type: "thread-stream/assistant-delta-appended", itemId: "assistant", turnId: "turn-active", delta: "active", }); const next = chatReducer(state, { - type: "message-stream/assistant-delta-appended", + type: "thread-stream/assistant-delta-appended", itemId: "stale", turnId: "turn-stale", delta: "stale", }); expect(next).toBe(state); - expect(next.messageStream.activeSegment?.turnId).toBe("turn-active"); - expect(next.messageStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "active" })]); + expect(next.threadStream.activeSegment?.turnId).toBe("turn-active"); + expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "active" })]); }); it("keeps optimistic active segment items when the first acknowledged delta arrives", () => { @@ -291,14 +288,14 @@ describe("chatReducer", () => { }); const next = chatReducer(state, { - type: "message-stream/assistant-delta-appended", + type: "thread-stream/assistant-delta-appended", itemId: "assistant", turnId: "turn", delta: "ack", }); - expect(next.messageStream.activeSegment?.turnId).toBe("turn"); - expect(next.messageStream.activeSegment?.items).toEqual([ + expect(next.threadStream.activeSegment?.turnId).toBe("turn"); + expect(next.threadStream.activeSegment?.items).toEqual([ expect.objectContaining({ id: "local-user" }), expect.objectContaining({ id: "assistant", text: "ack", turnId: "turn" }), ]); @@ -308,7 +305,7 @@ describe("chatReducer", () => { let state = chatStateFixture(); state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" }); state = chatReducer(state, { - type: "message-stream/item-text-appended", + type: "thread-stream/item-text-appended", itemId: "shared-source", turnId: "turn", label: "Tool", @@ -317,7 +314,7 @@ describe("chatReducer", () => { }); const next = chatReducer(state, { - type: "message-stream/item-text-appended", + type: "thread-stream/item-text-appended", itemId: "shared-source", turnId: "turn", label: "Reasoning", @@ -326,14 +323,14 @@ describe("chatReducer", () => { }); expect(next).toBe(state); - expect(next.messageStream.activeSegment?.items).toEqual([expect.objectContaining({ kind: "tool", text: "Tool: tool text" })]); + expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ kind: "tool", text: "Tool: tool text" })]); }); it("appends text to an existing item when the item kind matches", () => { let state = chatStateFixture(); state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" }); state = chatReducer(state, { - type: "message-stream/item-text-appended", + type: "thread-stream/item-text-appended", itemId: "reasoning", turnId: "turn", label: "Reasoning", @@ -342,7 +339,7 @@ describe("chatReducer", () => { }); const next = chatReducer(state, { - type: "message-stream/item-text-appended", + type: "thread-stream/item-text-appended", itemId: "reasoning", turnId: "turn", label: "Reasoning", @@ -350,7 +347,7 @@ describe("chatReducer", () => { kind: "reasoning", }); - expect(next.messageStream.activeSegment?.items).toEqual([expect.objectContaining({ kind: "reasoning", text: "Reasoning: onetwo" })]); + expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ kind: "reasoning", text: "Reasoning: onetwo" })]); }); it("clears running state when a turn start fails", () => { @@ -369,13 +366,13 @@ describe("chatReducer", () => { it("ignores stale turn start failures after the turn is already running", () => { let state = chatStateFixture(); state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } }); - state = withChatStateMessageStreamItems(state, [message("existing")]); + state = withChatStateThreadStreamItems(state, [message("existing")]); const next = chatReducer(state, { type: "turn/start-failed", items: [] }); expect(chatTurnBusy(next)).toBe(true); expect(activeTurnId(next)).toBe("turn"); - expect(chatStateMessageStreamItems(next)).toEqual([message("existing")]); + expect(chatStateThreadStreamItems(next)).toEqual([message("existing")]); }); it("clears turn-scoped requests when clearing the local turn scope", () => { @@ -384,7 +381,7 @@ describe("chatReducer", () => { state = chatStateWith(state, { requests: { approvals: [approval(1)] } }); state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } }); state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "draft"]]) } }); - state = withChatStateMessageStreamItems(state, [message("kept")]); + state = withChatStateThreadStreamItems(state, [message("kept")]); state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } }); state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["kept:details"]) } } }); @@ -395,7 +392,7 @@ describe("chatReducer", () => { expect(next.requests.approvals).toEqual([]); expect(next.requests.pendingUserInputs).toEqual([]); expect(next.requests.userInputDrafts.size).toBe(0); - expect(chatStateMessageStreamItems(next)).toEqual([message("kept")]); + expect(chatStateThreadStreamItems(next)).toEqual([message("kept")]); expect([...next.ui.disclosures.approvalDetails]).toEqual([]); expect([...next.ui.disclosures.textDetails]).toEqual(["kept:details"]); }); @@ -412,14 +409,14 @@ describe("chatReducer", () => { ]), }, }); - state = withChatStateMessageStreamItems(state, [message("existing")]); + state = withChatStateThreadStreamItems(state, [message("existing")]); state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } }); state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["existing:details"]) } } }); const withoutResult = chatReducer(state, { type: "request/resolved", requestId: 1 }); expect(withoutResult.requests.approvals).toEqual([]); expect(withoutResult.requests.pendingUserInputs).toEqual([userInput(2)]); - expect(chatStateMessageStreamItems(withoutResult)).toEqual([message("existing")]); + expect(chatStateThreadStreamItems(withoutResult)).toEqual([message("existing")]); expect([...withoutResult.ui.disclosures.approvalDetails]).toEqual([]); expect([...withoutResult.ui.disclosures.textDetails]).toEqual(["existing:details"]); @@ -427,18 +424,18 @@ describe("chatReducer", () => { const withResult = chatReducer(withoutResult, { type: "request/resolved", requestId: 2, resultItem }); expect(withResult.requests.pendingUserInputs).toEqual([]); expect(withResult.requests.userInputDrafts.size).toBe(0); - expect(chatStateMessageStreamItems(withResult)).toEqual([message("existing"), resultItem]); + expect(chatStateThreadStreamItems(withResult)).toEqual([message("existing"), resultItem]); expect([...withResult.ui.disclosures.textDetails]).toEqual(["existing:details"]); }); it("ignores stale request resolutions without appending result items", () => { let state = chatStateFixture(); - state = withChatStateMessageStreamItems(state, [message("existing")]); + state = withChatStateThreadStreamItems(state, [message("existing")]); state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["existing:details"]) } } }); const next = chatReducer(state, { type: "request/resolved", requestId: 99, resultItem: message("stale result") }); - expect(chatStateMessageStreamItems(next)).toEqual([message("existing")]); + expect(chatStateThreadStreamItems(next)).toEqual([message("existing")]); expect([...next.ui.disclosures.textDetails]).toEqual(["existing:details"]); }); @@ -449,7 +446,7 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "turn/start-acknowledged", turnId: "completed-turn", - items: [{ id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "completed-turn" }], + items: [{ id: "local-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello", turnId: "completed-turn" }], }); expect(chatTurnBusy(next)).toBe(false); @@ -460,8 +457,8 @@ describe("chatReducer", () => { const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] }; let state = chatStateFixture(); state = chatStateWith(state, { turn: { lifecycle: { kind: "starting", pendingTurnStart: pending } } }); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }, ]); const next = chatReducer(state, { @@ -473,7 +470,7 @@ describe("chatReducer", () => { expect(chatTurnBusy(next)).toBe(true); expect(pendingTurnStart(next)).toEqual(pending); - expect(chatStateMessageStreamItems(next)).toEqual(chatStateMessageStreamItems(state)); + expect(chatStateThreadStreamItems(next)).toEqual(chatStateThreadStreamItems(state)); }); it("keeps toolbar panels mutually exclusive", () => { @@ -685,13 +682,13 @@ describe("chatReducer", () => { it("stores updates through ChatStateStore without mutating the initial snapshot", () => { let initial = chatStateFixture(); - initial = withChatStateMessageStreamItems(initial, [message("initial")]); + initial = withChatStateThreadStreamItems(initial, [message("initial")]); const store = createChatStateStore(initial); - store.dispatch({ type: "message-stream/item-upserted", item: message("next") }); + store.dispatch({ type: "thread-stream/item-upserted", item: message("next") }); - expect(chatStateMessageStreamItems(initial)).toEqual([message("initial")]); - expect(chatStateMessageStreamItems(store.getState())).toEqual([message("initial"), message("next")]); + expect(chatStateThreadStreamItems(initial)).toEqual([message("initial")]); + expect(chatStateThreadStreamItems(store.getState())).toEqual([message("initial"), message("next")]); }); it("keeps panel-local thread, request, and composer state isolated across stores", () => { @@ -755,8 +752,8 @@ describe("chatReducer", () => { }); }); -function message(id: string): MessageStreamItem { - return { id, kind: "message", role: "assistant", text: id, messageKind: "assistantResponse", messageState: "completed" }; +function message(id: string): ThreadStreamItem { + return { id, kind: "dialogue", role: "assistant", text: id, dialogueKind: "assistantResponse", dialogueState: "completed" }; } function threadScopedResidue(options: { threadId?: string; turnId?: string; draft?: string; messageId?: string } = {}): ChatState { @@ -769,7 +766,7 @@ function threadScopedResidue(options: { threadId?: string; turnId?: string; draf active: { collaborationMode: "plan" }, pending: { collaborationMode: setCollaborationModeIntent("plan") }, }, - messageStream: { + threadStream: { historyCursor: "cursor", loadingHistory: true, turnDiffs: new Map([[turnId, "@@"]]), @@ -794,16 +791,16 @@ function threadScopedResidue(options: { threadId?: string; turnId?: string; draf goalEditor: { kind: "editing", threadId, objectiveDraft: "draft", tokenBudgetDraft: null }, }, }); - state = withChatStateMessageStreamItems(state, [message(options.messageId ?? "previous-message")]); + state = withChatStateThreadStreamItems(state, [message(options.messageId ?? "previous-message")]); return state; } -function expectThreadScopeReset(state: ChatState, options: { items: readonly MessageStreamItem[] }): void { +function expectThreadScopeReset(state: ChatState, options: { items: readonly ThreadStreamItem[] }): void { expect(activeTurnId(state)).toBeNull(); - expect(chatStateMessageStreamItems(state)).toEqual(options.items); - expect(state.messageStream.turnDiffs.size).toBe(0); - expect(state.messageStream.historyCursor).toBeNull(); - expect(state.messageStream.loadingHistory).toBe(false); + expect(chatStateThreadStreamItems(state)).toEqual(options.items); + expect(state.threadStream.turnDiffs.size).toBe(0); + expect(state.threadStream.historyCursor).toBeNull(); + expect(state.threadStream.loadingHistory).toBe(false); expect(state.requests.approvals).toEqual([]); expect(state.requests.pendingUserInputs).toEqual([]); expect(state.requests.userInputDrafts.size).toBe(0); diff --git a/tests/features/chat/application/state/thread-stream.test.ts b/tests/features/chat/application/state/thread-stream.test.ts new file mode 100644 index 00000000..e99912c4 --- /dev/null +++ b/tests/features/chat/application/state/thread-stream.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + initialChatThreadStreamState, + threadStreamRollbackCandidate, + threadStreamTurnsAfterTurnId, + threadStreamWithActiveTurnItems, +} from "../../../../../src/features/chat/application/state/thread-stream"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; + +describe("thread stream selectors", () => { + it("counts turns after a turn id from thread stream state", () => { + const state = initialChatThreadStreamState(items()); + + expect(threadStreamTurnsAfterTurnId(state, "turn-1")).toBe(2); + expect(threadStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); + expect(threadStreamTurnsAfterTurnId(state, "turn-3")).toBe(0); + expect(threadStreamTurnsAfterTurnId(state, "missing")).toBeNull(); + }); + + it("includes the active segment when counting turns", () => { + const state = threadStreamWithActiveTurnItems(initialChatThreadStreamState(items()), "turn-3", items()); + + expect(threadStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); + }); + + it("selects the latest turn user message for rollback restoration", () => { + const state = initialChatThreadStreamState(items()); + + expect(threadStreamRollbackCandidate(state)).toEqual({ turnId: "turn-3", itemId: "u3", text: "third" }); + }); + + it("uses the semantic prompt instead of steering messages for rollback restoration", () => { + const state = initialChatThreadStreamState([ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "initial", turnId: "turn-1" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "steer", turnId: "turn-1", clientId: "local-steer-1" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "turn-1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]); + + expect(threadStreamRollbackCandidate(state)).toEqual({ turnId: "turn-1", itemId: "u1", text: "initial" }); + }); + + it("restores the raw user message text instead of rendered display text", () => { + const state = initialChatThreadStreamState([ + { + id: "u1", + kind: "dialogue", + dialogueKind: "user", + role: "user", + text: "Use `$obsidian-codex-panel-maintain`.", + copyText: "Use $obsidian-codex-panel-maintain.", + turnId: "turn-1", + }, + ]); + + expect(threadStreamRollbackCandidate(state)).toEqual({ + turnId: "turn-1", + itemId: "u1", + text: "Use $obsidian-codex-panel-maintain.", + }); + }); + + it("returns null when rollback has no user message candidate", () => { + expect(threadStreamRollbackCandidate(initialChatThreadStreamState([]))).toBeNull(); + expect( + threadStreamRollbackCandidate(initialChatThreadStreamState([{ id: "system", kind: "system", role: "system", text: "Idle" }])), + ).toBeNull(); + expect( + threadStreamRollbackCandidate( + initialChatThreadStreamState([ + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "answer", + turnId: "turn-1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]), + ), + ).toBeNull(); + }); +}); + +function items(): ThreadStreamItem[] { + return [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "first", turnId: "turn-1" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "first answer", + turnId: "turn-1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + { id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" }, + { + id: "a2", + kind: "dialogue", + role: "assistant", + text: "second answer", + turnId: "turn-2", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "third", turnId: "turn-3" }, + { + id: "a3", + kind: "dialogue", + role: "assistant", + text: "third answer", + turnId: "turn-3", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; +} diff --git a/tests/features/chat/application/threads/auto-title-coordinator.test.ts b/tests/features/chat/application/threads/auto-title-coordinator.test.ts index 0cf01e65..270341d2 100644 --- a/tests/features/chat/application/threads/auto-title-coordinator.test.ts +++ b/tests/features/chat/application/threads/auto-title-coordinator.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { Thread } from "../../../../../src/domain/threads/model"; import type { ThreadTitleContext } from "../../../../../src/domain/threads/title-generation-model"; -import { messageStreamItems } from "../../../../../src/features/chat/application/state/message-stream"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { threadStreamItems } from "../../../../../src/features/chat/application/state/thread-stream"; import { type AutoTitleCoordinator, type AutoTitleCoordinatorHost, createAutoTitleCoordinator, } from "../../../../../src/features/chat/application/threads/auto-title-coordinator"; -import { threadTitleContextFromMessageStreamItems } from "../../../../../src/features/chat/application/threads/title-context"; +import { threadTitleContextFromThreadStreamItems } from "../../../../../src/features/chat/application/threads/title-context"; import { createThreadOperations } from "../../../../../src/features/threads/workflows/thread-operations"; import { createThreadTitleService } from "../../../../../src/features/threads/workflows/thread-title-service"; import { DEFAULT_SETTINGS } from "../../../../../src/settings/model"; @@ -25,16 +25,16 @@ describe("AutoTitleCoordinator", () => { generateThreadTitle, }); stateStore.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "Visible streamed request.", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Visible streamed request.", turnId: "turn" }, { id: "a1", - kind: "message", - messageKind: "assistantResponse", + kind: "dialogue", + dialogueKind: "assistantResponse", role: "assistant", text: "Visible streamed response.", - messageState: "completed", + dialogueState: "completed", turnId: "turn", }, ], @@ -61,16 +61,16 @@ describe("AutoTitleCoordinator", () => { generateThreadTitle, }); stateStore.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "Please diagnose auto naming.", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Please diagnose auto naming.", turnId: "turn" }, { id: "a1", - kind: "message", - messageKind: "assistantResponse", + kind: "dialogue", + dialogueKind: "assistantResponse", role: "assistant", text: "I found the regression.", - messageState: "completed", + dialogueState: "completed", turnId: "turn", }, ], @@ -168,7 +168,7 @@ function coordinatorFixture( withClient: async (operation) => operation(currentClient()), }, visibleCompletedTurnContext: (turnId) => - threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)), + threadTitleContextFromThreadStreamItems(turnId, threadStreamItems(stateStore.getState().threadStream)), generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"), }); const host = { diff --git a/tests/features/chat/application/threads/history-controller.test.ts b/tests/features/chat/application/threads/history-controller.test.ts index a860cbc9..8e3f3821 100644 --- a/tests/features/chat/application/threads/history-controller.test.ts +++ b/tests/features/chat/application/threads/history-controller.test.ts @@ -5,10 +5,10 @@ import type { ThreadHistoryPage, ThreadHistoryTransport, } from "../../../../../src/features/chat/application/threads/thread-loading-transport"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { deferred } from "../../../../support/async"; -import { chatStateMessageStreamItems } from "../../support/message-stream"; import { chatStateFixture, chatStateWith } from "../../support/state"; +import { chatStateThreadStreamItems } from "../../support/thread-stream"; describe("HistoryController", () => { it("keeps the latest history load when an older request resolves later", async () => { @@ -26,8 +26,8 @@ describe("HistoryController", () => { first.resolve(historyPage([], "first-cursor")); await firstLoad; - expect(stateStore.getState().messageStream.historyCursor).toBe("second-cursor"); - expect(stateStore.getState().messageStream.loadingHistory).toBe(false); + expect(stateStore.getState().threadStream.historyCursor).toBe("second-cursor"); + expect(stateStore.getState().threadStream.loadingHistory).toBe(false); }); it("ignores a history load that is invalidated while pending", async () => { @@ -37,15 +37,15 @@ describe("HistoryController", () => { }); const loading = loader.loadLatest(); - expect(stateStore.getState().messageStream.loadingHistory).toBe(true); + expect(stateStore.getState().threadStream.loadingHistory).toBe(true); loader.invalidate(); pending.resolve(historyPage([message("assistant", "Stale")], "stale-cursor")); await loading; - expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]); - expect(stateStore.getState().messageStream.historyCursor).toBeNull(); - expect(stateStore.getState().messageStream.loadingHistory).toBe(false); + expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]); + expect(stateStore.getState().threadStream.historyCursor).toBeNull(); + expect(stateStore.getState().threadStream.loadingHistory).toBe(false); expect(addSystemMessage).not.toHaveBeenCalled(); }); @@ -57,10 +57,10 @@ describe("HistoryController", () => { expect(applied).toBe(true); expect(readHistoryPage).not.toHaveBeenCalled(); - expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([ + expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([ expect.objectContaining({ id: "assistant", text: "Ready", turnId: "turn" }), ]); - expect(stateStore.getState().messageStream.historyCursor).toBe("older"); + expect(stateStore.getState().threadStream.historyCursor).toBe("older"); expect(showLatestPageAtBottom).toHaveBeenCalledOnce(); }); @@ -70,22 +70,22 @@ describe("HistoryController", () => { const applied = loader.applyLatestPage("other", historyPage([message("assistant", "Stale")], "older")); expect(applied).toBe(false); - expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]); - expect(stateStore.getState().messageStream.historyCursor).toBeNull(); + expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]); + expect(stateStore.getState().threadStream.historyCursor).toBeNull(); }); - it("loads older history without coupling message stream replacement to bottom pin state", async () => { + it("loads older history without coupling thread stream replacement to bottom pin state", async () => { const readHistoryPage = vi.fn().mockResolvedValue(historyPage([message("older", "Older")], "next")); const { loader, stateStore, dispatch, showLatestPageAtBottom } = historyFixture({ readHistoryPage }); - stateStore.dispatch({ type: "message-stream/items-replaced", items: [message("current", "Current")], historyCursor: "cursor" }); + stateStore.dispatch({ type: "thread-stream/items-replaced", items: [message("current", "Current")], historyCursor: "cursor" }); await loader.loadOlder(); expect(readHistoryPage).toHaveBeenCalledWith("thread", "cursor", 20); - expect(chatStateMessageStreamItems(stateStore.getState()).map((item) => item.id)).toEqual(["older", "current"]); - expect(stateStore.getState().messageStream.historyCursor).toBe("next"); + expect(chatStateThreadStreamItems(stateStore.getState()).map((item) => item.id)).toEqual(["older", "current"]); + expect(stateStore.getState().threadStream.historyCursor).toBe("next"); expect(showLatestPageAtBottom).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "message-stream/items-replaced" })); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "thread-stream/items-replaced" })); }); it("clears loading state when the history transport has no page", async () => { @@ -95,8 +95,8 @@ describe("HistoryController", () => { await loader.loadLatest(); expect(readHistoryPage).toHaveBeenCalledWith("thread", null, 20); - expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]); - expect(stateStore.getState().messageStream.loadingHistory).toBe(false); + expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]); + expect(stateStore.getState().threadStream.loadingHistory).toBe(false); expect(setThreadTurnPresence).not.toHaveBeenCalled(); expect(addSystemMessage).not.toHaveBeenCalled(); }); @@ -124,18 +124,18 @@ function historyFixture(options: { readHistoryPage: ReturnType 0 }; } -function message(id: string, text: string): MessageStreamItem { +function message(id: string, text: string): ThreadStreamItem { return { id, - kind: "message" as const, + kind: "dialogue" as const, role: "assistant" as const, text, - messageKind: "assistantResponse" as const, - messageState: "completed" as const, + dialogueKind: "assistantResponse" as const, + dialogueState: "completed" as const, turnId: "turn", }; } diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 412a995b..32689ff6 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -13,7 +13,7 @@ import type { ThreadResumeSnapshot, ThreadResumeTransport, } from "../../../../../src/features/chat/application/threads/thread-loading-transport"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; function activation(threadId: string, overrides: Partial = {}): ThreadResumeSnapshot { return { @@ -229,14 +229,14 @@ function panelThread(id: string): PanelThread { }; } -function historyPage(items: MessageStreamItem[], nextCursor: string | null): ThreadHistoryPage { +function historyPage(items: ThreadStreamItem[], nextCursor: string | null): ThreadHistoryPage { return { items, nextCursor, hadTurns: items.length > 0 }; } -function message(id: string, text: string, role: "assistant" | "user"): MessageStreamItem { +function message(id: string, text: string, role: "assistant" | "user"): ThreadStreamItem { return role === "user" - ? { id, kind: "message", role, text, messageKind: "user", turnId: "turn" } - : { id, kind: "message", role, text, messageKind: "assistantResponse", messageState: "completed", turnId: "turn" }; + ? { id, kind: "dialogue", role, text, dialogueKind: "user", turnId: "turn" } + : { id, kind: "dialogue", role, text, dialogueKind: "assistantResponse", dialogueState: "completed", turnId: "turn" }; } function tokenUsageFixture(inputTokens: number): ThreadTokenUsage { diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index 6a8a5ab5..e5e8a47b 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -12,10 +12,10 @@ import type { ThreadMutationTransport, ThreadRollbackSnapshot, } from "../../../../../src/features/chat/application/threads/thread-mutation-transport"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { deferred, waitForAsyncWork } from "../../../../support/async"; -import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream"; import { chatStateFixture } from "../../support/state"; +import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; interface ThreadMutationTransportMock { compactThread: Mock; @@ -345,7 +345,7 @@ describe("thread management actions", () => { approvalsReviewer: null, }); host.stateStore.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false, @@ -355,9 +355,9 @@ describe("thread management actions", () => { await controller.rollbackThread("source"); expect(host.threadTransport.rollbackThread).toHaveBeenCalledWith("source"); - expect(chatStateMessageStreamItems(host.stateStore.getState()).slice(0, 2)).toMatchObject([ - { kind: "message", role: "user", text: "kept prompt", turnId: "kept-turn" }, - { kind: "message", role: "assistant", text: "kept answer", turnId: "kept-turn" }, + expect(chatStateThreadStreamItems(host.stateStore.getState()).slice(0, 2)).toMatchObject([ + { kind: "dialogue", role: "user", text: "kept prompt", turnId: "kept-turn" }, + { kind: "dialogue", role: "assistant", text: "kept answer", turnId: "kept-turn" }, ]); expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage)); expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.refreshAfterThreadMutation)); @@ -383,7 +383,7 @@ describe("thread management actions", () => { approvalsReviewer: null, }); host.stateStore.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false, @@ -441,7 +441,7 @@ describe("thread management actions", () => { approvalsReviewer: null, }); host.stateStore.dispatch({ - type: "message-stream/items-replaced", + type: "thread-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false, @@ -456,52 +456,52 @@ describe("thread management actions", () => { }); }); -function turnItems(): MessageStreamItem[] { +function turnItems(): ThreadStreamItem[] { return [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "one", turnId: "turn-1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "one", turnId: "turn-1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "one answer", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "two", turnId: "turn-2" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "two", turnId: "turn-2" }, { id: "a2", - kind: "message", + kind: "dialogue", role: "assistant", text: "two answer", turnId: "turn-2", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, - { id: "u3", kind: "message", messageKind: "user", role: "user", text: "three", turnId: "turn-3" }, + { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "three", turnId: "turn-3" }, { id: "a3", - kind: "message", + kind: "dialogue", role: "assistant", text: "three answer", turnId: "turn-3", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; } -function rollbackItems(): MessageStreamItem[] { +function rollbackItems(): ThreadStreamItem[] { return [ - { id: "kept-user", kind: "message", messageKind: "user", role: "user", text: "kept prompt", turnId: "kept-turn" }, + { id: "kept-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "kept prompt", turnId: "kept-turn" }, { id: "kept-agent", - kind: "message", + kind: "dialogue", role: "assistant", text: "kept answer", turnId: "kept-turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; } @@ -515,11 +515,11 @@ function hostMock({ operations: operationOverrides = {}, threadTransport: transportOverrides = {}, }: { - items: MessageStreamItem[]; + items: ThreadStreamItem[]; operations?: Partial; threadTransport?: Partial; }): ThreadManagementActionsHostMock { - const state = withChatStateMessageStreamItems(chatStateFixture(), items); + const state = withChatStateThreadStreamItems(chatStateFixture(), items); const stateStore = createChatStateStore(state); const threadTransport: ThreadMutationTransportMock = { compactThread: vi.fn().mockResolvedValue(true), diff --git a/tests/features/chat/application/threads/title-context.test.ts b/tests/features/chat/application/threads/title-context.test.ts index 5258b5ed..e02ade87 100644 --- a/tests/features/chat/application/threads/title-context.test.ts +++ b/tests/features/chat/application/threads/title-context.test.ts @@ -3,23 +3,23 @@ import { describe, expect, it } from "vitest"; import { - firstThreadTitleContextFromMessageStreamItems, - threadTitleContextFromMessageStreamItems, + firstThreadTitleContextFromThreadStreamItems, + threadTitleContextFromThreadStreamItems, } from "../../../../../src/features/chat/application/threads/title-context"; describe("chat thread title context", () => { - it("extracts title context from streamed message stream items when completed turn items are not loaded", () => { + it("extracts title context from streamed thread stream items when completed turn items are not loaded", () => { expect( - threadTitleContextFromMessageStreamItems("turn", [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "自動命名を直したい", turnId: "turn" }, + threadTitleContextFromThreadStreamItems("turn", [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "自動命名を直したい", turnId: "turn" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "原因を直しました。", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]), ).toEqual({ @@ -30,27 +30,27 @@ describe("chat thread title context", () => { it("uses the first usable displayed turn as a resumed-history fallback", () => { expect( - firstThreadTitleContextFromMessageStreamItems([ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "本文だけのturn", turnId: "turn-1" }, - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "履歴から命名したい", turnId: "turn-2" }, + firstThreadTitleContextFromThreadStreamItems([ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "本文だけのturn", turnId: "turn-1" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "履歴から命名したい", turnId: "turn-2" }, { id: "a2", - kind: "message", + kind: "dialogue", role: "assistant", text: "表示済み履歴から候補を作ります。", turnId: "turn-2", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, - { id: "u3", kind: "message", messageKind: "user", role: "user", text: "後続turn", turnId: "turn-3" }, + { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "後続turn", turnId: "turn-3" }, { id: "a3", - kind: "message", + kind: "dialogue", role: "assistant", text: "後続応答", turnId: "turn-3", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]), ).toEqual({ @@ -61,7 +61,7 @@ describe("chat thread title context", () => { it("uses a preceding goal event objective when the first completed turn has no user item", () => { expect( - threadTitleContextFromMessageStreamItems("turn", [ + threadTitleContextFromThreadStreamItems("turn", [ { id: "goal", kind: "goal", @@ -72,12 +72,12 @@ describe("chat thread title context", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "ゴール内容に基づいて実装しました。", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]), ).toEqual({ diff --git a/tests/features/chat/application/conversation/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts similarity index 99% rename from tests/features/chat/application/conversation/composer-submit-actions.test.ts rename to tests/features/chat/application/turns/composer-submit-actions.test.ts index 544d2ae9..27ee39e7 100644 --- a/tests/features/chat/application/conversation/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { submitComposer } from "../../../../../src/features/chat/application/conversation/composer-submit-actions"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { submitComposer } from "../../../../../src/features/chat/application/turns/composer-submit-actions"; function thread(id: string): Thread { return { diff --git a/tests/features/chat/application/conversation/composition.test.ts b/tests/features/chat/application/turns/composition.test.ts similarity index 89% rename from tests/features/chat/application/conversation/composition.test.ts rename to tests/features/chat/application/turns/composition.test.ts index 393ff683..d1424681 100644 --- a/tests/features/chat/application/conversation/composition.test.ts +++ b/tests/features/chat/application/turns/composition.test.ts @@ -3,21 +3,21 @@ import { describe, expect, it, vi } from "vitest"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; import type { ComposerInputSnapshot } from "../../../../../src/features/chat/application/composer/input-snapshot"; -import { createConversationTurnActions } from "../../../../../src/features/chat/application/conversation/composition"; import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import { createTurnWorkflowActions } from "../../../../../src/features/chat/application/turns/composition"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; -const planItem = (id: string): MessageStreamItem => ({ +const planItem = (id: string): ThreadStreamItem => ({ id, - kind: "message", + kind: "dialogue", role: "assistant", text: "Plan", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", }); function thread(id: string): Thread { @@ -31,7 +31,7 @@ function thread(id: string): Thread { }; } -function resumeThread(stateStore: ReturnType, items: readonly MessageStreamItem[]): void { +function resumeThread(stateStore: ReturnType, items: readonly ThreadStreamItem[]): void { stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -51,7 +51,7 @@ function resumeThread(stateStore: ReturnType, items stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" }); } -describe("createConversationTurnActions", () => { +describe("createTurnWorkflowActions", () => { it("does not attach composer-only active file context when implementing a plan", async () => { const stateStore = createChatStateStore(createChatState()); const plan = planItem("plan"); @@ -76,7 +76,7 @@ describe("createConversationTurnActions", () => { { type: "mention", name: "unexpected", path: "notes/Alpha.md" }, ], })); - const actions = createConversationTurnActions( + const actions = createTurnWorkflowActions( { stateStore, localItemIds: createLocalIdSource(), diff --git a/tests/features/chat/application/conversation/optimistic-turn-start.test.ts b/tests/features/chat/application/turns/optimistic-turn-start.test.ts similarity index 89% rename from tests/features/chat/application/conversation/optimistic-turn-start.test.ts rename to tests/features/chat/application/turns/optimistic-turn-start.test.ts index fff65d91..14b8faa2 100644 --- a/tests/features/chat/application/conversation/optimistic-turn-start.test.ts +++ b/tests/features/chat/application/turns/optimistic-turn-start.test.ts @@ -6,8 +6,8 @@ import { localUserMessageItemFromInput, optimisticTurnStart, shouldAcknowledgeTurnStart, -} from "../../../../../src/features/chat/application/conversation/optimistic-turn-start"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +} from "../../../../../src/features/chat/application/turns/optimistic-turn-start"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; describe("optimistic turn start helpers", () => { it("builds optimistic turn starts from immutable input snapshots", () => { @@ -21,8 +21,8 @@ describe("optimistic turn start helpers", () => { expect(start.pendingTurnStart).toEqual({ anchorItemId: "local-user", promptSubmitHookItemIds: [] }); expect(start.item).toMatchObject({ id: "local-user", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "hello [[Note]]", mentionedFiles: [{ name: "Note", path: "Note.md" }], @@ -133,10 +133,17 @@ describe("optimistic turn start helpers", () => { }); it("attaches acknowledged turn ids and pending hook runs immutably", () => { - const items: MessageStreamItem[] = [ + const items: ThreadStreamItem[] = [ localUserMessage("local-user", "hello"), hookItem("hook-1"), - { id: "assistant", kind: "message", role: "assistant", text: "working", messageKind: "assistantResponse", messageState: "completed" }, + { + id: "assistant", + kind: "dialogue", + role: "assistant", + text: "working", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, ]; const next = acknowledgeOptimisticTurnStart({ @@ -154,7 +161,7 @@ describe("optimistic turn start helpers", () => { }); it("removes optimistic user and pending hook items after start failure", () => { - const items: MessageStreamItem[] = [localUserMessage("local-user", "hello"), hookItem("hook-1"), hookItem("keep")]; + const items: ThreadStreamItem[] = [localUserMessage("local-user", "hello"), hookItem("hook-1"), hookItem("keep")]; expect( cleanupFailedTurnStart({ @@ -167,11 +174,11 @@ describe("optimistic turn start helpers", () => { }); }); -function localUserMessage(id: string, text: string): MessageStreamItem { +function localUserMessage(id: string, text: string): ThreadStreamItem { return localUserMessageItemFromInput({ id, text, codexInput: [{ type: "text", text }] }); } -function hookItem(id: string): MessageStreamItem { +function hookItem(id: string): ThreadStreamItem { return { id, kind: "hook", diff --git a/tests/features/chat/application/conversation/plan-implementation.test.ts b/tests/features/chat/application/turns/plan-implementation.test.ts similarity index 89% rename from tests/features/chat/application/conversation/plan-implementation.test.ts rename to tests/features/chat/application/turns/plan-implementation.test.ts index f9d2418a..212df46c 100644 --- a/tests/features/chat/application/conversation/plan-implementation.test.ts +++ b/tests/features/chat/application/turns/plan-implementation.test.ts @@ -1,34 +1,33 @@ import { describe, expect, it, vi } from "vitest"; - +import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { implementPlan, implementPlanTargetFromState, type PlanImplementationHost, -} from "../../../../../src/features/chat/application/conversation/plan-implementation"; -import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; -import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +} from "../../../../../src/features/chat/application/turns/plan-implementation"; import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; -const planItem = (id: string): MessageStreamItem => ({ +const planItem = (id: string): ThreadStreamItem => ({ id, - kind: "message", + kind: "dialogue", role: "assistant", text: "Plan", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", }); -const streamingPlanItem = (id: string): MessageStreamItem => ({ +const streamingPlanItem = (id: string): ThreadStreamItem => ({ id, - kind: "message", + kind: "dialogue", role: "assistant", text: "Plan", - messageKind: "proposedPlan", - messageState: "streaming", + dialogueKind: "proposedPlan", + dialogueState: "streaming", }); -function resumeThread(stateStore: ChatStateStore, items: readonly MessageStreamItem[]): void { +function resumeThread(stateStore: ChatStateStore, items: readonly ThreadStreamItem[]): void { stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, diff --git a/tests/features/chat/application/conversation/runtime-event-plan.test.ts b/tests/features/chat/application/turns/runtime-event-plan.test.ts similarity index 50% rename from tests/features/chat/application/conversation/runtime-event-plan.test.ts rename to tests/features/chat/application/turns/runtime-event-plan.test.ts index 06b160ad..204e8d13 100644 --- a/tests/features/chat/application/conversation/runtime-event-plan.test.ts +++ b/tests/features/chat/application/turns/runtime-event-plan.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { planConversationRuntimeEvents } from "../../../../../src/features/chat/application/conversation/runtime-event-plan"; -import type { ConversationRuntimeEvent } from "../../../../../src/features/chat/application/conversation/runtime-events"; import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream"; +import { planTurnRuntimeEvents } from "../../../../../src/features/chat/application/turns/runtime-event-plan"; +import type { TurnRuntimeEvent } from "../../../../../src/features/chat/application/turns/runtime-events"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { chatStateFixture, chatStateWith } from "../../support/state"; +import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; function activeRunningState(): ChatState { let state = chatStateFixture(); @@ -16,35 +16,33 @@ function applyActions(state: ChatState, actions: readonly ChatAction[]): ChatSta return actions.reduce(chatReducer, state); } -describe("ConversationRuntimeEvent planner", () => { - it("reports run starts as conversation-owned outcomes", () => { +describe("TurnRuntimeEvent planner", () => { + it("reports turn starts as turn-runtime outcomes", () => { const state = chatStateWith(chatStateFixture(), { activeThread: { id: "thread-active" } }); - const plan = planConversationRuntimeEvents(state, [ - { type: "runStarted", threadId: "thread-active", runId: "turn-active", recencyAt: 123 }, - ]); + const plan = planTurnRuntimeEvents(state, [{ type: "turnStarted", threadId: "thread-active", turnId: "turn-active", recencyAt: 123 }]); - expect(plan.outcomes).toEqual([{ type: "run-started", threadId: "thread-active", runId: "turn-active", recencyAt: 123 }]); + expect(plan.outcomes).toEqual([{ type: "turn-started", threadId: "thread-active", turnId: "turn-active", recencyAt: 123 }]); }); - it("reconciles completed run snapshots with optimistic local user messages", () => { + it("reconciles completed turn snapshots with optimistic local user messages", () => { let state = activeRunningState(); - state = withChatStateMessageStreamItems(state, [ - { id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "turn-active" }, + state = withChatStateThreadStreamItems(state, [ + { id: "local-user-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello", turnId: "turn-active" }, ]); - const events: ConversationRuntimeEvent[] = [ + const events: TurnRuntimeEvent[] = [ { - type: "runCompleted", + type: "turnCompleted", threadId: "thread-active", - runId: "turn-active", + turnId: "turn-active", status: "completed", completedSummary: { userText: "hello", assistantText: "done" }, completedItems: [ { id: "u1", sourceItemId: "u1", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "hello", clientId: "local-user-1", @@ -53,26 +51,26 @@ describe("ConversationRuntimeEvent planner", () => { { id: "a1", sourceItemId: "a1", - kind: "message", - messageKind: "assistantResponse", + kind: "dialogue", + dialogueKind: "assistantResponse", role: "assistant", text: "done", - messageState: "completed", + dialogueState: "completed", turnId: "turn-active", }, ], }, ]; - const plan = planConversationRuntimeEvents(state, events); + const plan = planTurnRuntimeEvents(state, events); const next = applyActions(state, plan.actions); - expect(chatStateMessageStreamItems(next).map((item) => item.id)).toEqual(["u1", "a1"]); + expect(chatStateThreadStreamItems(next).map((item) => item.id)).toEqual(["u1", "a1"]); expect(plan.outcomes).toEqual([ { - type: "run-completed", + type: "turn-completed", threadId: "thread-active", - runId: "turn-active", + turnId: "turn-active", completedSummary: { userText: "hello", assistantText: "done" }, }, ]); @@ -80,11 +78,11 @@ describe("ConversationRuntimeEvent planner", () => { it("upserts structured auto-review results without dropping unrelated stream items", () => { let state = activeRunningState(); - state = withChatStateMessageStreamItems(state, [ - { id: "m1", kind: "message", messageKind: "assistantResponse", role: "assistant", text: "working", messageState: "completed" }, + state = withChatStateThreadStreamItems(state, [ + { id: "m1", kind: "dialogue", dialogueKind: "assistantResponse", role: "assistant", text: "working", dialogueState: "completed" }, { id: "warning-1", kind: "reviewResult", role: "tool", text: "Auto-review warning", executionState: "completed" }, ]); - const item: MessageStreamItem = { + const item: ThreadStreamItem = { id: "review-1", kind: "reviewResult", role: "tool", @@ -93,9 +91,9 @@ describe("ConversationRuntimeEvent planner", () => { executionState: "completed", }; - const plan = planConversationRuntimeEvents(state, [{ type: "autoReviewUpdated", item }]); + const plan = planTurnRuntimeEvents(state, [{ type: "autoReviewUpdated", item }]); const next = applyActions(state, plan.actions); - expect(chatStateMessageStreamItems(next).map((streamItem) => streamItem.id)).toEqual(["m1", "review-1"]); + expect(chatStateThreadStreamItems(next).map((streamItem) => streamItem.id)).toEqual(["m1", "review-1"]); }); }); diff --git a/tests/features/chat/application/conversation/slash-command-execution.test.ts b/tests/features/chat/application/turns/slash-command-execution.test.ts similarity index 99% rename from tests/features/chat/application/conversation/slash-command-execution.test.ts rename to tests/features/chat/application/turns/slash-command-execution.test.ts index 354c06f0..1c970107 100644 --- a/tests/features/chat/application/conversation/slash-command-execution.test.ts +++ b/tests/features/chat/application/turns/slash-command-execution.test.ts @@ -6,7 +6,7 @@ import { slashCommandHelpSections } from "../../../../../src/features/chat/appli import { executeSlashCommand, type SlashCommandExecutionContext, -} from "../../../../../src/features/chat/application/conversation/slash-command-execution"; +} from "../../../../../src/features/chat/application/turns/slash-command-execution"; function context(overrides: Partial = {}): SlashCommandExecutionContext { return { diff --git a/tests/features/chat/application/conversation/slash-command-executor.test.ts b/tests/features/chat/application/turns/slash-command-executor.test.ts similarity index 98% rename from tests/features/chat/application/conversation/slash-command-executor.test.ts rename to tests/features/chat/application/turns/slash-command-executor.test.ts index e3c99864..2b053b31 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/turns/slash-command-executor.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; +import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { executeSlashCommandWithState, type SlashCommandExecutorHost, -} from "../../../../../src/features/chat/application/conversation/slash-command-executor"; -import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; -import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +} from "../../../../../src/features/chat/application/turns/slash-command-executor"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; diff --git a/tests/features/chat/application/conversation/turn-state.test.ts b/tests/features/chat/application/turns/turn-state.test.ts similarity index 99% rename from tests/features/chat/application/conversation/turn-state.test.ts rename to tests/features/chat/application/turns/turn-state.test.ts index 0e6a7cf8..16ba72b9 100644 --- a/tests/features/chat/application/conversation/turn-state.test.ts +++ b/tests/features/chat/application/turns/turn-state.test.ts @@ -5,7 +5,7 @@ import { type ChatTurnLifecycleState, type PendingTurnStart, transitionChatTurnLifecycleState, -} from "../../../../../src/features/chat/application/conversation/turn-state"; +} from "../../../../../src/features/chat/application/turns/turn-state"; describe("chat turn lifecycle state machine", () => { it.each([ diff --git a/tests/features/chat/application/conversation/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts similarity index 95% rename from tests/features/chat/application/conversation/turn-submission-actions.test.ts rename to tests/features/chat/application/turns/turn-submission-actions.test.ts index ec292ec1..48d07e1a 100644 --- a/tests/features/chat/application/conversation/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { optimisticTurnStart } from "../../../../../src/features/chat/application/conversation/optimistic-turn-start"; -import { - createTurnSubmissionActions, - type TurnSubmissionActionsHost, -} from "../../../../../src/features/chat/application/conversation/turn-submission-actions"; import { createLocalIdSource } from "../../../../../src/features/chat/application/local-id-source"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import { chatStateMessageStreamItems } from "../../support/message-stream"; +import { optimisticTurnStart } from "../../../../../src/features/chat/application/turns/optimistic-turn-start"; +import { + createTurnSubmissionActions, + type TurnSubmissionActionsHost, +} from "../../../../../src/features/chat/application/turns/turn-submission-actions"; +import { chatStateThreadStreamItems } from "../../support/thread-stream"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; @@ -142,8 +142,8 @@ describe("TurnSubmissionActions", () => { ], clientUserMessageId: expect.any(String), }); - expect(chatStateMessageStreamItems(stateStore.getState())[0]).toMatchObject({ - kind: "message", + expect(chatStateThreadStreamItems(stateStore.getState())[0]).toMatchObject({ + kind: "dialogue", text: "fix [[notes/Alpha]] (L42:C5-L47:C1)", mentionedFiles: [{ name: "Alpha", path: "notes/Alpha.md" }], }); @@ -220,8 +220,8 @@ describe("TurnSubmissionActions", () => { expect(host.setStatus).toHaveBeenCalledWith("Steered current turn."); const localSteerId = steerTurn.mock.calls[0]?.[0].clientUserMessageId; expect( - chatStateMessageStreamItems(stateStore.getState()).some( - (item) => item.kind === "message" && item.id === localSteerId && item.text === "follow up", + chatStateThreadStreamItems(stateStore.getState()).some( + (item) => item.kind === "dialogue" && item.id === localSteerId && item.text === "follow up", ), ).toBe(true); }); @@ -281,7 +281,7 @@ describe("TurnSubmissionActions", () => { expect(startTurn).not.toHaveBeenCalled(); expect(host.setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); expect(host.setStatus).not.toHaveBeenCalledWith("Steered current turn."); - expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]); + expect(chatStateThreadStreamItems(stateStore.getState())).toEqual([]); }); it("does not restore stale steer drafts or report stale steer failures after the active turn changes", async () => { diff --git a/tests/features/chat/domain/message-stream/completed-turn-reconciliation.test.ts b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts similarity index 83% rename from tests/features/chat/domain/message-stream/completed-turn-reconciliation.test.ts rename to tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts index a50fa90a..6c9ba01a 100644 --- a/tests/features/chat/domain/message-stream/completed-turn-reconciliation.test.ts +++ b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "vitest"; -import { reconcileCompletedTurnItems } from "../../../../../src/features/chat/domain/message-stream/completed-turn-reconciliation"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import { reconcileCompletedTurnItems } from "../../../../../src/features/chat/domain/thread-stream/completed-turn-reconciliation"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; describe("reconcileCompletedTurnItems", () => { it("replaces optimistic local user messages with server user messages that share the client id", () => { - const currentItems: MessageStreamItem[] = [ + const currentItems: ThreadStreamItem[] = [ userMessage("local-user-1", "same text", "turn", "local-user-1"), userMessage("local-steer-2", "steer", "turn", "local-steer-2"), userMessage("local-user-2", "other turn", "other", "local-user-2"), ]; - const turnItems: MessageStreamItem[] = [ + const turnItems: ThreadStreamItem[] = [ userMessage("u1", "same text", "turn", "local-user-1"), userMessage("u2", "steer", "turn", "local-steer-2"), assistantMessage("a1", "done", "turn"), @@ -22,11 +22,11 @@ describe("reconcileCompletedTurnItems", () => { }); it("falls back to local user text only when server user messages have no client ids", () => { - const currentItems: MessageStreamItem[] = [ + const currentItems: ThreadStreamItem[] = [ userMessage("local-user-without-client-id", "fallback text", "turn"), userMessage("local-user-other-turn", "fallback text", "other"), ]; - const turnItems: MessageStreamItem[] = [userMessage("u1", "fallback text", "turn")]; + const turnItems: ThreadStreamItem[] = [userMessage("u1", "fallback text", "turn")]; const next = reconcileCompletedTurnItems({ currentItems, completedTurnId: "turn", turnItems }); @@ -80,19 +80,19 @@ function permutations(items: readonly T[]): T[][] { return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)]).map((tail) => [item, ...tail])); } -function reconciliationCase(currentItems: readonly MessageStreamItem[], turnItems: readonly MessageStreamItem[]): string { +function reconciliationCase(currentItems: readonly ThreadStreamItem[], turnItems: readonly ThreadStreamItem[]): string { return `current=${currentIds(currentItems)} turn=${currentIds(turnItems)}`; } -function currentIds(items: readonly MessageStreamItem[]): string { +function currentIds(items: readonly ThreadStreamItem[]): string { return items.map((item) => item.id).join(","); } -function userMessage(id: string, text: string, turnId: string, clientId?: string): MessageStreamItem { +function userMessage(id: string, text: string, turnId: string, clientId?: string): ThreadStreamItem { return { id, - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text, copyText: text, @@ -101,14 +101,14 @@ function userMessage(id: string, text: string, turnId: string, clientId?: string }; } -function assistantMessage(id: string, text: string, turnId: string): MessageStreamItem { +function assistantMessage(id: string, text: string, turnId: string): ThreadStreamItem { return { id, - kind: "message", - messageKind: "assistantResponse", + kind: "dialogue", + dialogueKind: "assistantResponse", role: "assistant", text, - messageState: "completed", + dialogueState: "completed", turnId, }; } diff --git a/tests/features/chat/domain/message-stream/factories/goal-items.test.ts b/tests/features/chat/domain/thread-stream/factories/goal-items.test.ts similarity index 91% rename from tests/features/chat/domain/message-stream/factories/goal-items.test.ts rename to tests/features/chat/domain/thread-stream/factories/goal-items.test.ts index 319d8ba1..b124dade 100644 --- a/tests/features/chat/domain/message-stream/factories/goal-items.test.ts +++ b/tests/features/chat/domain/thread-stream/factories/goal-items.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import type { ThreadGoal } from "../../../../../../src/domain/threads/goal"; -import { goalChangeItem } from "../../../../../../src/features/chat/domain/message-stream/factories/goal-items"; +import { goalChangeItem } from "../../../../../../src/features/chat/domain/thread-stream/factories/goal-items"; -describe("goal message stream items", () => { +describe("goal thread stream items", () => { it("keeps goal event summaries compact while retaining the full objective in details", () => { const objective = `Ship ${"the feature ".repeat(20)}`.trim(); const item = goalChangeItem("goal", null, goal({ objective })); diff --git a/tests/features/chat/domain/message-stream/selectors.test.ts b/tests/features/chat/domain/thread-stream/selectors.test.ts similarity index 51% rename from tests/features/chat/domain/message-stream/selectors.test.ts rename to tests/features/chat/domain/thread-stream/selectors.test.ts index 588cebee..77b39f95 100644 --- a/tests/features/chat/domain/message-stream/selectors.test.ts +++ b/tests/features/chat/domain/thread-stream/selectors.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import { forkCandidatesFromItems } from "../../../../../src/features/chat/domain/message-stream/selectors"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { forkCandidatesFromItems } from "../../../../../src/features/chat/domain/thread-stream/selectors"; -describe("message stream item selectors", () => { +describe("thread stream item selectors", () => { it("selects final assistant messages as fork candidates", () => { - const streamItems = messageStreamItems(); + const streamItems = threadStreamItems(); const candidates = forkCandidatesFromItems(streamItems); @@ -16,46 +16,46 @@ describe("message stream item selectors", () => { }); }); -function messageStreamItems(): MessageStreamItem[] { +function threadStreamItems(): ThreadStreamItem[] { return [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "first", turnId: "turn-1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "first", turnId: "turn-1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "first answer", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, { id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" }, { id: "a2-delta", - kind: "message", + kind: "dialogue", role: "assistant", text: "draft", turnId: "turn-2", - messageKind: "proposedPlan", - messageState: "streaming", + dialogueKind: "proposedPlan", + dialogueState: "streaming", }, { id: "a2", - kind: "message", + kind: "dialogue", role: "assistant", text: "second answer", turnId: "turn-2", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, - { id: "u3", kind: "message", messageKind: "user", role: "user", text: "third", turnId: "turn-3" }, + { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "third", turnId: "turn-3" }, { id: "a3", - kind: "message", + kind: "dialogue", role: "assistant", text: "third answer", turnId: "turn-3", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]; } diff --git a/tests/features/chat/domain/message-stream/semantics/classification.test.ts b/tests/features/chat/domain/thread-stream/semantics/classification.test.ts similarity index 70% rename from tests/features/chat/domain/message-stream/semantics/classification.test.ts rename to tests/features/chat/domain/thread-stream/semantics/classification.test.ts index 10a7b725..0d4afd92 100644 --- a/tests/features/chat/domain/message-stream/semantics/classification.test.ts +++ b/tests/features/chat/domain/thread-stream/semantics/classification.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from "vitest"; import type { TurnItem } from "../../../../../../src/app-server/protocol/turn"; -import { streamingFileChangeMessageStreamItem } from "../../../../../../src/features/chat/app-server/mappers/message-stream/file-changes"; -import { messageStreamItemFromTurnItem } from "../../../../../../src/features/chat/app-server/mappers/message-stream/turn-items"; -import type { MessageStreamItem } from "../../../../../../src/features/chat/domain/message-stream/items"; -import { messageStreamReasoningIsActive } from "../../../../../../src/features/chat/domain/message-stream/semantics/active-turn"; -import { messageStreamSemanticClassifications } from "../../../../../../src/features/chat/domain/message-stream/semantics/classify"; -import { messageStreamIsAutoReviewDecision } from "../../../../../../src/features/chat/domain/message-stream/semantics/predicates"; +import { streamingFileChangeThreadStreamItem } from "../../../../../../src/features/chat/app-server/mappers/thread-stream/file-changes"; +import { threadStreamItemFromTurnItem } from "../../../../../../src/features/chat/app-server/mappers/thread-stream/turn-items"; +import type { ThreadStreamItem } from "../../../../../../src/features/chat/domain/thread-stream/items"; +import { threadStreamReasoningIsActive } from "../../../../../../src/features/chat/domain/thread-stream/semantics/active-turn"; +import { threadStreamSemanticClassifications } from "../../../../../../src/features/chat/domain/thread-stream/semantics/classify"; +import { threadStreamIsAutoReviewDecision } from "../../../../../../src/features/chat/domain/thread-stream/semantics/predicates"; -describe("message stream semantic classification", () => { +describe("thread stream semantic classification", () => { it("separates dialogue meaning from turn placement", () => { - const semantic = messageStreamSemanticClassifications([ + const semantic = threadStreamSemanticClassifications([ userMessage("u1", "do it", "turn"), userMessage("u2", "also check tests", "turn"), { id: "a1", - kind: "message", - messageKind: "assistantResponse", - messageState: "completed", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", role: "assistant", text: "done", turnId: "turn", @@ -37,7 +37,7 @@ describe("message stream semantic classification", () => { }); it("uses local steer provenance before falling back to turn order", () => { - const semantic = messageStreamSemanticClassifications([ + const semantic = threadStreamSemanticClassifications([ userMessage("u1", "do it", "turn", "local-user-1"), userMessage("u2", "also check tests", "turn", "local-steer-1"), userMessage("u3", "and docs", "turn", null), @@ -56,7 +56,7 @@ describe("message stream semantic classification", () => { }); it("does not treat local user client ids as steering by prefix alone", () => { - const semantic = messageStreamSemanticClassifications([ + const semantic = threadStreamSemanticClassifications([ userMessage("u1", "do it", "turn", "local-user-1"), userMessage("u2", "new turn", "next-turn", "local-user-2"), ]); @@ -64,8 +64,8 @@ describe("message stream semantic classification", () => { expect(semantic.map(({ placement }) => ("turnRole" in placement ? placement.turnRole : null))).toEqual(["initiator", "initiator"]); }); - it("classifies message stream item meaning independently from payload shape", () => { - const semantic = messageStreamSemanticClassifications([ + it("classifies thread stream item meaning independently from payload shape", () => { + const semantic = threadStreamSemanticClassifications([ commandItem("cmd"), { id: "patch", @@ -92,13 +92,8 @@ describe("message stream semantic classification", () => { }); it("classifies streaming file change patch status as lifecycle state", () => { - const [semantic] = messageStreamSemanticClassifications([ - streamingFileChangeMessageStreamItem( - "patch", - "turn", - [{ kind: "update", path: "src/main.ts", diff: "@@\n-old\n+new" }], - "inProgress", - ), + const [semantic] = threadStreamSemanticClassifications([ + streamingFileChangeThreadStreamItem("patch", "turn", [{ kind: "update", path: "src/main.ts", diff: "@@\n-old\n+new" }], "inProgress"), ]); expect(semantic).toMatchObject({ @@ -108,7 +103,7 @@ describe("message stream semantic classification", () => { }); it("classifies thread and interaction events by meaning", () => { - const semantic = messageStreamSemanticClassifications([ + const semantic = threadStreamSemanticClassifications([ { id: "goal", kind: "goal", role: "tool", text: "set: Ship it", action: "set" }, { id: "approval", @@ -142,7 +137,7 @@ describe("message stream semantic classification", () => { }); it("classifies automatic review results as permission decisions", () => { - const semantic = messageStreamSemanticClassifications([ + const semantic = threadStreamSemanticClassifications([ { id: "parsed-review", kind: "reviewResult", @@ -163,25 +158,25 @@ describe("message stream semantic classification", () => { { plane: "permission", event: "decision" }, { plane: "permission", event: "decision" }, ]); - expect(semantic.map(messageStreamIsAutoReviewDecision)).toEqual([true, true]); + expect(semantic.map(threadStreamIsAutoReviewDecision)).toEqual([true, true]); }); it("marks completed proposed plans as implementable turn outcomes", () => { - const [draft, completed] = messageStreamSemanticClassifications([ + const [draft, completed] = threadStreamSemanticClassifications([ { id: "draft", - kind: "message", - messageKind: "proposedPlan", - messageState: "streaming", + kind: "dialogue", + dialogueKind: "proposedPlan", + dialogueState: "streaming", role: "assistant", text: "draft", turnId: "turn", }, { id: "plan", - kind: "message", - messageKind: "proposedPlan", - messageState: "completed", + kind: "dialogue", + dialogueKind: "proposedPlan", + dialogueState: "completed", role: "assistant", text: "plan", turnId: "turn", @@ -203,8 +198,8 @@ describe("message stream semantic classification", () => { }); it("classifies sub-agent execution summaries as coordination progress", () => { - const item = messageStreamItemFromTurnItem(collabAgentToolCall(), "turn"); - const [classification] = messageStreamSemanticClassifications(item ? [item] : []); + const item = threadStreamItemFromTurnItem(collabAgentToolCall(), "turn"); + const [classification] = threadStreamSemanticClassifications(item ? [item] : []); expect(classification).toMatchObject({ provenance: { source: "appServer", channel: "turnItem", itemType: "collabAgentToolCall", itemId: "agent-1" }, @@ -214,24 +209,24 @@ describe("message stream semantic classification", () => { }); it("marks only the latest unfinished active-turn reasoning item as active", () => { - const firstReasoning: MessageStreamItem = { id: "r1", kind: "reasoning", role: "tool", text: "first", turnId: "turn" }; - const latestReasoning: MessageStreamItem = { id: "r2", kind: "reasoning", role: "tool", text: "latest", turnId: "turn" }; - const otherTurnReasoning: MessageStreamItem = { id: "r3", kind: "reasoning", role: "tool", text: "other", turnId: "other" }; + const firstReasoning: ThreadStreamItem = { id: "r1", kind: "reasoning", role: "tool", text: "first", turnId: "turn" }; + const latestReasoning: ThreadStreamItem = { id: "r2", kind: "reasoning", role: "tool", text: "latest", turnId: "turn" }; + const otherTurnReasoning: ThreadStreamItem = { id: "r3", kind: "reasoning", role: "tool", text: "other", turnId: "other" }; const context = { activeTurnId: "turn", items: [firstReasoning, latestReasoning, otherTurnReasoning] }; - expect(messageStreamReasoningIsActive(firstReasoning, context)).toBe(false); - expect(messageStreamReasoningIsActive(latestReasoning, context)).toBe(true); - expect(messageStreamReasoningIsActive(otherTurnReasoning, context)).toBe(false); - expect(messageStreamReasoningIsActive({ ...latestReasoning, executionState: "completed" }, context)).toBe(false); + expect(threadStreamReasoningIsActive(firstReasoning, context)).toBe(false); + expect(threadStreamReasoningIsActive(latestReasoning, context)).toBe(true); + expect(threadStreamReasoningIsActive(otherTurnReasoning, context)).toBe(false); + expect(threadStreamReasoningIsActive({ ...latestReasoning, executionState: "completed" }, context)).toBe(false); }); }); -function userMessage(id: string, text: string, turnId: string, clientId?: string | null): MessageStreamItem { - return { id, kind: "message", messageKind: "user", role: "user", text, turnId, ...(clientId ? { clientId } : {}) }; +function userMessage(id: string, text: string, turnId: string, clientId?: string | null): ThreadStreamItem { + return { id, kind: "dialogue", dialogueKind: "user", role: "user", text, turnId, ...(clientId ? { clientId } : {}) }; } -function commandItem(id: string): MessageStreamItem { +function commandItem(id: string): ThreadStreamItem { return { id, kind: "command", diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 48e2be2f..d4f18df5 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -13,8 +13,8 @@ import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/co import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/session/deferred-work"; import { createChatPanelSessionGraph } from "../../../../src/features/chat/host/session-graph"; import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller"; -import { createChatMessageStreamScrollBinding } from "../../../../src/features/chat/panel/message-stream-scroll-binding"; -import { MessageStreamPresenter } from "../../../../src/features/chat/panel/surface/message-stream-presenter"; +import { ThreadStreamPresenter } from "../../../../src/features/chat/panel/surface/thread-stream-presenter"; +import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding"; import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model"; import { waitForAsyncWork } from "../../../support/async"; import { installObsidianDomShims } from "../../../support/dom"; @@ -236,7 +236,7 @@ describe("createChatPanelSessionGraph actions", () => { }); it("disposes presenter and composer resources from the graph action", () => { - const disposePresenter = vi.spyOn(MessageStreamPresenter.prototype, "dispose").mockImplementation(() => undefined); + const disposePresenter = vi.spyOn(ThreadStreamPresenter.prototype, "dispose").mockImplementation(() => undefined); const disposeComposer = vi.spyOn(ChatComposerController.prototype, "dispose").mockImplementation(() => undefined); const { graph } = sessionGraphFixture(); @@ -264,7 +264,7 @@ describe("createChatPanelSessionGraph actions", () => { deferredTasks, resumeWork, connectionWork, - messageScrollBinding: createChatMessageStreamScrollBinding(), + threadStreamScrollBinding: createChatThreadStreamScrollBinding(), getClosing: () => false, viewWindow: () => window, }); diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index 14857387..c9ff1ee1 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -52,7 +52,7 @@ function turnBundleFixture(options: { stateStore?: ReturnType { - it("composes toolbar, goal, message stream, and composer in one Preact root", async () => { + it("composes toolbar, goal, thread stream, and composer in one Preact root", async () => { const store = createChatStateStore(); const container = document.createElement("div"); document.body.appendChild(container); @@ -55,7 +55,7 @@ describe("ChatPanelShell", () => { await act(async () => { store.dispatch({ type: "composer/draft-set", draft: "ready", clearSuggestions: true }); store.dispatch({ - type: "message-stream/system-item-added", + type: "thread-stream/system-item-added", item: { id: "system-1", kind: "system", role: "system", text: "Model set." }, }); await settleShellEffects(); @@ -133,7 +133,7 @@ describe("ChatPanelShell", () => { }); }); - it("keeps composer rendering off message stream updates until turn presence changes", async () => { + it("keeps composer rendering off thread stream updates until turn presence changes", async () => { const store = createChatStateStore(); const container = document.createElement("div"); document.body.appendChild(container); @@ -149,7 +149,7 @@ describe("ChatPanelShell", () => { await act(async () => { store.dispatch({ - type: "message-stream/system-item-added", + type: "thread-stream/system-item-added", item: { id: "system-1", kind: "system", role: "system", text: "Status only." }, }); await settleShellEffects(); @@ -158,13 +158,13 @@ describe("ChatPanelShell", () => { await act(async () => { store.dispatch({ - type: "message-stream/item-added", + type: "thread-stream/item-added", item: { id: "assistant-1", turnId: "turn-1", - kind: "message", - messageKind: "assistantResponse", - messageState: "completed", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", role: "assistant", text: "Turn response.", }, @@ -193,7 +193,7 @@ describe("ChatPanelShell", () => { await act(async () => { store.dispatch({ type: "turn/optimistic-started", - item: { id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello" }, + item: { id: "local-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }, pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] }, }); await settleShellEffects(); @@ -205,7 +205,7 @@ describe("ChatPanelShell", () => { store.dispatch({ type: "turn/start-acknowledged", turnId: "turn-1", - items: [{ id: "local-user", turnId: "turn-1", kind: "message", messageKind: "user", role: "user", text: "hello" }], + items: [{ id: "local-user", turnId: "turn-1", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" }], }); await settleShellEffects(); }); @@ -242,19 +242,19 @@ describe("ChatPanelShell", () => { }); }); - it("keeps message stream rendering off active thread updates until stream thread fields change", async () => { + it("keeps thread stream rendering off active thread updates until stream thread fields change", async () => { const store = createChatStateStore(); const container = document.createElement("div"); document.body.appendChild(container); const parts = shellParts(); - const renderMessageStreamState = vi.fn(parts.messageStream.renderState.bind(parts.messageStream)); - parts.messageStream.renderState = renderMessageStreamState; + const renderThreadStreamState = vi.fn(parts.threadStream.renderState.bind(parts.threadStream)); + parts.threadStream.renderState = renderThreadStreamState; await act(async () => { renderChatPanelShell(container, { ...shellProps(store), parts }); await settleShellEffects(); }); - renderMessageStreamState.mockClear(); + renderThreadStreamState.mockClear(); await act(async () => { store.dispatch({ @@ -263,19 +263,19 @@ describe("ChatPanelShell", () => { }); await settleShellEffects(); }); - expect(renderMessageStreamState).not.toHaveBeenCalled(); + expect(renderThreadStreamState).not.toHaveBeenCalled(); await act(async () => { store.dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: "thread", open: true }); await settleShellEffects(); }); - expect(renderMessageStreamState).not.toHaveBeenCalled(); + expect(renderThreadStreamState).not.toHaveBeenCalled(); await act(async () => { store.dispatch({ type: "active-thread/cwd-set", cwd: "/workspace" }); await settleShellEffects(); }); - expect(renderMessageStreamState).toHaveBeenCalledTimes(1); + expect(renderThreadStreamState).toHaveBeenCalledTimes(1); await act(async () => { unmountChatPanelShell(container); @@ -418,19 +418,19 @@ function shellParts( actions: toolbarActionsFixture(), }, goal: surface.goal, - messageStream: { + threadStream: { renderState: (model) => { void model.activeThreadCwd.value; return { - blocks: messageStreamViewBlocks({ + blocks: threadStreamViewBlocks({ activeThreadId: model.activeThreadId.value, activeTurnId: null, historyCursor: model.historyCursor.value, loadingHistory: model.loadingHistory.value, items: model.items.value, }), - context: testMessageStreamContext, - scrollPortBinding: noOpMessageStreamScrollPortBinding, + context: testThreadStreamContext, + scrollPortBinding: noOpThreadStreamScrollPortBinding, }; }, }, @@ -541,7 +541,7 @@ function composerProjectionFixture(_model: ChatPanelComposerReadModel) { }; } -const testMessageStreamContext: MessageStreamContext = { +const testThreadStreamContext: ThreadStreamContext = { activeThreadId: "thread", workspaceRoot: "/vault", loadOlderTurns: () => undefined, @@ -590,7 +590,7 @@ function surfaceFixture(options: { toolbarConnected?: () => boolean; goalSendSho }; } -const noOpMessageStreamScrollPortBinding: MessageStreamScrollPortBinding = { +const noOpThreadStreamScrollPortBinding: ThreadStreamScrollPortBinding = { mountScrollPort: () => () => undefined, }; @@ -603,7 +603,7 @@ function toolbarActionsFixture(): ChatPanelShellParts["toolbar"]["actions"] { }, chat: { startNewThread: vi.fn(), - compactConversation: vi.fn(), + compactContext: vi.fn(), setGoal: vi.fn(), }, status: { diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 3bb2c1bd..bb7f6b41 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -18,9 +18,9 @@ import { ChatPanelToolbar } from "../../../../../src/features/chat/panel/surface import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; import { installObsidianDomShims } from "../../../../support/dom"; -import { withChatStateMessageStreamItems } from "../../support/message-stream"; import { composerReadModelFromChatState } from "../../support/shell-read-model"; import { chatStateFixture, chatStateWith } from "../../support/state"; +import { withChatStateThreadStreamItems } from "../../support/thread-stream"; installObsidianDomShims(); @@ -202,13 +202,13 @@ describe("chat panel surface projections", () => { it("uses a neutral composer context indicator when usage is unavailable", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-1" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "item", turnId: "turn-1", - kind: "message", - messageKind: "assistantResponse", - messageState: "completed", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "Existing turn", role: "assistant", }, @@ -473,7 +473,7 @@ function toolbarActionsFixture(overrides: ToolbarActionOverrides = {}): ToolbarA }, chat: { startNewThread: () => undefined, - compactConversation: () => undefined, + compactContext: () => undefined, setGoal: () => undefined, }, status: { diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts similarity index 78% rename from tests/features/chat/panel/surface/message-stream-presenter.test.ts rename to tests/features/chat/panel/surface/thread-stream-presenter.test.ts index 3c3f15e3..99e60e81 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts @@ -6,39 +6,39 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { ThreadStreamPresenter } from "../../../../../src/features/chat/panel/surface/thread-stream-presenter"; import { - type ChatMessageStreamScrollBinding, - createChatMessageStreamScrollBinding, -} from "../../../../../src/features/chat/panel/message-stream-scroll-binding"; -import { MessageStreamPresenter } from "../../../../../src/features/chat/panel/surface/message-stream-presenter"; + type ChatThreadStreamSurfaceContext, + threadStreamSurfaceProjectionFromModel, +} from "../../../../../src/features/chat/panel/surface/thread-stream-projection"; import { - type ChatMessageStreamSurfaceContext, - messageStreamSurfaceProjectionFromModel, -} from "../../../../../src/features/chat/panel/surface/message-stream-projection"; -import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer.obsidian"; -import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/stream-blocks"; + type ChatThreadStreamScrollBinding, + createChatThreadStreamScrollBinding, +} from "../../../../../src/features/chat/panel/thread-stream-scroll-binding"; +import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian"; +import { ThreadStreamViewport } from "../../../../../src/features/chat/ui/thread-stream/stream-blocks"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; import { notices } from "../../../../mocks/obsidian"; import { installObsidianDomShims } from "../../../../support/dom"; -import { withChatStateMessageStreamItems } from "../../support/message-stream"; -import { messageStreamReadModelFromChatState } from "../../support/shell-read-model"; +import { threadStreamReadModelFromChatState } from "../../support/shell-read-model"; import { chatStateFixture, chatStateWith } from "../../support/state"; -import { installMessageViewportMetrics, pendingApproval } from "../../ui/message-stream/test-helpers"; +import { withChatStateThreadStreamItems } from "../../support/thread-stream"; +import { installMessageViewportMetrics, pendingApproval } from "../../ui/thread-stream/test-helpers"; const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96; installObsidianDomShims(); -function renderMessageStreamPresenter(parent: HTMLElement, presenter: MessageStreamPresenter, state: ChatState): void { - renderUiRoot(parent, h(MessageStreamViewport, { state: presenter.renderState(messageStreamReadModelFromChatState(state)) })); +function renderThreadStreamPresenter(parent: HTMLElement, presenter: ThreadStreamPresenter, state: ChatState): void { + renderUiRoot(parent, h(ThreadStreamViewport, { state: presenter.renderState(threadStreamReadModelFromChatState(state)) })); } -describe("MessageStreamPresenter scroll pinning", () => { +describe("ThreadStreamPresenter scroll pinning", () => { beforeEach(() => { notices.length = 0; }); - it("projects reducer state into message stream view state", () => { + it("projects reducer state into thread stream view state", () => { const store = createChatStateStore(chatStateFixture()); store.dispatch({ type: "active-thread/resumed", @@ -56,9 +56,9 @@ describe("MessageStreamPresenter scroll pinning", () => { approvalsReviewer: null, }); - const projection = messageStreamSurfaceProjectionFromModel( - messageStreamReadModelFromChatState(store.getState()), - testMessageStreamSurfaceContext({ + const projection = threadStreamSurfaceProjectionFromModel( + threadStreamReadModelFromChatState(store.getState()), + testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: (action) => { store.dispatch(action); @@ -73,29 +73,29 @@ describe("MessageStreamPresenter scroll pinning", () => { expect(projection.context.forkMenuItemId).toBeNull(); }); - it("wires message stream disclosure actions through the surface context", () => { + it("wires thread stream disclosure actions through the surface context", () => { const store = createChatStateStore(chatStateFixture()); - const surfaceContext = testMessageStreamSurfaceContext({ + const surfaceContext = testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: (action) => { store.dispatch(action); }, }); - const context = messageStreamSurfaceProjectionFromModel(messageStreamReadModelFromChatState(store.getState()), surfaceContext).context; - if (!context.onDisclosureToggle) throw new Error("Expected message stream disclosure action"); + const context = threadStreamSurfaceProjectionFromModel(threadStreamReadModelFromChatState(store.getState()), surfaceContext).context; + if (!context.onDisclosureToggle) throw new Error("Expected thread stream disclosure action"); context.onDisclosureToggle("textDetails", "message:details", true); expect(store.getState().ui.disclosures.textDetails.has("message:details")).toBe(true); }); - it("projects pending requests from the captured message stream state", () => { + it("projects pending requests from the captured thread stream state", () => { let state = chatStateFixture(); - state = withChatStateMessageStreamItems(state, [{ id: "system", kind: "system", role: "system", text: "Waiting for approval." }]); + state = withChatStateThreadStreamItems(state, [{ id: "system", kind: "system", role: "system", text: "Waiting for approval." }]); state = chatStateWith(state, { requests: { approvals: [pendingApproval()] } }); - const projection = messageStreamSurfaceProjectionFromModel( - messageStreamReadModelFromChatState(state), - testMessageStreamSurfaceContext({ + const projection = threadStreamSurfaceProjectionFromModel( + threadStreamReadModelFromChatState(state), + testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: () => undefined, }), @@ -227,21 +227,21 @@ describe("MessageStreamPresenter scroll pinning", () => { it("pins to the scroll container bottom without aligning the last message element", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Streaming message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = messageStreamPresenter(state); + const { presenter, scrollPortBinding } = threadStreamPresenter(state); - renderMessageStreamPresenter(parent, presenter, state); + renderThreadStreamPresenter(parent, presenter, state); const messages = messageViewport(parent); Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true }); installMessageViewportMetrics(messages, { clientHeight: 100 }); @@ -259,19 +259,19 @@ describe("MessageStreamPresenter scroll pinning", () => { it("repins after composer growth has changed the scroll viewport height", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Streaming message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = messageStreamPresenter(state); + const { presenter, scrollPortBinding } = threadStreamPresenter(state); const messages = parent.createDiv({ cls: "codex-panel__messages" }); let scrollTop = 0; @@ -297,7 +297,7 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); messages.scrollTop = 1000; - renderMessageStreamPresenter(messages, presenter, state); + renderThreadStreamPresenter(messages, presenter, state); await settleMessageRender(messages); expect(messages.scrollTop).toBe(0); @@ -310,8 +310,8 @@ describe("MessageStreamPresenter scroll pinning", () => { expect(messages.scrollTop).toBe(0); }); - it("accepts scroll commands when no message stream viewport is mounted", () => { - const { presenter, scrollPortBinding } = messageStreamPresenter(); + it("accepts scroll commands when no thread stream viewport is mounted", () => { + const { presenter, scrollPortBinding } = threadStreamPresenter(); expect(() => { scrollPortBinding.showLatest(); @@ -323,23 +323,23 @@ describe("MessageStreamPresenter scroll pinning", () => { }).not.toThrow(); }); - it("detaches the active scroll port when the message stream unmounts", async () => { + it("detaches the active scroll port when the thread stream unmounts", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Rendered message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = messageStreamPresenter(state); - renderMessageStreamPresenter(parent, presenter, state); + const { presenter, scrollPortBinding } = threadStreamPresenter(state); + renderThreadStreamPresenter(parent, presenter, state); const messages = messageViewport(parent); installMessageViewportMetrics(messages); await settleMessageRender(messages); @@ -355,20 +355,20 @@ describe("MessageStreamPresenter scroll pinning", () => { it("binds scroll commands to the currently mounted message viewport", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Rendered message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = messageStreamPresenter(state); - renderMessageStreamPresenter(parent, presenter, state); + const { presenter, scrollPortBinding } = threadStreamPresenter(state); + renderThreadStreamPresenter(parent, presenter, state); const oldMessages = messageViewport(parent); installMessageViewportMetrics(oldMessages, { clientHeight: 100, scrollHeight: 1000 }); await settleMessageRender(oldMessages); @@ -376,7 +376,7 @@ describe("MessageStreamPresenter scroll pinning", () => { unmountUiRoot(parent); - renderMessageStreamPresenter(parent, presenter, state); + renderThreadStreamPresenter(parent, presenter, state); const newMessages = messageViewport(parent); installMessageViewportMetrics(newMessages, { clientHeight: 100, scrollHeight: 1000 }); await settleMessageRender(newMessages); @@ -389,21 +389,21 @@ describe("MessageStreamPresenter scroll pinning", () => { it("completes bottom pinning after the message viewport commits", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Streaming message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter, scrollPortBinding } = messageStreamPresenter(state, vi.fn(), "/vault"); + const { presenter, scrollPortBinding } = threadStreamPresenter(state, vi.fn(), "/vault"); - renderMessageStreamPresenter(parent, presenter, state); + renderThreadStreamPresenter(parent, presenter, state); const messages = messageViewport(parent); installMessageViewportMetrics(messages, { clientHeight: 100, scrollHeight: 1000 }); scrollPortBinding.showLatest(); @@ -415,23 +415,23 @@ describe("MessageStreamPresenter scroll pinning", () => { it("does not force the bottom into view when the user is reading older messages", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Initial message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter } = messageStreamPresenter(state); + const { presenter } = threadStreamPresenter(state); const messages = parent.createDiv({ cls: "codex-panel__messages" }); installMessageViewportMetrics(messages); - renderMessageStreamPresenter(messages, presenter, state); + renderThreadStreamPresenter(messages, presenter, state); await settleMessageRender(messages); Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); @@ -441,18 +441,18 @@ describe("MessageStreamPresenter scroll pinning", () => { const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); scrollIntoView.mockClear(); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Updated streaming message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); - renderMessageStreamPresenter(messages, presenter, state); + renderThreadStreamPresenter(messages, presenter, state); await settleMessageRender(messages); expect(scrollIntoView).not.toHaveBeenCalled(); @@ -461,25 +461,25 @@ describe("MessageStreamPresenter scroll pinning", () => { it("does not run a pending bottom pin after the user scrolls away", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Streaming message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter } = messageStreamPresenter(state); + const { presenter } = threadStreamPresenter(state); const messages = parent.createDiv({ cls: "codex-panel__messages" }); Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true }); installMessageViewportMetrics(messages, { clientHeight: 100 }); messages.scrollTop = 920; - renderMessageStreamPresenter(messages, presenter, state); + renderThreadStreamPresenter(messages, presenter, state); const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); scrollIntoView.mockClear(); @@ -490,27 +490,27 @@ describe("MessageStreamPresenter scroll pinning", () => { expect(scrollIntoView).not.toHaveBeenCalled(); }); - it("leaves the mounted message stream content in place on dispose", async () => { + it("leaves the mounted thread stream content in place on dispose", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems(state, [ + state = withChatStateThreadStreamItems(state, [ { id: "message", - kind: "message", + kind: "dialogue", role: "assistant", text: "Rendered message", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ]); const parent = document.createElement("div"); - const { presenter } = messageStreamPresenter(state); + const { presenter } = threadStreamPresenter(state); - renderMessageStreamPresenter(parent, presenter, state); + renderThreadStreamPresenter(parent, presenter, state); let messages = messageViewport(parent); installMessageViewportMetrics(messages); - renderMessageStreamPresenter(parent, presenter, state); + renderThreadStreamPresenter(parent, presenter, state); messages = messageViewport(parent); await settleMessageRender(messages); expect(parent.querySelector(".codex-panel__messages")).not.toBeNull(); @@ -520,25 +520,25 @@ describe("MessageStreamPresenter scroll pinning", () => { expect(parent.querySelector(".codex-panel__messages")).not.toBeNull(); }); - it("renders large message streams through the flow viewport", async () => { + it("renders large thread streams through the flow viewport", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); - state = withChatStateMessageStreamItems( + state = withChatStateThreadStreamItems( state, Array.from({ length: 200 }, (_value, index) => ({ id: `message-${String(index)}`, - kind: "message", + kind: "dialogue", role: "assistant", text: `Message ${String(index)}`, turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", })), ); const parent = document.createElement("div"); - const { presenter } = messageStreamPresenter(state); + const { presenter } = threadStreamPresenter(state); - renderMessageStreamPresenter(parent, presenter, state); + renderThreadStreamPresenter(parent, presenter, state); const messages = messageViewport(parent); installMessageViewportMetrics(messages, { clientHeight: 320, scrollHeight: 19_200 }); await settleMessageRender(messages); @@ -548,10 +548,10 @@ describe("MessageStreamPresenter scroll pinning", () => { }); }); -function testMessageStreamSurfaceContext(options: { +function testThreadStreamSurfaceContext(options: { vaultPath: string; dispatch: (action: ChatAction) => void; -}): ChatMessageStreamSurfaceContext { +}): ChatThreadStreamSurfaceContext { return { vaultPath: options.vaultPath, setDisclosureOpen: (bucket, id, open) => { @@ -666,20 +666,20 @@ async function renderedTag( }; } -interface TestMessageStreamPresenter { - presenter: MessageStreamPresenter; - scrollPortBinding: ChatMessageStreamScrollBinding; +interface TestThreadStreamPresenter { + presenter: ThreadStreamPresenter; + scrollPortBinding: ChatThreadStreamScrollBinding; } -function messageStreamPresenter( +function threadStreamPresenter( state = chatStateFixture(), openLinkText = vi.fn(), vaultPath = "/vault", vaultFiles: string[] = [], -): TestMessageStreamPresenter { +): TestThreadStreamPresenter { const files = new Map(vaultFiles.map((path) => [path, tFile(path)])); - const scrollPortBinding = createChatMessageStreamScrollBinding(); - const presenter = new MessageStreamPresenter({ + const scrollPortBinding = createChatThreadStreamScrollBinding(); + const presenter = new ThreadStreamPresenter({ obsidian: { app: { workspace: { diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 273a6493..1342b145 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -10,8 +10,8 @@ import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection"; import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection"; import { createToolbarPanelActions, type ToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions"; -import type { MessageStreamContext } from "../../../../src/features/chat/ui/message-stream/context"; -import type { MessageStreamScrollPortBinding } from "../../../../src/features/chat/ui/message-stream/flow-scroll.measure"; +import type { ThreadStreamContext } from "../../../../src/features/chat/ui/thread-stream/context"; +import type { ThreadStreamScrollPortBinding } from "../../../../src/features/chat/ui/thread-stream/flow-scroll.measure"; import { installObsidianDomShims } from "../../../support/dom"; installObsidianDomShims(); @@ -82,7 +82,7 @@ function shellParts(store: ReturnType, toolbarPanel }, chat: { startNewThread: vi.fn(), - compactConversation: vi.fn(), + compactContext: vi.fn(), setGoal: vi.fn(), }, status: { @@ -109,11 +109,11 @@ function shellParts(store: ReturnType, toolbarPanel }, }, goal: surface.goal, - messageStream: { + threadStream: { renderState: () => ({ blocks: [], - context: testMessageStreamContext, - scrollPortBinding: noOpMessageStreamScrollPortBinding, + context: testThreadStreamContext, + scrollPortBinding: noOpThreadStreamScrollPortBinding, }), }, composer: { @@ -189,11 +189,11 @@ function surfaceFixture( }; } -const noOpMessageStreamScrollPortBinding: MessageStreamScrollPortBinding = { +const noOpThreadStreamScrollPortBinding: ThreadStreamScrollPortBinding = { mountScrollPort: () => () => undefined, }; -const testMessageStreamContext: MessageStreamContext = { +const testThreadStreamContext: ThreadStreamContext = { activeThreadId: "thread", workspaceRoot: "/vault", loadOlderTurns: () => undefined, diff --git a/tests/features/chat/presentation/message-stream/view-model.test.ts b/tests/features/chat/presentation/thread-stream/view-model.test.ts similarity index 72% rename from tests/features/chat/presentation/message-stream/view-model.test.ts rename to tests/features/chat/presentation/thread-stream/view-model.test.ts index 326f8671..39c6fd6a 100644 --- a/tests/features/chat/presentation/message-stream/view-model.test.ts +++ b/tests/features/chat/presentation/thread-stream/view-model.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import { messageStreamViewBlocks } from "../../../../../src/features/chat/presentation/message-stream/view-model"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { threadStreamViewBlocks } from "../../../../../src/features/chat/presentation/thread-stream/view-model"; -describe("message stream presentation blocks", () => { +describe("thread stream presentation blocks", () => { it("keeps the empty state after the history affordance", () => { - const blocks = messageStreamViewBlocks({ + const blocks = threadStreamViewBlocks({ activeThreadId: "thread", activeTurnId: null, historyCursor: "cursor", @@ -16,7 +16,7 @@ describe("message stream presentation blocks", () => { }); it("moves active task progress out of the persisted layout into live blocks", () => { - const blocks = messageStreamViewBlocks({ + const blocks = threadStreamViewBlocks({ activeThreadId: "thread", activeTurnId: "turn", historyCursor: null, @@ -29,7 +29,7 @@ describe("message stream presentation blocks", () => { }); it("anchors active agent summaries at the first active agent item", () => { - const blocks = messageStreamViewBlocks({ + const blocks = threadStreamViewBlocks({ activeThreadId: "thread", activeTurnId: "turn", historyCursor: null, @@ -42,7 +42,7 @@ describe("message stream presentation blocks", () => { }); it("renders unknown item kinds as generic status updates", () => { - const blocks = messageStreamViewBlocks({ + const blocks = threadStreamViewBlocks({ activeThreadId: "thread", activeTurnId: null, historyCursor: null, @@ -55,7 +55,7 @@ describe("message stream presentation blocks", () => { status: "running", output: "raw output", operation: "future operation", - } as unknown as MessageStreamItem, + } as unknown as ThreadStreamItem, ], }); @@ -73,15 +73,15 @@ describe("message stream presentation blocks", () => { }); }); -function userMessage(id: string, turnId: string): MessageStreamItem { - return { id, kind: "message", messageKind: "user", role: "user", text: "run", turnId }; +function userMessage(id: string, turnId: string): ThreadStreamItem { + return { id, kind: "dialogue", dialogueKind: "user", role: "user", text: "run", turnId }; } -function assistantMessage(id: string, turnId: string): MessageStreamItem { - return { id, kind: "message", messageKind: "assistantResponse", messageState: "completed", role: "assistant", text: "done", turnId }; +function assistantMessage(id: string, turnId: string): ThreadStreamItem { + return { id, kind: "dialogue", dialogueKind: "assistantResponse", dialogueState: "completed", role: "assistant", text: "done", turnId }; } -function taskProgressItem(id: string, turnId: string): MessageStreamItem { +function taskProgressItem(id: string, turnId: string): ThreadStreamItem { return { id, kind: "taskProgress", @@ -94,7 +94,7 @@ function taskProgressItem(id: string, turnId: string): MessageStreamItem { }; } -function agentItem(id: string, turnId: string): MessageStreamItem { +function agentItem(id: string, turnId: string): ThreadStreamItem { return { id, kind: "agent", diff --git a/tests/features/chat/support/message-stream.ts b/tests/features/chat/support/message-stream.ts deleted file mode 100644 index 9c58fbc7..00000000 --- a/tests/features/chat/support/message-stream.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { messageStreamItems, messageStreamWithItems } from "../../../../src/features/chat/application/state/message-stream"; -import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer"; -import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items"; -import { chatStateWith } from "./state"; - -export function chatStateMessageStreamItems(state: Pick): readonly MessageStreamItem[] { - return messageStreamItems(state.messageStream); -} - -export function withChatStateMessageStreamItems(state: ChatState, items: readonly MessageStreamItem[]): ChatState { - return chatStateWith(state, { - messageStream: messageStreamWithItems(state.messageStream, items), - }); -} diff --git a/tests/features/chat/support/shell-read-model.ts b/tests/features/chat/support/shell-read-model.ts index 29fc073b..251b0511 100644 --- a/tests/features/chat/support/shell-read-model.ts +++ b/tests/features/chat/support/shell-read-model.ts @@ -5,6 +5,6 @@ export function composerReadModelFromChatState(state: ChatState) { return createChatPanelShellReadModelBinding(state).readModel.composer; } -export function messageStreamReadModelFromChatState(state: ChatState) { - return createChatPanelShellReadModelBinding(state).readModel.messageStream; +export function threadStreamReadModelFromChatState(state: ChatState) { + return createChatPanelShellReadModelBinding(state).readModel.threadStream; } diff --git a/tests/features/chat/support/state.ts b/tests/features/chat/support/state.ts index 37e9ce59..cdb834e7 100644 --- a/tests/features/chat/support/state.ts +++ b/tests/features/chat/support/state.ts @@ -11,7 +11,7 @@ interface ChatStateFixturePatch { activeThread?: Partial; runtime?: RuntimePatch; turn?: Partial; - messageStream?: Partial; + threadStream?: Partial; requests?: Partial; composer?: Partial; ui?: Partial> & { @@ -33,7 +33,7 @@ export function chatStateWith(state: ChatState, patch: ChatStateFixturePatch): C ...(patch.activeThread ? { activeThread: { ...state.activeThread, ...patch.activeThread } } : {}), ...(patch.runtime ? { runtime: runtimeWithPatch(state.runtime, patch.runtime) } : {}), ...(patch.turn ? { turn: { ...state.turn, ...patch.turn } } : {}), - ...(patch.messageStream ? { messageStream: { ...state.messageStream, ...patch.messageStream } } : {}), + ...(patch.threadStream ? { threadStream: { ...state.threadStream, ...patch.threadStream } } : {}), ...(patch.requests ? { requests: { ...state.requests, ...patch.requests } } : {}), ...(patch.composer ? { composer: { ...state.composer, ...patch.composer } } : {}), ...(uiPatch diff --git a/tests/features/chat/support/thread-stream.ts b/tests/features/chat/support/thread-stream.ts new file mode 100644 index 00000000..153ca12f --- /dev/null +++ b/tests/features/chat/support/thread-stream.ts @@ -0,0 +1,14 @@ +import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer"; +import { threadStreamItems, threadStreamWithItems } from "../../../../src/features/chat/application/state/thread-stream"; +import type { ThreadStreamItem } from "../../../../src/features/chat/domain/thread-stream/items"; +import { chatStateWith } from "./state"; + +export function chatStateThreadStreamItems(state: Pick): readonly ThreadStreamItem[] { + return threadStreamItems(state.threadStream); +} + +export function withChatStateThreadStreamItems(state: ChatState, items: readonly ThreadStreamItem[]): ChatState { + return chatStateWith(state, { + threadStream: threadStreamWithItems(state.threadStream, items), + }); +} diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx similarity index 85% rename from tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx rename to tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx index 5d148207..82a69b84 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx @@ -3,52 +3,52 @@ import { MarkdownRenderer } from "obsidian"; import { act } from "preact/test-utils"; import { describe, expect, it, vi } from "vitest"; -import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/conversation/plan-implementation"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/turns/plan-implementation"; import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; -import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-rendered-event.dom"; -import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer.obsidian"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/thread-stream/content-rendered-event.dom"; +import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian"; import { deferred } from "../../../../support/async"; import { attributeValues, textContents, topLevelDetailsSummaries } from "../../../../support/dom"; import "./setup"; import { expectPresent, idleTurnLifecycle, - messageStreamBlocks, renderMessageBlockElement, - renderMessageStreamBlocksInAct, + renderThreadStreamBlocksInAct, runningTurnLifecycle, testDisclosures, + threadStreamBlocks, unmountUiRootInAct, withMessageContentScrollHeight, } from "./test-helpers"; -describe("message stream rendering and message action menu", () => { +describe("thread stream rendering and message action menu", () => { it("inserts completed-turn activity groups between conversation blocks", () => { const parent = document.createElement("div"); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), ); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "hook-1", kind: "hook", @@ -60,12 +60,12 @@ describe("message stream rendering and message action menu", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -84,9 +84,9 @@ describe("message stream rendering and message action menu", () => { it("keeps Work details in the flow when the activity group is collapsed", async () => { const parent = document.createElement("div"); - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "hook-1", kind: "hook", @@ -98,19 +98,19 @@ describe("message stream rendering and message action menu", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "t1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], disclosures: testDisclosures({ activityGroups: ["t1"] }), }); const activityBlock = expectPresent(blocks.find((block) => block.key === "activity:turn-t1-activity")); - renderMessageStreamBlocksInAct(parent, [activityBlock]); + renderThreadStreamBlocksInAct(parent, [activityBlock]); const host = expectPresent(parent.querySelector(".codex-panel__message-block")); const messageFlow = expectPresent(parent.querySelector(".codex-panel__message-flow")); @@ -138,11 +138,11 @@ describe("message stream rendering and message action menu", () => { it("keeps blocks in the flow after their rendered content shrinks on rerender", () => { const parent = document.createElement("div"); - const block = messageStreamBlocks({ - items: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "expanded", turnId: "t1" }], + const block = threadStreamBlocks({ + items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "expanded", turnId: "t1" }], })[0]; - renderMessageStreamBlocksInAct(parent, [block]); + renderThreadStreamBlocksInAct(parent, [block]); const host = expectPresent(parent.querySelector(".codex-panel__message-block")); const messageFlow = expectPresent(parent.querySelector(".codex-panel__message-flow")); @@ -155,9 +155,9 @@ describe("message stream rendering and message action menu", () => { expect(host.style.transform).toBe(""); Object.defineProperty(host, "offsetHeight", { value: 120, configurable: true }); - renderMessageStreamBlocksInAct(parent, [ - messageStreamBlocks({ - items: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "collapsed", turnId: "t1" }], + renderThreadStreamBlocksInAct(parent, [ + threadStreamBlocks({ + items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "collapsed", turnId: "t1" }], })[0], ]); @@ -167,7 +167,7 @@ describe("message stream rendering and message action menu", () => { }); it("renders review result items as compact auto-review tool rows", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command." }], })[0]; @@ -181,7 +181,7 @@ describe("message stream rendering and message action menu", () => { }); it("renders review result details inside one auto-review details block", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ { id: "review-1", @@ -194,7 +194,7 @@ describe("message stream rendering and message action menu", () => { auditFacts: [ { key: "status", value: "approved" }, { key: "action", value: "apply patch" }, - { key: "files", value: "src/shared/ui/detail-view.ts\nsrc/shared/ui/message-stream.ts" }, + { key: "files", value: "src/shared/ui/detail-view.ts\nsrc/shared/ui/thread-stream.ts" }, ], }, }, @@ -211,14 +211,14 @@ describe("message stream rendering and message action menu", () => { expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain( - "filessrc/shared/ui/detail-view.ts\nsrc/shared/ui/message-stream.ts", + "filessrc/shared/ui/detail-view.ts\nsrc/shared/ui/thread-stream.ts", ); expect(textContents(element, ".codex-panel__output-title")).toEqual([]); }); it("renders structured system result details as visible selectable meta rows", () => { const renderMarkdown = vi.fn((parent: HTMLElement, text: string) => parent.createDiv({ text: `markdown:${text}` })); - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ { id: "system-help", @@ -263,8 +263,8 @@ describe("message stream rendering and message action menu", () => { ); }); - it("renders goal events as collapsed tool-like message stream items", () => { - const block = messageStreamBlocks({ + it("renders goal events as collapsed tool-like thread stream items", () => { + const block = threadStreamBlocks({ items: [ { id: "goal-1", @@ -292,19 +292,19 @@ describe("message stream rendering and message action menu", () => { it("renders rollback action only for the eligible user message", () => { const onRollback = vi.fn(); const items = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "older", turnId: "turn-1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "older answer", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "latest", turnId: "turn-2" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "latest", turnId: "turn-2" }, ] as const; - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [...items], textActionTargetsByItemId: new Map([["u2", { rollback: true }]]), onRollback, @@ -322,18 +322,18 @@ describe("message stream rendering and message action menu", () => { it("renders copy actions for copyable messages", () => { const copyText = vi.fn(); - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "rendered user", copyText: "**user**", turnId: "turn-1" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "rendered user", copyText: "**user**", turnId: "turn-1" }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "rendered answer", copyText: "# Answer", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], copyText, @@ -354,18 +354,18 @@ describe("message stream rendering and message action menu", () => { it("expands assistant fork actions in the copy action region and defaults repeat clicks to plain fork", () => { const onForkMenuToggle = vi.fn(); const onFork = vi.fn(); - const item: MessageStreamItem = { + const item: ThreadStreamItem = { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "answer", copyText: "answer", turnId: "turn-1", }; - const closedBlock = messageStreamBlocks({ + const closedBlock = threadStreamBlocks({ items: [item], onForkMenuToggle, copyText: vi.fn(), @@ -381,7 +381,7 @@ describe("message stream rendering and message action menu", () => { initialFork.click(); expect(onForkMenuToggle).toHaveBeenCalledWith("a1"); - const openBlock = messageStreamBlocks({ + const openBlock = threadStreamBlocks({ items: [item], forkMenuItemId: "a1", onForkMenuToggle, @@ -404,17 +404,17 @@ describe("message stream rendering and message action menu", () => { it("runs fork and archive from the expanded assistant fork actions", () => { const onFork = vi.fn(); - const item: MessageStreamItem = { + const item: ThreadStreamItem = { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "answer", copyText: "answer", turnId: "turn-1", }; - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [item], forkMenuItemId: "a1", onForkMenuToggle: vi.fn(), @@ -435,19 +435,19 @@ describe("message stream rendering and message action menu", () => { renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text: `markdown:${text}` }), }; - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "**answer** [[Note]]", turnId: "turn-1", - messageKind: "proposedPlan", - messageState: "streaming", + dialogueKind: "proposedPlan", + dialogueState: "streaming", }, ], }), @@ -455,19 +455,19 @@ describe("message stream rendering and message action menu", () => { expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("**answer** [[Note]]"); expect(parent.querySelector(".codex-panel__message-content a")).toBeNull(); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "**answer**", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -486,19 +486,19 @@ describe("message stream rendering and message action menu", () => { renderMarkdown, }; - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "first", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -507,19 +507,19 @@ describe("message stream rendering and message action menu", () => { "markdown:first", ); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "second", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -556,19 +556,19 @@ describe("message stream rendering and message action menu", () => { }, }; - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "old", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -577,19 +577,19 @@ describe("message stream rendering and message action menu", () => { const content = expectPresent(parent.querySelector(".codex-panel__message-content")); expect(content.textContent).toBe("rendered:old"); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "new", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -622,19 +622,19 @@ describe("message stream rendering and message action menu", () => { renderStreamMarkdown, }; - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "old", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "streaming", + dialogueKind: "assistantResponse", + dialogueState: "streaming", }, ], }), @@ -661,9 +661,9 @@ describe("message stream rendering and message action menu", () => { vaultPath: "/vault", }); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ renderObsidianMarkdown: (element: HTMLElement, text: string) => { markdownRenderer.renderObsidianMarkdown(element, text); }, @@ -671,12 +671,12 @@ describe("message stream rendering and message action menu", () => { items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "**done**", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], }), @@ -728,10 +728,10 @@ describe("message stream rendering and message action menu", () => { const item = { id: "a-running", sourceItemId: "a-running", - kind: "message", + kind: "dialogue", role: "assistant", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "partial", copyText: "partial", turnId: "turn-1", @@ -743,8 +743,8 @@ describe("message stream rendering and message action menu", () => { copyText: vi.fn(), }; - const runningBlock = messageStreamBlocks(context)[0]; - const completedBlock = messageStreamBlocks({ ...context, turnLifecycle: idleTurnLifecycle() })[0]; + const runningBlock = threadStreamBlocks(context)[0]; + const completedBlock = threadStreamBlocks({ ...context, turnLifecycle: idleTurnLifecycle() })[0]; expect(renderMessageBlockElement(runningBlock).querySelector(".codex-panel__copy-message")).toBeNull(); expect(renderMessageBlockElement(completedBlock).querySelector(".codex-panel__copy-message")).not.toBeNull(); @@ -752,17 +752,17 @@ describe("message stream rendering and message action menu", () => { it("renders implement plan action for eligible proposed plans", () => { const onImplementPlan = vi.fn(); - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ { id: "p1", - kind: "message", + kind: "dialogue", role: "assistant", text: "# Plan", copyText: "# Plan", turnId: "turn-1", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", }, ], copyText: vi.fn(), @@ -781,36 +781,36 @@ describe("message stream rendering and message action menu", () => { it("selects only the latest proposed plan as an implement candidate", () => { const firstPlan = { id: "p1", - kind: "message", + kind: "dialogue", role: "assistant", text: "# First plan", turnId: "turn-1", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", } as const; const secondPlan = { id: "p2", - kind: "message", + kind: "dialogue", role: "assistant", text: "# Second plan", turnId: "turn-2", - messageKind: "proposedPlan", - messageState: "completed", + dialogueKind: "proposedPlan", + dialogueState: "completed", } as const; const baseState = { activeThread: { id: "thread" }, turn: { lifecycle: { kind: "idle" as const } }, runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } }, - messageStream: { + threadStream: { stableItems: [ firstPlan, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "answer", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", } as const, secondPlan, ], @@ -826,7 +826,7 @@ describe("message stream rendering and message action menu", () => { }); it("does not render copy actions for tool items", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ { id: "tool-1", @@ -846,8 +846,8 @@ describe("message stream rendering and message action menu", () => { it("renders copy and rollback actions together when both apply", () => { const copyText = vi.fn(); const onRollback = vi.fn(); - const block = messageStreamBlocks({ - items: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "latest", copyText: "latest", turnId: "turn-1" }], + const block = threadStreamBlocks({ + items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "latest", copyText: "latest", turnId: "turn-1" }], copyText, textActionTargetsByItemId: new Map([["u1", { rollback: true }]]), onRollback, @@ -875,12 +875,12 @@ describe("message stream rendering and message action menu", () => { } }); const render = () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ { id: "u1", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "visible text", copyText: "full copied text", @@ -891,7 +891,7 @@ describe("message stream rendering and message action menu", () => { onDisclosureToggle, copyText, }); - renderMessageStreamBlocksInAct(parent, blocks); + renderThreadStreamBlocksInAct(parent, blocks); }; render(); @@ -933,8 +933,8 @@ describe("message stream rendering and message action menu", () => { it("does not show the collapse control for short user messages or assistant messages", () => { withMessageContentScrollHeight(120, () => { - const shortUserBlock = messageStreamBlocks({ - items: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "short", turnId: "turn-1" }], + const shortUserBlock = threadStreamBlocks({ + items: [{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "short", turnId: "turn-1" }], })[0]; const shortUser = renderMessageBlockElement(shortUserBlock); @@ -942,16 +942,16 @@ describe("message stream rendering and message action menu", () => { }); withMessageContentScrollHeight(500, () => { - const assistantBlock = messageStreamBlocks({ + const assistantBlock = threadStreamBlocks({ items: [ { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "long", turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], })[0]; @@ -962,7 +962,7 @@ describe("message stream rendering and message action menu", () => { }); it("renders command items as a compact summary with output behind details", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -994,7 +994,7 @@ describe("message stream rendering and message action menu", () => { }); it("omits command exit and duration rows while they are unavailable", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -1023,7 +1023,7 @@ describe("message stream rendering and message action menu", () => { }); it("uses read as the command header for parsed file reads", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -1048,7 +1048,7 @@ describe("message stream rendering and message action menu", () => { }); it("derives command summaries from semantic command targets instead of item text", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -1072,7 +1072,7 @@ describe("message stream rendering and message action menu", () => { }); it("renders file diffs inside a single file change details block", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), workspaceRoot: "/vault/project", items: [ @@ -1098,7 +1098,7 @@ describe("message stream rendering and message action menu", () => { }); it("derives file change summaries from changes and status instead of item text", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), workspaceRoot: "/vault/project", items: [ @@ -1120,7 +1120,7 @@ describe("message stream rendering and message action menu", () => { it("renders the edited files footer with an open diff action when aggregated turn diff exists", () => { const openTurnDiff = vi.fn(); - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ workspaceRoot: "/vault/project", items: [ { @@ -1133,12 +1133,12 @@ describe("message stream rendering and message action menu", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "Done", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], turnDiffs: new Map([["turn", "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new"]]), @@ -1165,12 +1165,12 @@ describe("message stream rendering and message action menu", () => { }); it("renders referenced thread metadata without exposing hidden context", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ { id: "u1", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "この続きです", copyText: "この続きです", @@ -1193,12 +1193,12 @@ describe("message stream rendering and message action menu", () => { }); it("renders resolved file mentions as a collapsed user message attachment", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ { id: "u1", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "Read [[Alpha]].", copyText: "Read [[Alpha]].", @@ -1218,7 +1218,7 @@ describe("message stream rendering and message action menu", () => { }); it("does not render the open diff action without aggregated turn diff", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ { id: "patch-1", @@ -1230,12 +1230,12 @@ describe("message stream rendering and message action menu", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "Done", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], openTurnDiff: vi.fn(), diff --git a/tests/features/chat/ui/message-stream/flow-scroll.test.ts b/tests/features/chat/ui/thread-stream/flow-scroll.test.ts similarity index 88% rename from tests/features/chat/ui/message-stream/flow-scroll.test.ts rename to tests/features/chat/ui/thread-stream/flow-scroll.test.ts index 5b7898ff..c8d2e45e 100644 --- a/tests/features/chat/ui/message-stream/flow-scroll.test.ts +++ b/tests/features/chat/ui/thread-stream/flow-scroll.test.ts @@ -3,19 +3,19 @@ import { h, type ComponentChild as UiNode } from "preact"; import { act } from "preact/test-utils"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-rendered-event.dom"; +import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/thread-stream/content-rendered-event.dom"; import { - MessageStreamFlowFrame, - type MessageStreamScrollCommand, - type MessageStreamScrollPort, - type MessageStreamScrollPortBinding, -} from "../../../../../src/features/chat/ui/message-stream/flow-scroll.measure"; + ThreadStreamFlowFrame, + type ThreadStreamScrollCommand, + type ThreadStreamScrollPort, + type ThreadStreamScrollPortBinding, +} from "../../../../../src/features/chat/ui/thread-stream/flow-scroll.measure"; import { renderUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; import { installObsidianDomShims } from "../../../../support/dom"; installObsidianDomShims(); -describe("message stream flow scrolling", () => { +describe("thread stream flow scrolling", () => { beforeEach(() => { resizeObserverCallbacks = []; animationFrameCallbacks = []; @@ -33,7 +33,7 @@ describe("message stream flow scrolling", () => { }); it("pins to the DOM scroll end and follows appended content while pinned", () => { - const { controller, messages, render } = renderFlowMessageStream(["first"], { first: 300 }); + const { controller, messages, render } = renderFlowThreadStream(["first"], { first: 300 }); const scrollCalls = installScrollToCapture(messages); void act(() => { @@ -49,7 +49,7 @@ describe("message stream flow scrolling", () => { }); it("keeps the current reading position when content is appended after the user scrolls away", () => { - const { controller, messages, render } = renderFlowMessageStream(["first"], { first: 300 }); + const { controller, messages, render } = renderFlowThreadStream(["first"], { first: 300 }); void act(() => { controller.dispatch({ kind: "show-latest" }); }); @@ -62,7 +62,7 @@ describe("message stream flow scrolling", () => { }); it("preserves the visible block when older history is prepended", () => { - const { messages, render } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const { messages, render } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 }); messages.scrollTop = 320; messages.dispatchEvent(new Event("scroll")); @@ -72,7 +72,7 @@ describe("message stream flow scrolling", () => { }); it("preserves the visible block when older history is inserted after the history bar", () => { - const { messages, render } = renderFlowMessageStream(["history-bar", "current", "next"], { + const { messages, render } = renderFlowThreadStream(["history-bar", "current", "next"], { "history-bar": 40, current: 300, next: 300, @@ -91,7 +91,7 @@ describe("message stream flow scrolling", () => { }); it("does not force the end when delayed content changes while the user is reading", () => { - const { messages, blockElement, setHeights } = renderFlowMessageStream(["first", "second", "third"], { + const { messages, blockElement, setHeights } = renderFlowThreadStream(["first", "second", "third"], { first: 300, second: 300, third: 300, @@ -107,9 +107,9 @@ describe("message stream flow scrolling", () => { expect(messages.scrollTop).toBe(360); }); - it("keeps hidden message streams pinned when the viewport size returns", () => { + it("keeps hidden thread streams pinned when the viewport size returns", () => { const restoreInitialMetrics = installMessageViewportPrototypeMetrics({ scrollHeight: 600, viewport: { width: 0, height: 0 } }); - const { messages, resizeViewport } = renderFlowMessageStream( + const { messages, resizeViewport } = renderFlowThreadStream( ["first", "second"], { first: 300, second: 300 }, { @@ -129,7 +129,7 @@ describe("message stream flow scrolling", () => { it("does not double-adjust when the browser anchors a prepended block before effects run", () => { let nativeAnchoringApplied = false; - const { messages, render } = renderFlowMessageStream( + const { messages, render } = renderFlowThreadStream( ["first", "second"], { first: 300, second: 300 }, { @@ -155,7 +155,7 @@ describe("message stream flow scrolling", () => { }); it("returns to the DOM scroll end after delayed message content renders while pinned", () => { - const { controller, messages, blockElement, setHeights } = renderFlowMessageStream(["message"], { message: 300 }); + const { controller, messages, blockElement, setHeights } = renderFlowThreadStream(["message"], { message: 300 }); void act(() => { controller.dispatch({ kind: "show-latest" }); }); @@ -170,7 +170,7 @@ describe("message stream flow scrolling", () => { }); it("scrolls by composer text-line and page commands", () => { - const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const { controller, messages } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 }); const scrollCalls = installScrollToCapture(messages); messages.style.lineHeight = "20px"; messages.scrollTop = 240; @@ -194,7 +194,7 @@ describe("message stream flow scrolling", () => { it("uses instant composer scrolling when reduced motion is preferred", () => { window.matchMedia = createTestMatchMedia(true); - const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const { controller, messages } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 }); const scrollCalls = installScrollToCapture(messages); messages.style.lineHeight = "20px"; messages.scrollTop = 240; @@ -209,7 +209,7 @@ describe("message stream flow scrolling", () => { }); it("uses the normal text-line distance for repeated composer scrolling without smooth animation", () => { - const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const { controller, messages } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 }); const scrollCalls = installScrollToCapture(messages); messages.style.lineHeight = "20px"; messages.scrollTop = 240; @@ -224,7 +224,7 @@ describe("message stream flow scrolling", () => { }); it("uses the normal page distance for repeated composer page scrolling without smooth animation", () => { - const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const { controller, messages } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 }); const scrollCalls = installScrollToCapture(messages); messages.scrollTop = 240; messages.dispatchEvent(new Event("scroll")); @@ -238,7 +238,7 @@ describe("message stream flow scrolling", () => { }); it("scrolls to stream edges from composer commands", () => { - const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const { controller, messages } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 }); const scrollCalls = installScrollToCapture(messages); messages.scrollTop = 240; messages.dispatchEvent(new Event("scroll")); @@ -265,12 +265,12 @@ interface CapturedScrollToOptions { behavior: ScrollBehavior | undefined; } -interface TestMessageStreamScrollPortBinding extends MessageStreamScrollPortBinding { - dispatch(command: MessageStreamScrollCommand): void; +interface TestThreadStreamScrollPortBinding extends ThreadStreamScrollPortBinding { + dispatch(command: ThreadStreamScrollCommand): void; } -function createTestMessageStreamScrollPortBinding(): TestMessageStreamScrollPortBinding { - let port: MessageStreamScrollPort | null = null; +function createTestThreadStreamScrollPortBinding(): TestThreadStreamScrollPortBinding { + let port: ThreadStreamScrollPort | null = null; return { mountScrollPort(nextPort) { port = nextPort; @@ -284,7 +284,7 @@ function createTestMessageStreamScrollPortBinding(): TestMessageStreamScrollPort }; } -function renderFlowMessageStream( +function renderFlowThreadStream( keys: readonly string[], heights: Record, options: { @@ -292,7 +292,7 @@ function renderFlowMessageStream( viewport?: { width: number; height: number }; } = {}, ): { - controller: TestMessageStreamScrollPortBinding; + controller: TestThreadStreamScrollPortBinding; messages: HTMLElement; render: (nextKeys: readonly string[], nextHeights: Record) => void; setHeights: (nextHeights: Record) => void; @@ -301,7 +301,7 @@ function renderFlowMessageStream( } { const parent = document.createElement("div"); document.body.append(parent); - const controller = createTestMessageStreamScrollPortBinding(); + const controller = createTestThreadStreamScrollPortBinding(); let currentHeights = heights; let currentKeys = keys; let viewport = options.viewport ?? { width: 240, height: 100 }; @@ -312,7 +312,7 @@ function renderFlowMessageStream( void act(() => { renderUiRoot( parent, - h(MessageStreamFlowFrame, { + h(ThreadStreamFlowFrame, { blocks: nextKeys.map((key) => ({ key })), scrollPortBinding: controller, renderBlockContent: (block) => options.blockNode?.(block.key) ?? h("div", null, block.key), diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx similarity index 92% rename from tests/features/chat/ui/message-stream/pending-requests.test.tsx rename to tests/features/chat/ui/thread-stream/pending-requests.test.tsx index 7630402f..edca06eb 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx @@ -2,33 +2,33 @@ import { describe, expect, it, vi } from "vitest"; import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "../../../../../src/domain/pending-requests/model"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState, } from "../../../../../src/features/chat/presentation/pending-requests/view-model"; -import type { PendingRequestBlockContext } from "../../../../../src/features/chat/ui/message-stream/context"; +import type { PendingRequestBlockContext } from "../../../../../src/features/chat/ui/thread-stream/context"; import { changeInputValue, textContents } from "../../../../support/dom"; import "./setup"; import { actEvent, dispatchComposingInputValue, expectPresent, - messageStreamBlocks, pendingApproval, pendingFreeformUserInput, pendingOtherUserInput, pendingRequestActions, pendingUserInput, renderMessageBlockElement, - renderMessageStreamBlocksInAct, renderPendingRequestNode, + renderThreadStreamBlocksInAct, setNativeInputValue, + threadStreamBlocks, unmountUiRootInAct, } from "./test-helpers"; describe("pending request renderer decisions", () => { - it("renders pending requests as one message-stream block and keeps user input drafts live", () => { + it("renders pending requests as one thread stream block and keeps user input drafts live", () => { const parent = document.createElement("div"); const drafts = new Map(); const resolveUserInput = vi.fn(); @@ -366,7 +366,7 @@ describe("pending request renderer decisions", () => { it("renders submitted user input separately from approvals", () => { const renderMarkdown = vi.fn((parent: HTMLElement, text: string) => parent.createDiv({ text: `markdown:${text}` })); - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ { id: "user-input-submitted-1", @@ -393,7 +393,7 @@ describe("pending request renderer decisions", () => { }); it("renders manual approval results with completion state and details", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ { id: "approval-1", @@ -426,7 +426,7 @@ describe("pending request renderer decisions", () => { }); it("renders auto-review summaries under the final assistant message", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ { id: "review-1", @@ -446,10 +446,10 @@ describe("pending request renderer decisions", () => { }, { id: "assistant-1", - kind: "message", + kind: "dialogue", role: "assistant", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", text: "Done", turnId: "turn", }, @@ -464,9 +464,11 @@ describe("pending request renderer decisions", () => { expect(element.querySelector(".codex-panel__auto-reviews")?.textContent).toContain("Auto-review approved: npm test"); }); - it("adds pending requests to the bottom of message stream blocks", () => { - const blocks = messageStreamBlocks({ - items: [{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }], + it("adds pending requests to the bottom of thread stream blocks", () => { + const blocks = threadStreamBlocks({ + items: [ + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, + ], pendingRequests: pendingRequestContext({ signature: "request:1", }), @@ -481,11 +483,11 @@ describe("pending request renderer decisions", () => { const resolveMcpElicitation = vi.fn(); const setMcpElicitationDraft = vi.fn(); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ items: [ - { id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }, + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, ], renderMarkdown: (element, text) => element.createDiv({ text }), pendingRequests: pendingRequestContext({ @@ -517,11 +519,11 @@ describe("pending request renderer decisions", () => { const parent = document.createElement("div"); const resolveMcpElicitation = vi.fn(); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ items: [ - { id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }, + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, ], renderMarkdown: (element, text) => element.createDiv({ text }), pendingRequests: pendingRequestContext({ @@ -551,11 +553,11 @@ describe("pending request renderer decisions", () => { const parent = document.createElement("div"); const resolveMcpElicitation = vi.fn(); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ items: [ - { id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }, + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, ], renderMarkdown: (element, text) => element.createDiv({ text }), pendingRequests: pendingRequestContext({ @@ -577,11 +579,13 @@ describe("pending request renderer decisions", () => { unmountUiRootInAct(parent); }); - it("does not consume pending request autofocus while building message stream blocks", () => { + it("does not consume pending request autofocus while building thread stream blocks", () => { const consumeAutoFocus = vi.fn(() => true); - const blocks = messageStreamBlocks({ - items: [{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }], + const blocks = threadStreamBlocks({ + items: [ + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, + ], pendingRequests: pendingRequestContext({ signature: "request:1", consumeAutoFocus, @@ -598,11 +602,11 @@ describe("pending request renderer decisions", () => { const consumeAutoFocus = vi.fn(() => true); try { - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ items: [ - { id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }, + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, ], renderMarkdown: (element, text) => element.createDiv({ text }), pendingRequests: pendingRequestContext({ @@ -625,7 +629,7 @@ describe("pending request renderer decisions", () => { const pendingSnapshot = vi.fn(() => emptyPendingRequestBlockSnapshot()); const consumeAutoFocus = vi.fn(() => true); - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [], pendingRequests: pendingRequestContext({ signature: "request:1", @@ -643,13 +647,13 @@ describe("pending request renderer decisions", () => { const parent = document.createElement("div"); const baseContext = { items: [ - { id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" }, - ] satisfies MessageStreamItem[], + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, + ] satisfies ThreadStreamItem[], }; - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( parent, - messageStreamBlocks({ + threadStreamBlocks({ ...baseContext, pendingRequests: pendingRequestContext({ signature: "request:1", @@ -659,7 +663,7 @@ describe("pending request renderer decisions", () => { ); expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).not.toBeNull(); - renderMessageStreamBlocksInAct(parent, messageStreamBlocks(baseContext)); + renderThreadStreamBlocksInAct(parent, threadStreamBlocks(baseContext)); expect(parent.querySelector('[data-codex-panel-block-key="pending-requests"]')).toBeNull(); expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).not.toBeNull(); diff --git a/tests/features/chat/ui/message-stream/setup.ts b/tests/features/chat/ui/thread-stream/setup.ts similarity index 100% rename from tests/features/chat/ui/message-stream/setup.ts rename to tests/features/chat/ui/thread-stream/setup.ts diff --git a/tests/features/chat/ui/message-stream/stream-items.test.tsx b/tests/features/chat/ui/thread-stream/stream-items.test.tsx similarity index 91% rename from tests/features/chat/ui/message-stream/stream-items.test.tsx rename to tests/features/chat/ui/thread-stream/stream-items.test.tsx index 94ec90b9..8d438e9d 100644 --- a/tests/features/chat/ui/message-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/thread-stream/stream-items.test.tsx @@ -2,22 +2,22 @@ import { describe, expect, it, vi } from "vitest"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { textContents, topLevelDetailsSummaries } from "../../../../support/dom"; import "./setup"; import { expectPresent, - messageStreamBlocks, renderMessageBlockElement, - renderMessageStreamBlocksInAct, + renderThreadStreamBlocksInAct, runningTurnLifecycle, testDisclosures, + threadStreamBlocks, unmountUiRootInAct, } from "./test-helpers"; -describe("message stream item renderer decisions", () => { +describe("thread stream item renderer decisions", () => { it("renders generic tool details as visible sections inside one details block", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -47,24 +47,24 @@ describe("message stream item renderer decisions", () => { }); it("renders steering activity as a compact two-line tool summary", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "turn" }, { id: "u2", - kind: "message", - messageKind: "user", + kind: "dialogue", + dialogueKind: "user", role: "user", text: "also check tests and keep the summary compact", turnId: "turn", }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", turnId: "turn", }, ], @@ -78,7 +78,7 @@ describe("message stream item renderer decisions", () => { }); it("renders path summary tools relative to the workspace root", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), workspaceRoot: "/vault/project", items: [ @@ -101,7 +101,7 @@ describe("message stream item renderer decisions", () => { }); it("derives generic tool summaries from primary targets instead of item text", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), workspaceRoot: "/vault/project", items: [ @@ -136,19 +136,19 @@ describe("message stream item renderer decisions", () => { } as const; const baseContext = { turnLifecycle: runningTurnLifecycle("turn"), - items: [item] satisfies MessageStreamItem[], + items: [item] satisfies ThreadStreamItem[], }; - renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault" })); + renderThreadStreamBlocksInAct(parent, threadStreamBlocks({ ...baseContext, workspaceRoot: "/vault" })); expect(parent.querySelector(".codex-panel__stream-summary")?.textContent).toBe("project/assets/image.png"); - renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault/project" })); + renderThreadStreamBlocksInAct(parent, threadStreamBlocks({ ...baseContext, workspaceRoot: "/vault/project" })); expect(parent.querySelector(".codex-panel__stream-summary")?.textContent).toBe("assets/image.png"); unmountUiRootInAct(parent); }); it("keeps path summary tools absolute outside the workspace root", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), workspaceRoot: "/vault/project", items: [ @@ -170,7 +170,7 @@ describe("message stream item renderer decisions", () => { }); it("does not treat generic tool summaries as paths without an explicit marker", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), workspaceRoot: "/vault/project", items: [ @@ -191,7 +191,7 @@ describe("message stream item renderer decisions", () => { }); it("renders hook metadata as rows inside one details block", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -228,9 +228,9 @@ describe("message stream item renderer decisions", () => { }); it("renders hook metadata when the hook is inside a completed-turn activity group", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "turn" }, { id: "hook-1", kind: "hook", @@ -248,12 +248,12 @@ describe("message stream item renderer decisions", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "done", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, ], disclosures: testDisclosures({ activityGroups: ["turn"], details: ["hook-1:details"] }), @@ -272,7 +272,7 @@ describe("message stream item renderer decisions", () => { }); it("renders task progress items as a dedicated task list", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -301,10 +301,10 @@ describe("message stream item renderer decisions", () => { }); it("renders active task progress with the shared bottom live blocks", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "Do it", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" }, { id: "plan-progress-turn", kind: "taskProgress", @@ -317,12 +317,12 @@ describe("message stream item renderer decisions", () => { }, { id: "a1", - kind: "message", + kind: "dialogue", role: "assistant", text: "Working", turnId: "turn", - messageKind: "assistantResponse", - messageState: "completed", + dialogueKind: "assistantResponse", + dialogueState: "completed", }, { id: "agent-1", @@ -373,10 +373,10 @@ describe("message stream item renderer decisions", () => { }); it("orders shared bottom live blocks by insertion order", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "Do it", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" }, { id: "agent-1", kind: "agent", @@ -409,10 +409,10 @@ describe("message stream item renderer decisions", () => { }); it("anchors the live agent summary at the first agent activity", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "Do it", turnId: "turn" }, + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "Do it", turnId: "turn" }, { id: "agent-spawn", kind: "agent", @@ -466,7 +466,7 @@ describe("message stream item renderer decisions", () => { }); it("renders agent activity as a one-line summary with consolidated details", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -502,7 +502,7 @@ describe("message stream item renderer decisions", () => { it("opens agent threads from agent activity headers", () => { const openThreadInNewView = vi.fn(); - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), openThreadInNewView, items: [ @@ -537,7 +537,7 @@ describe("message stream item renderer decisions", () => { }); it("keeps agent activity headers passive without an open thread handler", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -565,7 +565,7 @@ describe("message stream item renderer decisions", () => { it("keeps agent thread actions available after agent details expand", () => { const openThreadInNewView = vi.fn(); - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), openThreadInNewView, disclosures: testDisclosures({ details: ["agent-1:agent-details"] }), @@ -597,7 +597,7 @@ describe("message stream item renderer decisions", () => { }); it("keeps agent activity prompt previews visually constrained to one line", () => { - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -628,7 +628,7 @@ describe("message stream item renderer decisions", () => { const longMessage = `Done\n${"a".repeat(180)}`; const threadId = "019e061e-0046-7653-a362-86de9a47cb5c"; const onDisclosureToggle = vi.fn(); - const block = messageStreamBlocks({ + const block = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -668,7 +668,7 @@ describe("message stream item renderer decisions", () => { it("renders a compact live agent summary while subagents are running", () => { const openThreadInNewView = vi.fn(); - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), openThreadInNewView, items: [ @@ -712,16 +712,16 @@ describe("message stream item renderer decisions", () => { it("renders context compaction as a one-line status item while running and after completion", () => { const runningParent = document.createElement("div"); - const item: MessageStreamItem = { + const item: ThreadStreamItem = { id: "compact-1", kind: "contextCompaction", role: "tool", turnId: "turn", }; - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( runningParent, - messageStreamBlocks({ + threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [item], renderMarkdown: (element, text) => element.createDiv({ text }), @@ -737,9 +737,9 @@ describe("message stream item renderer decisions", () => { unmountUiRootInAct(runningParent); const completedParent = document.createElement("div"); - renderMessageStreamBlocksInAct( + renderThreadStreamBlocksInAct( completedParent, - messageStreamBlocks({ + threadStreamBlocks({ items: [item], renderMarkdown: (element, text) => element.createDiv({ text }), }), @@ -754,7 +754,7 @@ describe("message stream item renderer decisions", () => { }); it("hides the live agent summary once all subagents are complete", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { @@ -779,7 +779,7 @@ describe("message stream item renderer decisions", () => { }); it("marks the live agent summary failed when any subagent fails", () => { - const blocks = messageStreamBlocks({ + const blocks = threadStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), items: [ { diff --git a/tests/features/chat/ui/message-stream/stream-markdown-renderer.test.ts b/tests/features/chat/ui/thread-stream/stream-markdown-renderer.test.ts similarity index 98% rename from tests/features/chat/ui/message-stream/stream-markdown-renderer.test.ts rename to tests/features/chat/ui/thread-stream/stream-markdown-renderer.test.ts index 25ef85d7..c4a09c75 100644 --- a/tests/features/chat/ui/message-stream/stream-markdown-renderer.test.ts +++ b/tests/features/chat/ui/thread-stream/stream-markdown-renderer.test.ts @@ -3,7 +3,7 @@ import { TFile } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderStreamMarkdown } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer.obsidian"; +import { renderStreamMarkdown } from "../../../../../src/features/chat/ui/thread-stream/markdown-renderer.obsidian"; import { notices } from "../../../../mocks/obsidian"; import { installObsidianDomShims } from "../../../../support/dom"; diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/thread-stream/test-helpers.tsx similarity index 74% rename from tests/features/chat/ui/message-stream/test-helpers.tsx rename to tests/features/chat/ui/thread-stream/test-helpers.tsx index 3e057a44..e309a208 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/thread-stream/test-helpers.tsx @@ -3,29 +3,26 @@ import { act } from "preact/test-utils"; import { vi } from "vitest"; import type { PendingApproval, PendingUserInput } from "../../../../../src/domain/pending-requests/model"; -import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import type { MessageStreamTextActionTargets } from "../../../../../src/features/chat/presentation/message-stream/text-view"; -import { - type MessageStreamViewBlock, - messageStreamViewBlocks, -} from "../../../../../src/features/chat/presentation/message-stream/view-model"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { pendingRequestBlockSnapshotFromState } from "../../../../../src/features/chat/presentation/pending-requests/view-model"; +import type { ThreadStreamTextActionTargets } from "../../../../../src/features/chat/presentation/thread-stream/text-view"; +import { type ThreadStreamViewBlock, threadStreamViewBlocks } from "../../../../../src/features/chat/presentation/thread-stream/view-model"; import type { - MessageStreamContext, - MessageStreamDisclosureState, PendingRequestBlockActions, PendingRequestBlockContext, -} from "../../../../../src/features/chat/ui/message-stream/context"; -import type { MessageStreamScrollPortBinding } from "../../../../../src/features/chat/ui/message-stream/flow-scroll.measure"; -import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/message-stream/pending-request-block"; -import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/stream-blocks"; + ThreadStreamContext, + ThreadStreamDisclosureState, +} from "../../../../../src/features/chat/ui/thread-stream/context"; +import type { ThreadStreamScrollPortBinding } from "../../../../../src/features/chat/ui/thread-stream/flow-scroll.measure"; +import { pendingRequestBlockNode } from "../../../../../src/features/chat/ui/thread-stream/pending-request-block"; +import { ThreadStreamViewport } from "../../../../../src/features/chat/ui/thread-stream/stream-blocks"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; -export function messageStreamBlocks(context: TestMessageStreamContext): [MessageStreamViewBlock, ...MessageStreamViewBlock[]] { - const normalized = normalizeMessageStreamContext(context); - const blocks = messageStreamViewBlocks({ +export function threadStreamBlocks(context: TestThreadStreamContext): [ThreadStreamViewBlock, ...ThreadStreamViewBlock[]] { + const normalized = normalizeThreadStreamContext(context); + const blocks = threadStreamViewBlocks({ activeThreadId: normalized.activeThreadId, - activeTurnId: activeTurnIdForMessageStream(normalized.turnLifecycle), + activeTurnId: activeTurnIdForThreadStream(normalized.turnLifecycle), historyCursor: normalized.historyCursor, loadingHistory: normalized.loadingHistory, items: normalized.items, @@ -36,17 +33,17 @@ export function messageStreamBlocks(context: TestMessageStreamContext): [Message textActionTargetsByItemId: normalized.textActionTargetsByItemId, pendingRequests: pendingRequestBlockInput(normalized), }); - if (blocks.length === 0) throw new Error("Expected at least one message stream block."); - for (const block of blocks) messageStreamContextByBlock.set(block, normalized); - return blocks as [MessageStreamViewBlock, ...MessageStreamViewBlock[]]; + if (blocks.length === 0) throw new Error("Expected at least one thread stream block."); + for (const block of blocks) threadStreamContextByBlock.set(block, normalized); + return blocks as [ThreadStreamViewBlock, ...ThreadStreamViewBlock[]]; } -const messageStreamContextByBlock = new WeakMap(); +const threadStreamContextByBlock = new WeakMap(); function pendingRequestBlockInput( - context: TestMessageStreamContext, + context: TestThreadStreamContext, ): { signature: string; snapshot: ReturnType } | null { - if (messageStreamBlockItemsEmpty(context)) return null; + if (threadStreamBlockItemsEmpty(context)) return null; const pendingRequests = context.pendingRequests; const signature = pendingRequests?.signature; if (!signature) return null; @@ -56,53 +53,53 @@ function pendingRequestBlockInput( }; } -function messageStreamBlockItemsEmpty(context: TestMessageStreamContext): boolean { +function threadStreamBlockItemsEmpty(context: TestThreadStreamContext): boolean { if (!context.stableItems && !context.activeItems) return context.items.length === 0; return (context.stableItems?.length ?? 0) === 0 && (context.activeItems?.length ?? 0) === 0; } -type TestMessageStreamContext = Omit< - MessageStreamContext, +type TestThreadStreamContext = Omit< + ThreadStreamContext, "activeThreadId" | "disclosures" | "forkMenuItemId" | "loadOlderTurns" | "renderObsidianMarkdown" | "renderStreamMarkdown" > & Partial< Pick< - MessageStreamContext, + ThreadStreamContext, "activeThreadId" | "disclosures" | "forkMenuItemId" | "loadOlderTurns" | "renderObsidianMarkdown" | "renderStreamMarkdown" > > & { renderMarkdown?: (parent: HTMLElement, text: string) => void; - turnLifecycle?: MessageStreamTurnLifecycleState; + turnLifecycle?: ThreadStreamTurnLifecycleState; historyCursor?: string | null; loadingHistory?: boolean; - items: readonly MessageStreamItem[]; - stableItems?: readonly MessageStreamItem[]; - activeItems?: readonly MessageStreamItem[]; + items: readonly ThreadStreamItem[]; + stableItems?: readonly ThreadStreamItem[]; + activeItems?: readonly ThreadStreamItem[]; turnDiffs?: ReadonlyMap; - textActionTargetsByItemId?: ReadonlyMap; + textActionTargetsByItemId?: ReadonlyMap; }; -type MessageStreamTurnLifecycleState = +type ThreadStreamTurnLifecycleState = | { kind: "idle" } | { kind: "starting"; pendingTurnStart: unknown } | { kind: "running"; turnId: string }; -type NormalizedTestMessageStreamContext = MessageStreamContext & - Omit & { +type NormalizedTestThreadStreamContext = ThreadStreamContext & + Omit & { activeThreadId: string | null; historyCursor: string | null; loadingHistory: boolean; loadOlderTurns: () => void; - turnLifecycle: MessageStreamTurnLifecycleState; + turnLifecycle: ThreadStreamTurnLifecycleState; }; -function emptyDisclosures(): MessageStreamDisclosureState { +function emptyDisclosures(): ThreadStreamDisclosureState { return testDisclosures(); } export function testDisclosures( - overrides: Partial> = {}, -): MessageStreamDisclosureState { + overrides: Partial> = {}, +): ThreadStreamDisclosureState { return { details: new Set(overrides.details), activityGroups: new Set(overrides.activityGroups), @@ -112,7 +109,7 @@ export function testDisclosures( }; } -function normalizeMessageStreamContext(context: TestMessageStreamContext): NormalizedTestMessageStreamContext { +function normalizeThreadStreamContext(context: TestThreadStreamContext): NormalizedTestThreadStreamContext { const renderObsidianMarkdown = context.renderObsidianMarkdown ?? context.renderMarkdown ?? @@ -152,9 +149,9 @@ export function dispatchComposingInputValue(input: HTMLInputElement, value: stri input.dispatchEvent(event); } -export function renderMessageBlockElement(block: MessageStreamViewBlock): HTMLElement { +export function renderMessageBlockElement(block: ThreadStreamViewBlock): HTMLElement { const parent = document.createElement("div"); - renderMessageStreamBlocksInAct(parent, [block]); + renderThreadStreamBlocksInAct(parent, [block]); const host = expectPresent(parent.querySelector(`[data-codex-panel-block-key="${block.key}"]`)); return expectPresent(host.firstElementChild as HTMLElement | null); } @@ -163,32 +160,32 @@ export function actEvent(action: () => void): void { void act(action); } -export function renderMessageStreamBlocksInAct(parent: HTMLElement, blocks: MessageStreamViewBlock[]): void { +export function renderThreadStreamBlocksInAct(parent: HTMLElement, blocks: ThreadStreamViewBlock[]): void { parent.addClass("codex-panel__messages"); installMessageViewportMetrics(parent); if (!parent.isConnected) document.body.appendChild(parent); - const context = messageStreamContextForBlocks(blocks); + const context = threadStreamContextForBlocks(blocks); void act(() => { renderUiRoot( parent, - , ); }); } -const noOpMessageStreamScrollPortBinding: MessageStreamScrollPortBinding = { +const noOpThreadStreamScrollPortBinding: ThreadStreamScrollPortBinding = { mountScrollPort: () => () => undefined, }; -function messageStreamContextForBlocks(blocks: readonly MessageStreamViewBlock[]): MessageStreamContext { - const context = blocks.map((block) => messageStreamContextByBlock.get(block)).find((candidate) => candidate !== undefined); - if (!context) throw new Error("Expected message stream blocks created by messageStreamBlocks()."); +function threadStreamContextForBlocks(blocks: readonly ThreadStreamViewBlock[]): ThreadStreamContext { + const context = blocks.map((block) => threadStreamContextByBlock.get(block)).find((candidate) => candidate !== undefined); + if (!context) throw new Error("Expected thread stream blocks created by threadStreamBlocks()."); return context; } @@ -274,15 +271,15 @@ export function pendingRequestActions(overrides: Partial { it("renders chat actions in the new chat toolbar menu", () => { const parent = document.createElement("div"); const startNewThread = vi.fn(); - const compactConversation = vi.fn(); + const compactContext = vi.fn(); const setGoal = vi.fn(); mountToolbar( parent, toolbarModel({ chatActionsOpen: true, openPanel: "chat-actions" }), - toolbarActions({ startNewThread, compactConversation, setGoal }), + toolbarActions({ startNewThread, compactContext, setGoal }), ); const items = [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")]; @@ -93,7 +93,7 @@ describe("Toolbar decisions", () => { items[1]?.click(); items[2]?.click(); expect(startNewThread).toHaveBeenCalledOnce(); - expect(compactConversation).toHaveBeenCalledOnce(); + expect(compactContext).toHaveBeenCalledOnce(); expect(setGoal).toHaveBeenCalledOnce(); }); @@ -421,7 +421,7 @@ interface ToolbarActionOverrides { toggleHistory?: () => void; startNewThread?: () => void; toggleChatActions?: () => void; - compactConversation?: () => void; + compactContext?: () => void; setGoal?: () => void; toggleStatusPanel?: () => void; connect?: () => void; @@ -446,7 +446,7 @@ function toolbarActions(overrides: ToolbarActionOverrides = {}): ToolbarActions }, chat: { startNewThread: overrides.startNewThread ?? vi.fn(), - compactConversation: overrides.compactConversation ?? vi.fn(), + compactContext: overrides.compactContext ?? vi.fn(), setGoal: overrides.setGoal ?? vi.fn(), }, status: { diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 7917abf9..e5b76b59 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -422,7 +422,7 @@ export const loadedRoot = root; `.trimStart(), ); await writeFile( - path.join(cwd, "src/features/chat/application/state/message-stream.ts"), + path.join(cwd, "src/features/chat/application/state/thread-stream.ts"), ` export function timestamp(): number { setTimeout(() => undefined, 1); @@ -455,7 +455,7 @@ export function timestamp(): number { "src/features/chat/panel/shell.dom.tsx", "src/features/chat/panel/composer-controller.ts", "src/features/chat/ui/root-escapes.tsx", - "src/features/chat/application/state/message-stream.ts", + "src/features/chat/application/state/thread-stream.ts", "src/features/chat/application/threads/resume-actions.ts", ], cwd, @@ -488,7 +488,7 @@ export function timestamp(): number { "Import the Preact root adapter only from explicit root bridge files.", "Import the Preact root adapter only from explicit root bridge files.", ]); - expect(pluginMessages(report, "src/features/chat/application/state/message-stream.ts")).toEqual([ + expect(pluginMessages(report, "src/features/chat/application/state/thread-stream.ts")).toEqual([ "Keep this state module deterministic and free of app-server, Obsidian, scheduling, and browser side effects.", "Keep this state module deterministic and free of app-server, Obsidian, scheduling, and browser side effects.", ]); @@ -524,9 +524,9 @@ export const values = [statusText, Toolbar] satisfies unknown[]; await writeFile( path.join(cwd, "src/features/chat/application/allowed.ts"), ` -import type { MessageStreamItem } from "../domain/message-stream/items"; +import type { ThreadStreamItem } from "../domain/thread-stream/items"; -export type Item = MessageStreamItem; +export type Item = ThreadStreamItem; `.trimStart(), ); await writeFile( @@ -563,9 +563,9 @@ export const values = [statusText, Toolbar] satisfies unknown[]; ` import type { AppServerClient } from "../../../app-server/connection/client"; import type { ChatStateStore } from "../application/state/store"; -import type { MessageStreamItem } from "../domain/message-stream/items"; +import type { ThreadStreamItem } from "../domain/thread-stream/items"; -export type Allowed = AppServerClient | ChatStateStore | MessageStreamItem; +export type Allowed = AppServerClient | ChatStateStore | ThreadStreamItem; `.trimStart(), ); await writeFile( @@ -698,9 +698,9 @@ export const toolbar = Toolbar; path.join(cwd, "src/features/chat/presentation/allowed.ts"), ` import type { Thread } from "../../../domain/threads/model"; -import type { MessageStreamItem } from "../domain/message-stream/items"; +import type { ThreadStreamItem } from "../domain/thread-stream/items"; -export type Allowed = Thread | MessageStreamItem; +export type Allowed = Thread | ThreadStreamItem; `.trimStart(), ); await writeFile( @@ -719,10 +719,10 @@ export type Allowed = Thread | MessageStreamItem; path.join(cwd, "src/features/chat/ui/allowed.tsx"), ` import type { Thread } from "../../../domain/threads/model"; -import type { MessageStreamItem } from "../domain/message-stream/items"; +import type { ThreadStreamItem } from "../domain/thread-stream/items"; import { statusText } from "../presentation/runtime/status"; -export type Allowed = Thread | MessageStreamItem; +export type Allowed = Thread | ThreadStreamItem; export const value = statusText; `.trimStart(), ); @@ -833,7 +833,7 @@ describe("Biome Grit include boundaries", () => { expect(pluginMessages(report, "src/features/chat/application/threads/history-controller.ts")).toEqual([ APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE, ]); - expect(pluginMessages(report, "src/features/chat/panel/surface/message-stream-presenter.ts")).toEqual([ + expect(pluginMessages(report, "src/features/chat/panel/surface/thread-stream-presenter.ts")).toEqual([ APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE, ]); expect(pluginMessages(report, "src/features/chat/ui/protocol-leak.tsx")).toEqual([APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE]); @@ -842,7 +842,7 @@ describe("Biome Grit include boundaries", () => { ]); expectOnlyPluginMessage( report, - "src/features/chat/app-server/mappers/message-stream/turn-items.ts", + "src/features/chat/app-server/mappers/thread-stream/turn-items.ts", APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE, ); expectOnlyPluginMessage( @@ -863,13 +863,13 @@ describe("Biome Grit include boundaries", () => { expect(pluginDiagnostics(report, "src/domain/threads/format.ts")).toEqual([]); expectOnlyPluginMessage( report, - "src/features/chat/domain/message-stream/selectors.ts", + "src/features/chat/domain/thread-stream/selectors.ts", "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", ); - expect(pluginDiagnostics(report, "src/features/chat/domain/message-stream/items.ts")).toEqual([]); + expect(pluginDiagnostics(report, "src/features/chat/domain/thread-stream/items.ts")).toEqual([]); expectOnlyPluginMessage( report, - "src/features/chat/domain/message-stream/outer-shapes.ts", + "src/features/chat/domain/thread-stream/outer-shapes.ts", "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", ); }); @@ -1239,7 +1239,7 @@ export type Item = TurnItem; `.trimStart(), ); await writeFile( - path.join(cwd, "src/features/chat/panel/surface/message-stream-presenter.ts"), + path.join(cwd, "src/features/chat/panel/surface/thread-stream-presenter.ts"), ` import { appServerUserInputResponse } from "../../../../../app-server/protocol/server-requests"; @@ -1263,7 +1263,7 @@ export const response = appServerUserInputResponse; `.trimStart(), ); await writeFile( - path.join(cwd, "src/features/chat/app-server/mappers/message-stream/turn-items.ts"), + path.join(cwd, "src/features/chat/app-server/mappers/thread-stream/turn-items.ts"), ` import type { TurnItem } from "../../../../../app-server/protocol/turn"; import { toolInventoryAppsFromAppInfos } from "../../../../../app-server/protocol/tool-inventory"; @@ -1307,7 +1307,7 @@ export const format = formatDate; `.trimStart(), ); await writeFile( - path.join(cwd, "src/features/chat/domain/message-stream/selectors.ts"), + path.join(cwd, "src/features/chat/domain/thread-stream/selectors.ts"), ` import type { ChatStateStore } from "../../application/state/store"; import type { Presenter } from 'src/features/chat/presentation/view'; @@ -1317,15 +1317,15 @@ export type View = Presenter; `.trimStart(), ); await writeFile( - path.join(cwd, "src/features/chat/domain/message-stream/items.ts"), + path.join(cwd, "src/features/chat/domain/thread-stream/items.ts"), ` -import type { MessageStreamItem } from "./item"; +import type { ThreadStreamItem } from "./item"; -export type Item = MessageStreamItem; +export type Item = ThreadStreamItem; `.trimStart(), ); await writeFile( - path.join(cwd, "src/features/chat/domain/message-stream/outer-shapes.ts"), + path.join(cwd, "src/features/chat/domain/thread-stream/outer-shapes.ts"), ` import type { ChatStateStore } from "../../application/state/store"; @@ -1457,16 +1457,16 @@ export async function read(client: AppServerClient): Promise { "src/app-server/protocol/server-requests.ts", "src/features/chat/application/pending-requests/pending-request-actions.ts", "src/features/chat/application/threads/history-controller.ts", - "src/features/chat/panel/surface/message-stream-presenter.ts", + "src/features/chat/panel/surface/thread-stream-presenter.ts", "src/features/chat/ui/protocol-leak.tsx", "src/features/chat/app-server/inbound/app-server-logs.ts", - "src/features/chat/app-server/mappers/message-stream/turn-items.ts", + "src/features/chat/app-server/mappers/thread-stream/turn-items.ts", "src/features/chat/app-server/inbound/server-request-protocol-leak.ts", "src/domain/threads/model.ts", "src/domain/threads/format.ts", - "src/features/chat/domain/message-stream/selectors.ts", - "src/features/chat/domain/message-stream/items.ts", - "src/features/chat/domain/message-stream/outer-shapes.ts", + "src/features/chat/domain/thread-stream/selectors.ts", + "src/features/chat/domain/thread-stream/items.ts", + "src/features/chat/domain/thread-stream/outer-shapes.ts", "src/app-server/protocol/diagnostics.ts", "src/shared/obsidian/thread-picker.ts", "src/domain/display/date.ts", @@ -1487,12 +1487,12 @@ export async function read(client: AppServerClient): Promise { async function tempBiomeWorkspace(plugins) { const cwd = await mkdtemp(path.join(tmpdir(), "codex-panel-grit-policy-")); await mkdir(cwd, { recursive: true }); - await mkdir(path.join(cwd, "src/features/chat/domain/message-stream"), { recursive: true }); + await mkdir(path.join(cwd, "src/features/chat/domain/thread-stream"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/application/state"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/application/threads"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/application/pending-requests"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/app-server/inbound"), { recursive: true }); - await mkdir(path.join(cwd, "src/features/chat/app-server/mappers/message-stream"), { recursive: true }); + await mkdir(path.join(cwd, "src/features/chat/app-server/mappers/thread-stream"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/host"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/host/bundles"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/host/session"), { recursive: true });