feat: integrate learning module
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 20:52:16 +08:00
parent 26b52d76e3
commit 01bee3188b
107 changed files with 6318 additions and 12063 deletions

View File

@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ArrowDown, Bot, ChevronDown, ChevronRight, CircleHelp, FileDiff, FileText, FolderOpen, Loader2, LockKeyhole, Mic, Plus, RotateCcw, Send, Settings as SettingsIcon, ShieldCheck, Square, Undo2, X } from 'lucide-react';
@@ -43,14 +43,11 @@ import { fetchWorksTokenUsage, transcribeWorksSpeech, WorksSquareApiError } from
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
import {
extractText,
extractThinkingSegments,
projectAssistantTextForCodex,
stripProcessMessagePrefix,
} from './message-utils';
import { deriveTaskSteps, type TaskStep } from './task-visualization';
import {
buildSessionTranscriptTimeline,
projectAssistantTextWithNativeParts,
} from './session-transcript-view-model';
import {
formatVisibleChatTranscript,
@@ -78,7 +75,6 @@ import type {
OpencodePermissionRequest,
OpencodeSession,
OpencodeSessionDiff,
OpencodeSessionStatus,
OpencodeSessionTranscriptState,
} from '@/types/opencode';
import { invokeIpc, saveMarkdownTranscript } from '@/lib/api-client';
@@ -406,6 +402,57 @@ function isUnauthorizedWorksError(error: unknown): boolean {
const chatComposerButtonClassName = 'motion-press border-0 bg-transparent text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]';
const chatComposerSendButtonClassName = 'motion-press border-0 bg-foreground text-background shadow-sm hover:bg-foreground/90 active:scale-[0.94]';
function AgentResponseLoader() {
const idPrefix = useId().replace(/:/gu, '');
const gradientId = `${idPrefix}-gradient`;
const shadowId = `${idPrefix}-shadow`;
return (
<svg
aria-hidden="true"
className="agent-response-loader"
data-testid="opencode-agent-response-loader"
focusable="false"
viewBox="0 0 100 100"
>
<defs>
<linearGradient id={gradientId} x1="14" y1="18" x2="86" y2="82" gradientUnits="userSpaceOnUse">
<stop stopColor="#f700a8" />
<stop offset="1" stopColor="#ff8000" />
</linearGradient>
<filter id={shadowId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
</defs>
<circle
className="agent-response-loader__ring agent-response-loader__ring--shadow"
cx="50"
cy="50"
r="35"
filter={`url(#${shadowId})`}
stroke={`url(#${gradientId})`}
strokeDasharray="180 800"
/>
<circle
className="agent-response-loader__ring agent-response-loader__ring--slow"
cx="50"
cy="50"
r="35"
stroke={`url(#${gradientId})`}
strokeDasharray="180 800"
/>
<circle
className="agent-response-loader__ring agent-response-loader__ring--fast"
cx="50"
cy="50"
r="35"
stroke={`url(#${gradientId})`}
strokeDasharray="26 54"
/>
</svg>
);
}
function hasDraggedFiles(event: DragEvent<HTMLElement>): boolean {
const types = Array.from(event.dataTransfer.types ?? []);
return types.includes('Files') || event.dataTransfer.files.length > 0;
@@ -491,35 +538,20 @@ function getNearestUserPrompt(messages: RawMessage[], index: number): string | u
return undefined;
}
function hasAssistantResponseSinceLatestUser(messages: RawMessage[]): boolean {
let latestUserIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.role === 'user') {
latestUserIndex = index;
break;
}
}
if (latestUserIndex < 0) return false;
return messages.slice(latestUserIndex + 1).some((message) => (
message?.role === 'assistant' && extractText(message).trim().length > 0
));
}
function getAssistantTextOverride(
message: RawMessage,
processSegments: string[],
userPrompt: string | undefined,
nativeProjection = projectAssistantTextForCodex(message),
): string | undefined {
if (message.role !== 'assistant') return undefined;
const text = extractText(message);
if (!text.trim()) return undefined;
const projectedText = nativeProjection;
let visibleText = stripEchoedUserPromptPrefix(
projectedText.finalText || (projectedText.processSegments.length > 0 ? '' : text),
userPrompt,
).trim();
// All assistant text blocks are user-facing response content, including a
// short acknowledgement or plan that appears immediately before a tool
// call. Keep the full text in the body instead of projecting pre-tool text
// into the collapsible execution trace.
let visibleText = stripEchoedUserPromptPrefix(text, userPrompt).trim();
let changed = normalizeProcessText(visibleText) !== normalizeProcessText(text);
if (!visibleText) return changed ? '' : undefined;
@@ -666,7 +698,6 @@ function buildTranscriptExecutionRuns({
streamingMessage: runActive ? streamingMessage : null,
streamingMessageKey: runActive ? streamingMessageKey : undefined,
streamingTools: runActive ? streamingTools : [],
omitLastStreamingMessageSegment: Boolean(runActive && streamingMessage),
includeThinking: showThinking,
nativeTranscript,
});
@@ -720,7 +751,6 @@ function buildLatestTranscriptExecutionRun({
streamingMessage,
streamingMessageKey,
streamingTools,
omitLastStreamingMessageSegment: Boolean(streamingMessage),
includeThinking: showThinking,
nativeTranscript,
});
@@ -750,10 +780,6 @@ function getExecutionRunForIndex(
}
const TRANSCRIPT_BOTTOM_THRESHOLD_PX = 48;
const SLOW_RESPONSE_THRESHOLD_SECONDS = 5;
const LONG_RESPONSE_THRESHOLD_SECONDS = 20;
const UPSTREAM_SATURATED_RETRY_NOTICE = '模型服务暂时繁忙,上游通道已满,正在自动重试。可以点击停止,稍后再试或切换模型。';
function isTranscriptNearBottom(element: HTMLDivElement): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= TRANSCRIPT_BOTTOM_THRESHOLD_PX;
}
@@ -762,14 +788,6 @@ function scrollTranscriptToBottom(element: HTMLDivElement): void {
element.scrollTop = element.scrollHeight;
}
function isUpstreamSaturatedRetryMessage(message: string | undefined): boolean {
const normalized = message?.trim().toLowerCase() ?? '';
if (!normalized) return false;
return normalized.includes('当前分组上游负载已饱和')
|| normalized.includes('rate_limit_exceeded')
|| (normalized.includes('upstream') && normalized.includes('saturat'));
}
function isQuotaExhaustedRetryMessage(message: string | undefined): boolean {
return getOpencodeErrorKind(message) === 'quota_exhausted';
}
@@ -784,23 +802,6 @@ function getAssistantErrorMessage(message: RawMessage | undefined): string | nul
return explicitMessage ?? (extractText(message).trim() || null);
}
function getUpstreamSaturatedRetryNotice(status: OpencodeSessionStatus | undefined): string | null {
return status?.type === 'retry' && isUpstreamSaturatedRetryMessage(status.message)
? UPSTREAM_SATURATED_RETRY_NOTICE
: null;
}
function getAssistantWaitingLabel(elapsedSeconds: number): string {
if (elapsedSeconds >= LONG_RESPONSE_THRESHOLD_SECONDS) return '等待时间较长';
if (elapsedSeconds >= SLOW_RESPONSE_THRESHOLD_SECONDS) return '仍在等待模型';
return '正在思考中';
}
function formatElapsedSeconds(elapsedSeconds: number): string {
return `${Math.max(0, Math.floor(elapsedSeconds))} 秒`;
}
type OpencodeChatPanelProps = {
variant?: 'sidebar' | 'main';
navigationDraft?: string;
@@ -925,8 +926,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const [voiceWaveformLevels, setVoiceWaveformLevels] = useState<number[]>(() => [...VOICE_WAVEFORM_IDLE_SCALES]);
const [voiceWaveformIsLive, setVoiceWaveformIsLive] = useState(false);
const [hasUnreadTranscript, setHasUnreadTranscript] = useState(false);
const [responseWaitStartedAt, setResponseWaitStartedAt] = useState<number | null>(null);
const [responseWaitNow, setResponseWaitNow] = useState(() => Date.now());
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false);
const [quotaUpgradePromptKey, setQuotaUpgradePromptKey] = useState<string | null>(null);
@@ -1073,10 +1072,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
&& confirmedQuotaFailureKey === quotaFailureKey;
const quotaUpgradePromptOpen = quotaFailureKey !== null
&& quotaUpgradePromptKey === quotaFailureKey;
const displayError = errorKind === 'quota_exhausted'
? (quotaExhaustedErrorActive ? QUOTA_EXHAUSTED_MESSAGE : null)
: error;
const upstreamSaturatedRetryNotice = getUpstreamSaturatedRetryNotice(selectedSessionStatus);
const visibleTranscriptMessages = useMemo(() => {
return buildVisibleTranscriptMessages(
sessionMessages,
@@ -1233,23 +1228,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
executionRuns.map((run) => [run.startIndex, run]),
), [executionRuns]);
const hasExecutionGraph = executionRuns.length > 0;
const hasLiveExecutionActivity = Boolean(
activeExecutionRun?.steps.some((step) => step.status === 'running')
|| streamingTools.length > 0
|| extractText(streamingMessage).trim().length > 0
|| extractThinkingSegments(streamingMessage).length > 0,
);
const awaitingFirstResponse = Boolean(
selectedSessionId
&& (selectedSessionRunning || selectedSessionStatus?.type === 'busy')
&& !hasAssistantResponseSinceLatestUser(visibleTranscriptMessages)
&& !hasLiveExecutionActivity
&& visiblePendingQuestions.length === 0
&& visiblePendingPermissions.length === 0,
);
const responseWaitElapsedSeconds = responseWaitStartedAt
? Math.max(0, Math.floor((responseWaitNow - responseWaitStartedAt) / 1000))
: 0;
const selectedProjectAgentAvatarSrc = selectedProjectAgent ? getAgentAvatarSrc(selectedProjectAgent.avatarId, selectedProjectAgent.avatarDataUrl) : undefined;
const selectedProjectAgentAvatarAlt = selectedProjectAgent ? `${selectedProjectAgent.name || selectedProjectAgent.roleName}头像` : '';
const selectedSessionDiff = useMemo(() => {
@@ -1416,22 +1394,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
setSelectedDiffPath(null);
}, [selectedDiffPath, sessionDiffs]);
useEffect(() => {
if (!awaitingFirstResponse) {
setResponseWaitStartedAt(null);
setResponseWaitNow(Date.now());
return undefined;
}
setResponseWaitStartedAt((current) => current ?? Date.now());
setResponseWaitNow(Date.now());
const timer = window.setInterval(() => {
setResponseWaitNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [awaitingFirstResponse, selectedSessionId]);
useEffect(() => {
if (voiceInputState !== 'recording' || voiceRecordingStartedAt === null) {
return undefined;
@@ -1460,7 +1422,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
}, [
streamingMessage,
streamingTools.length,
awaitingFirstResponse,
visiblePendingQuestions.length,
visibleTranscriptMessages,
compactionTimelineVersion,
@@ -2592,20 +2553,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
compactLayout ? 'p-3' : 'px-0 pb-0',
)}
>
{displayError && (
<div className="mx-4 mt-3 shrink-0 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive sm:mx-6">
{displayError}
</div>
)}
{upstreamSaturatedRetryNotice && (
<div
data-testid="opencode-upstream-saturated-notice"
className="mx-4 mt-3 shrink-0 rounded-md border border-amber-400/40 bg-amber-100 px-3 py-2 text-xs font-medium leading-5 text-amber-900 sm:mx-6"
>
{upstreamSaturatedRetryNotice}
</div>
)}
<div
className={cn('relative flex min-h-0 min-w-0 flex-1 gap-0', compactLayout ? 'flex-col' : 'flex-row')}
data-testid="opencode-chat-layout"
@@ -2654,15 +2601,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
>
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4 sm:pb-8">
{transcriptTimeline.length === 0 ? (
awaitingFirstResponse ? (
<div className="space-y-2">
<AssistantWaitingPlaceholder elapsedSeconds={responseWaitElapsedSeconds} assistantAvatarSrc={selectedProjectAgentAvatarSrc} assistantAvatarAlt={selectedProjectAgentAvatarAlt} />
</div>
) : (
<EmptyState
projectName={projectName}
/>
)
<EmptyState
projectName={projectName}
/>
) : (
<div className="space-y-6 sm:space-y-8">
{transcriptTimeline.map((timelineItem) => {
@@ -2696,8 +2637,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
message,
graphScopedAssistant ? messageExecutionRun?.processSegments ?? [] : [],
getNearestUserPrompt(visibleTranscriptMessages, index),
projectAssistantTextWithNativeParts(message, sessionTranscript)
?? projectAssistantTextForCodex(message),
);
const isGameAssetSelectionRound = hasGameAssetReviewContent(message);
const gameAssetReviewInvocation = parseGameAssetReviewInvocation(message);
@@ -2739,7 +2678,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
onToolExpandedChange={handleToolExpandedChange}
assistantAvatarSrc={selectedProjectAgentAvatarSrc}
assistantAvatarAlt={selectedProjectAgentAvatarAlt}
assistantAvatarMode={graphScopedAssistant ? 'embedded-trace' : 'visible'}
assistantAvatarMode="visible"
compactAssistantReply={graphScopedAssistant}
textOverride={visibleAssistantText}
suppressAssistantText={visibleAssistantText === ''}
@@ -2846,9 +2785,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
assistantAvatarAlt={selectedProjectAgentAvatarAlt}
/>
))}
{awaitingFirstResponse && (
<AssistantWaitingPlaceholder elapsedSeconds={responseWaitElapsedSeconds} assistantAvatarSrc={selectedProjectAgentAvatarSrc} assistantAvatarAlt={selectedProjectAgentAvatarAlt} />
)}
</div>
)}
</div>
@@ -3127,7 +3063,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
title="停止当前对话"
onClick={() => void handleAbortSession()}
>
<Square className="h-4 w-4 fill-current" />
<AgentResponseLoader />
</Button>
) : (
<Button
@@ -3274,30 +3210,65 @@ function PendingInteractionsDock({
onRejectQuestion: (requestId: string) => Promise<void>;
onReplyPermission: (requestId: string, reply: 'once' | 'always' | 'reject', message?: string) => Promise<void>;
}) {
const [expanded, setExpanded] = useState(false);
const contentId = `pending-interactions-${useId().replace(/:/gu, '')}`;
const interactionCount = questions.length + permissions.length;
return (
<div
data-testid="opencode-pending-interactions"
className="shrink-0 space-y-2 border-t bg-amber-500/5 p-3 border-border/70 sm:p-4"
className="shrink-0 rounded-lg border border-amber-500/25 bg-amber-500/5 p-2 sm:p-2.5"
>
<div className="flex items-center gap-2 text-xs font-semibold text-amber-700 text-amber-700">
<CircleHelp className="h-3.5 w-3.5" />
<span>继续前需要你确认</span>
</div>
{questions.map((question) => (
<PendingQuestionCard
key={question.id}
request={question}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
))}
{permissions.map((permission) => (
<PendingPermissionCard
key={permission.id}
request={permission}
onReply={onReplyPermission}
/>
))}
<button
type="button"
data-testid="opencode-pending-interactions-toggle"
className="flex min-h-9 w-full items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left text-amber-800 transition-colors hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600/40"
aria-expanded={expanded}
aria-controls={contentId}
aria-label={expanded ? '收起待确认事项' : '展开待确认事项'}
onClick={() => setExpanded((current) => !current)}
>
<span className="flex min-w-0 items-center gap-2">
<CircleHelp className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0">
<span className="block truncate text-xs font-semibold">继续前需要你确认</span>
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
{interactionCount} 项待处理
</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-amber-800/80">
<span>{expanded ? '收起' : '展开'}</span>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform duration-200 ease-out', expanded && 'rotate-180')} />
</span>
</button>
<DisclosureContent
id={contentId}
open={expanded}
className="mt-2"
innerClassName="min-h-0"
>
<div
data-testid="opencode-pending-interactions-list"
className="max-h-[min(42vh,28rem)] space-y-2 overflow-y-auto overscroll-contain pr-1"
>
{questions.map((question) => (
<PendingQuestionCard
key={question.id}
request={question}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
))}
{permissions.map((permission) => (
<PendingPermissionCard
key={permission.id}
request={permission}
onReply={onReplyPermission}
/>
))}
</div>
</DisclosureContent>
</div>
);
}
@@ -3666,27 +3637,6 @@ function ExecutionGraphTranscriptCard({
);
}
function AssistantWaitingPlaceholder({ elapsedSeconds, assistantAvatarSrc, assistantAvatarAlt }: { elapsedSeconds: number; assistantAvatarSrc?: string; assistantAvatarAlt?: string }) {
return (
<div className="flex gap-3 group" data-testid="assistant-waiting-placeholder" aria-live="polite">
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-foreground/15 bg-surface-subtle text-foreground bg-surface-subtle">
{assistantAvatarSrc ? <img src={assistantAvatarSrc} alt={assistantAvatarAlt} className="h-full w-full object-cover [image-rendering:pixelated]" /> : <Bot className="h-4 w-4" />}
</div>
<div className="flex w-full min-w-0 max-w-[80%] flex-col items-start space-y-2">
<div className="relative w-full rounded-2xl bg-surface-subtle px-4 py-3 text-foreground bg-surface-subtle">
<div className="flex min-w-0 items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
<span className="truncate font-medium">{getAssistantWaitingLabel(elapsedSeconds)}</span>
<span className="ml-auto shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{formatElapsedSeconds(elapsedSeconds)}
</span>
</div>
</div>
</div>
</div>
);
}
type PendingQuestionCardProps = {
request: OpencodeQuestionRequest;
onReply: (requestId: string, answers: string[][]) => Promise<void>;

View File

@@ -278,7 +278,13 @@ export function buildSessionTranscriptViewModel({
const partIDs = nativeTranscript.partOrderByMessageId[messageID] ?? [];
partIDs.forEach((partID) => {
const part = nativeTranscript.partsById[partID];
if (!part || part.type === 'text' || part.type === 'tool' || part.type === 'reasoning') return;
if (
!part
|| part.type === 'text'
|| part.type === 'tool'
|| part.type === 'reasoning'
|| part.type === 'compaction'
) return;
const kind = nativePartKind(part);
const status = nativePartStatus(part);
executionTrace.push({

View File

@@ -1,4 +1,4 @@
import { extractText, extractTextSegments, extractThinkingSegments, extractToolUse } from './message-utils';
import { extractThinkingSegments, extractToolUse } from './message-utils';
import {
getMessageVisibilityKey,
getStreamingMessageVisibilityKey,
@@ -6,7 +6,6 @@ import {
} from './chat-transcript';
import type { RawMessage, ToolStatus } from '@/types/chat';
import type { OpencodeSessionTranscriptState } from '@/types/opencode';
import { projectAssistantTextWithNativeParts } from './session-transcript-view-model';
export type TaskStepStatus = 'running' | 'completed' | 'error';
@@ -23,41 +22,12 @@ export interface TaskStep {
url?: string;
}
/**
* Detects the index of the "final reply" assistant message in a run segment.
*
* The reply is the last assistant message that carries non-empty text
* content, regardless of whether it ALSO carries tool calls. (Mixed
* `text + toolCall` replies are rare but real — the model can emit a parting
* text block alongside a final tool call. Treating such a message as the
* reply avoids mis-protecting an earlier narration as the "answer" and
* leaking the actual last text into the fold.)
*
* When this returns a non-negative index, the caller should avoid folding
* that message's text into the graph (it is the answer the user sees in the
* chat stream). When the run is still active (streaming) the final reply is
* produced via `streamingMessage` instead, so callers pass
* `hasStreamingReply = true` to skip protection and let every assistant-with-
* text message in history be folded into the graph as narration.
*/
export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply: boolean): number {
if (hasStreamingReply) return -1;
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
const message = messages[idx];
if (!message || message.role !== 'assistant') continue;
if (extractText(message).trim().length === 0) continue;
return idx;
}
return -1;
}
interface DeriveTaskStepsInput {
messages: RawMessage[];
messageIndexOffset?: number;
streamingMessage: unknown | null;
streamingMessageKey?: string;
streamingTools: ToolStatus[];
omitLastStreamingMessageSegment?: boolean;
includeThinking?: boolean;
nativeTranscript?: OpencodeSessionTranscriptState;
}
@@ -294,7 +264,6 @@ export function deriveTaskSteps({
streamingMessage,
streamingMessageKey,
streamingTools,
omitLastStreamingMessageSegment = false,
includeThinking = true,
nativeTranscript,
}: DeriveTaskStepsInput): TaskStep[] {
@@ -321,12 +290,6 @@ export function deriveTaskSteps({
? streamingMessage as RawMessage
: null;
// The final answer the user sees as a chat bubble. We avoid folding it into
// the graph to prevent duplication. When a run is still streaming, the
// reply lives in `streamingMessage`, so every pure-text assistant message in
// `messages` is treated as intermediate narration.
const replyIndex = findReplyMessageIndex(messages, streamMessage != null);
for (const [messageIndex, message] of messages.entries()) {
if (!message || message.role !== 'assistant') continue;
const stableMessageKey = getMessageVisibilityKey(
@@ -344,28 +307,10 @@ export function deriveTaskSteps({
});
}
const toolUses = extractToolUse(message);
// Fold any intermediate assistant text into the graph as a narration
// step — including text that lives on a mixed `text + toolCall` message.
// The narration step is emitted BEFORE the tool steps so the graph
// preserves the original ordering (the assistant "thinks out loud" and
// then invokes the tool).
const nativeTextProjection = projectAssistantTextWithNativeParts(message, nativeTranscript);
const narrationSegments = nativeTextProjection?.processSegments ?? extractTextSegments(message);
const graphNarrationSegments = nativeTextProjection
? narrationSegments
: messageIndex === replyIndex
? narrationSegments.slice(0, -1)
: narrationSegments;
appendDetailSegments(graphNarrationSegments, {
idPrefix: `history-message-${message.id || messageIndex}`,
label: 'Message',
kind: 'message',
running: false,
upsertStep,
});
toolUses.forEach((tool, index) => {
// Assistant text is user-facing response content. Keep it in the normal
// chat bubble even when the same message also invokes tools; only native
// thinking, tools, and runtime/system events belong to the folded trace.
extractToolUse(message).forEach((tool, index) => {
const input = tool.input as Record<string, unknown>;
const url = tool.name === 'web_fetch' && typeof input?.url === 'string' ? input.url : undefined;
upsertStep({
@@ -399,23 +344,6 @@ export function deriveTaskSteps({
});
}
// Stream-time narration should also appear in the execution graph so that
// intermediate process output stays in P1 instead of leaking into the
// assistant reply area.
const nativeStreamTextProjection = projectAssistantTextWithNativeParts(streamMessage, nativeTranscript);
const streamNarrationSegments = nativeStreamTextProjection?.processSegments ?? extractTextSegments(streamMessage);
const graphStreamNarrationSegments = nativeStreamTextProjection
? streamNarrationSegments
: omitLastStreamingMessageSegment
? streamNarrationSegments.slice(0, -1)
: streamNarrationSegments;
appendDetailSegments(graphStreamNarrationSegments, {
idPrefix: 'stream-message',
label: 'Message',
kind: 'message',
running: !omitLastStreamingMessageSegment,
upsertStep,
});
appendNativeExecutionParts(
nativeTranscript,
streamMessage.id,