Centralize chat domain derivations

This commit is contained in:
murashit 2026-06-23 20:21:10 +09:00
parent 28857413b3
commit 2992621354
21 changed files with 102 additions and 106 deletions

View file

@ -147,6 +147,15 @@ export function approvalActionKind(action: ApprovalAction): "accept" | "accept-s
return typeof action === "object" ? action.intent : action;
}
export function defaultPendingApprovalOptions(): PendingApprovalOption[] {
return [
{ id: "accept", label: "Allow", action: "accept", intent: "accept" },
{ id: "accept-session", label: "Allow session", action: "accept-session", intent: "accept-session" },
{ id: "decline", label: "Deny", action: "decline", intent: "decline" },
{ id: "cancel", label: "Cancel", action: "cancel", intent: "cancel" },
];
}
export function questionDefaultAnswer(question: PendingUserInputQuestion): string {
return question.options?.[0]?.label ?? "";
}

View file

@ -1,5 +1,5 @@
import type { ThreadConversationSummary } from "./transcript";
import { truncate } from "../../utils";
import { truncate } from "../../shared/text/preview";
const THREAD_TITLE_CONTEXT_MAX_CHARS = 4_000;
const DEFAULT_CONTEXT_PAGE_LIMIT = 20;

View file

@ -1,4 +1,4 @@
import { shortThreadId } from "../../utils";
import { shortThreadId } from "../../shared/id/thread-id";
import type { Thread } from "./model";
const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;

View file

@ -3,7 +3,7 @@ import type { ThreadCatalogEvent } from "../../../../workspace/thread-catalog";
import { threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/threads";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { runtimeConfigOrDefault } from "../../domain/runtime/effective";
import { serviceTierRequestForThreadStart } from "../../application/runtime/thread-settings-update";
import { serviceTierRequestForThreadStart } from "../runtime/thread-settings-update";
import { resumedThreadAction } from "../../application/state/actions";
import { captureChatServerActionClientScope, type ChatServerActionHost } from "./host";
import type { ChatState } from "../../application/state/root-reducer";

View file

@ -4,10 +4,10 @@ import {
type RuntimeServiceTierRequest,
type RuntimeSettingsPatch,
} from "../../../../domain/runtime/thread-settings";
import { currentModel, currentReasoningEffort, fastRuntimeServiceTierRequestValue } from "../../domain/runtime/effective";
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { currentModel, currentReasoningEffort, fastRuntimeServiceTierRequestValue } from "../../domain/runtime/effective";
import { effectiveCollaborationMode, type PendingRuntimeIntent } from "../../domain/runtime/intent";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
type TurnCollaborationModeWarning = "missing-model";
@ -34,16 +34,6 @@ export function serviceTierRequestForThreadStart(snapshot: RuntimeSnapshot, conf
return runtimeSettingsPatchValue(serviceTierTransportIntent(snapshot, config, "thread-start"));
}
function requestedTurnCollaborationModeSettings(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): TurnCollaborationModeSettings {
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
if (!model) return { collaborationMode: null, warning: "missing-model" };
return {
collaborationMode: runtimeCollaborationModeSettings(snapshot.selectedCollaborationMode, model, effort),
warning: null,
};
}
export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): PendingRuntimeSettingsPatch {
const update: RuntimeSettingsPatch = {};
const runtimeCollaborationModeSettings = requestedTurnCollaborationModeSettings(snapshot, config);
@ -79,6 +69,16 @@ export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: R
return { update, collaborationModeWarning: null };
}
function requestedTurnCollaborationModeSettings(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): TurnCollaborationModeSettings {
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
if (!model) return { collaborationMode: null, warning: "missing-model" };
return {
collaborationMode: runtimeCollaborationModeSettings(snapshot.selectedCollaborationMode, model, effort),
warning: null,
};
}
function runtimeTransportIntentFromPending<T>(intent: PendingRuntimeIntent<T>): RuntimeTransportIntent<T> {
if (intent.kind === "set") return { kind: "set", value: intent.value };
if (intent.kind === "resetToConfig") return { kind: "clear" };

View file

@ -7,7 +7,7 @@ import { autoReviewActive, fastModeActive, runtimeConfigOrDefault } from "../../
import {
pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch,
type PendingRuntimeSettingsPatch,
} from "./thread-settings-update";
} from "../../app-server/runtime/thread-settings-update";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { nextCollaborationMode, type CollaborationModeSelection, type RequestedFastMode } from "../../domain/runtime/intent";
import type { ChatAction, ChatState } from "../state/root-reducer";

View file

@ -1,4 +1,4 @@
import { upsertMessageStreamItemById } from "../../domain/message-stream/updates";
import { completeReasoningItems, upsertMessageStreamItemById } from "../../domain/message-stream/updates";
import type { MessageStreamItem, MessageStreamMessageItem } from "../../domain/message-stream/items";
import { normalizeProposedPlanMarkdown } from "../../domain/message-stream/format/proposed-plan";
import { messageStreamSemanticClassifications } from "../../domain/message-stream/semantics/classify";
@ -402,44 +402,23 @@ function appendItemOutputToMessageStream(
}
function completeReasoningInMessageStream(state: ChatMessageStreamState, turnId: string): ChatMessageStreamState {
const stableUpdate = completedReasoningItems(state.stableItems, turnId);
const stableItems = completeReasoningItems(state.stableItems, turnId);
const activeSegment = state.activeSegment;
if (activeSegment?.turnId !== turnId) {
return stableUpdate.changed ? patchObject(state, { stableItems: stableUpdate.items }) : state;
return stableItems !== state.stableItems ? patchObject(state, { stableItems }) : state;
}
const activeUpdate = completedReasoningItems(activeSegment.items, turnId);
const activeItems = completeReasoningItems(activeSegment.items, turnId);
return stableUpdate.changed || activeUpdate.changed
return stableItems !== state.stableItems || activeItems !== activeSegment.items
? patchObject(state, {
stableItems: stableUpdate.items,
activeSegment: activeUpdate.changed ? activeSegmentFromItems(activeSegment.turnId, activeUpdate.items) : activeSegment,
stableItems,
activeSegment: activeItems !== activeSegment.items ? activeSegmentFromItems(activeSegment.turnId, activeItems) : activeSegment,
})
: state;
}
function completedReasoningItems(
items: readonly MessageStreamItem[],
turnId: string,
): { items: readonly MessageStreamItem[]; changed: boolean } {
let changed = false;
const nextItems: MessageStreamItem[] = [];
for (const item of items) {
if (item.kind !== "reasoning" || item.turnId !== turnId) {
nextItems.push(item);
} else {
changed = true;
nextItems.push({
...item,
status: "completed",
executionState: "completed",
} satisfies MessageStreamItem);
}
}
return { items: changed ? nextItems : items, changed };
}
function updateActiveSegment(
state: ChatMessageStreamState,
turnId: string,

View file

@ -1,5 +1,5 @@
import type { ThreadGoal, ThreadGoalStatus } from "../../../../../domain/threads/goal";
import { truncate } from "../../../../../utils";
import { truncate } from "../../../../../shared/text/preview";
import type { GoalMessageStreamItem } from "../items";
const GOAL_SUMMARY_LIMIT = 140;

View file

@ -1,4 +1,4 @@
import { jsonPreview } from "../../../../../utils";
import { jsonPreview } from "../../../../../shared/text/preview";
interface DetailRow {
key: string;

View file

@ -10,6 +10,21 @@ 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<string, ForkCandidate>();
for (const { item, capabilities } of messageStreamSemanticClassifications(items)) {

View file

@ -1,4 +1,4 @@
import { truncate } from "../../../../../utils";
import { truncate } from "../../../../../shared/text/preview";
import { collabAgentStateExecutionState } from "../agent-state";
import type {
AgentRunSummary,

View file

@ -29,16 +29,22 @@ function mergeChanges(previous: MessageStreamItem, next: MessageStreamItem): rea
return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges;
}
export function completeReasoningItems(items: readonly MessageStreamItem[], turnId: string): MessageStreamItem[] {
return items.map((item) =>
item.kind === "reasoning" && item.turnId === turnId
? {
...item,
status: "completed",
executionState: "completed",
}
: item,
);
export function completeReasoningItems(items: readonly MessageStreamItem[], turnId: string): readonly MessageStreamItem[] {
let changed = false;
const nextItems: MessageStreamItem[] = [];
for (const item of items) {
if (item.kind !== "reasoning" || item.turnId !== turnId) {
nextItems.push(item);
continue;
}
changed = true;
nextItems.push({
...item,
status: "completed",
executionState: "completed",
} satisfies MessageStreamItem);
}
return changed ? nextItems : items;
}
export function attachHookRunsToTurn(

View file

@ -8,7 +8,7 @@ import {
type PendingUserInput,
} from "../../../../domain/pending-requests/model";
import type { MessageStreamItem, MessageStreamUserInputQuestionResult } from "../message-stream/items";
import { definedProp } from "../../../../utils";
import { definedProp } from "../../../../shared/object/defined-prop";
export function createApprovalResultItem(approval: PendingApproval, action: ApprovalAction): MessageStreamItem {
const kind = approvalActionKind(action);

View file

@ -1,7 +1,6 @@
import type { ChatAction, ChatDisclosureBucket, ChatDisclosureUiState } from "../../application/state/root-reducer";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamViewBlocks, type MessageStreamViewBlock } from "../../presentation/message-stream/view-model";
import { type ForkCandidate, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import { messageStreamSegmentsEmpty, type ForkCandidate, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import type { MessageStreamRollbackCandidate } from "../../application/state/message-stream";
import type { MessageStreamContext } from "../../ui/message-stream/context";
import type { ChatPanelMessageStreamShellState } from "../shell-state";
@ -142,7 +141,7 @@ function messageStreamStateProjection(
state.forkCandidates,
state.implementPlanTarget,
);
const pendingRequests = messageStreamBlockItemsEmpty(state.stableItems, state.activeItems) ? null : pendingRequestBlockFromState(state);
const pendingRequests = messageStreamSegmentsEmpty(state.stableItems, state.activeItems) ? null : pendingRequestBlockFromState(state);
return {
activeThreadId: state.activeThreadId,
@ -208,7 +207,3 @@ function pendingRequestBlockFromState(
snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromChatState(state)),
};
}
function messageStreamBlockItemsEmpty(stableItems: readonly MessageStreamItem[], activeItems: readonly MessageStreamItem[]): boolean {
return stableItems.length === 0 && activeItems.length === 0;
}

View file

@ -1,5 +1,6 @@
import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics/types";
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 { messageStreamLayoutBlocks, type MessageStreamItemAnnotations, type MessageStreamLayoutBlock } from "./layout";
import { detailView, type DetailView } from "./detail-view";
@ -132,7 +133,7 @@ export function messageStreamViewBlocks(input: MessageStreamPresentationBlockInp
function messageStreamPresentationBlocks(input: MessageStreamPresentationBlockInput): MessageStreamPresentationBlock[] {
const headerBlocks = historyPresentationBlocks(input);
if (messageStreamBlockItemsEmpty(input)) {
if (messageStreamItemsEmpty(input)) {
return [...headerBlocks, { kind: "empty", key: "empty" }];
}
@ -180,11 +181,6 @@ function pendingRequestPresentationBlocks(input: MessageStreamPresentationBlockI
];
}
function messageStreamBlockItemsEmpty(input: MessageStreamPresentationBlockInput): boolean {
if (!input.stableItems && !input.activeItems) return input.items.length === 0;
return (input.stableItems?.length ?? 0) === 0 && (input.activeItems?.length ?? 0) === 0;
}
function layoutBlocksForInput(input: MessageStreamPresentationBlockInput): MessageStreamLayoutBlock[] {
const { activeTurnId } = input;
if (!activeTurnId || !input.stableItems || !input.activeItems) {

View file

@ -1,5 +1,6 @@
import {
approvalActionKind,
defaultPendingApprovalOptions,
type ApprovalAction,
type PendingApproval,
type PendingMcpElicitation,
@ -136,13 +137,16 @@ function pendingApprovalViewModel(approval: PendingApproval): PendingApprovalVie
function approvalActionOptions(approval: PendingApproval): ApprovalActionOption[] {
const options = approval.actionOptions;
if (!options || options.length === 0) return defaultApprovalActionOptions();
return options.map((option) => ({
return (options && options.length > 0 ? options : defaultPendingApprovalOptions()).map(approvalActionOptionViewModel);
}
function approvalActionOptionViewModel(option: { id: string; label: string; action: ApprovalAction }): ApprovalActionOption {
return {
id: option.id,
label: option.label,
action: option.action,
className: approvalActionClassName(option.action),
}));
};
}
function pendingUserInputViewModel(input: PendingUserInput): PendingUserInputViewModel {
@ -164,15 +168,6 @@ function pendingUserInputViewModel(input: PendingUserInput): PendingUserInputVie
};
}
function defaultApprovalActionOptions(): ApprovalActionOption[] {
return [
{ id: "accept", label: "Allow", action: "accept", className: "mod-cta" },
{ id: "accept-session", label: "Allow session", action: "accept-session", className: "" },
{ id: "decline", label: "Deny", action: "decline", className: "mod-warning" },
{ id: "cancel", label: "Cancel", action: "cancel", className: "" },
];
}
function approvalActionClassName(action: ApprovalAction): string {
const kind = approvalActionKind(action);
if (kind === "accept") return "mod-cta";

View file

@ -0,0 +1,3 @@
export function shortThreadId(threadId: string): string {
return threadId.length <= 8 ? threadId : threadId.slice(0, 8);
}

View file

@ -0,0 +1,6 @@
export function definedProp<Key extends string, Value>(
key: Key,
value: Value | null | undefined,
): Record<Key, Value> | Record<string, never> {
return value === null || value === undefined ? {} : ({ [key]: value } as Record<Key, Value>);
}

View file

@ -0,0 +1,11 @@
export function truncate(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}...` : value;
}
export function jsonPreview(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View file

@ -1,26 +1,7 @@
export function truncate(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}...` : value;
}
export function jsonPreview(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function definedProp<Key extends string, Value>(
key: Key,
value: Value | null | undefined,
): Record<Key, Value> | Record<string, never> {
return value === null || value === undefined ? {} : ({ [key]: value } as Record<Key, Value>);
}
export { definedProp } from "./shared/object/defined-prop";
export { jsonPreview, truncate } from "./shared/text/preview";
export { shortThreadId } from "./shared/id/thread-id";
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function shortThreadId(threadId: string): string {
return threadId.length <= 8 ? threadId : threadId.slice(0, 8);
}

View file

@ -23,7 +23,7 @@ import { resetRuntimeIntentToConfig, setRuntimeIntentValue } from "../../src/fea
import {
pendingRuntimeSettingsPatch,
serviceTierRequestForThreadStart,
} from "../../src/features/chat/application/runtime/thread-settings-update";
} from "../../src/features/chat/app-server/runtime/thread-settings-update";
import { contextSummary, rateLimitSummary } from "../../src/features/chat/presentation/runtime/status";
describe("runtime settings", () => {