diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 64b3c576..304c25d0 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1847,18 +1847,70 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM return converted } +// convertRuntimePlanTodoItem 将 session 计划中的 legacy todo 项映射为 gateway 展示结构。 +func convertRuntimePlanTodoItem(item agentsession.TodoItem) gateway.PlanTodoItem { + required := false + if item.Required != nil { + required = *item.Required + } + return gateway.PlanTodoItem{ + ID: strings.TrimSpace(item.ID), + Content: strings.TrimSpace(item.Content), + Status: strings.TrimSpace(string(item.Status)), + Required: required, + Artifacts: append([]string(nil), item.Artifacts...), + FailureReason: strings.TrimSpace(item.FailureReason), + BlockedReason: strings.TrimSpace(string(item.BlockedReason)), + Revision: item.Revision, + } +} + +// convertRuntimePlanArtifact 将 runtime 当前计划快照映射为 gateway 公开契约。 +func convertRuntimePlanArtifact(plan *agentsession.PlanArtifact) *gateway.PlanArtifact { + if plan == nil { + return nil + } + converted := &gateway.PlanArtifact{ + ID: strings.TrimSpace(plan.ID), + Revision: plan.Revision, + Status: strings.TrimSpace(string(plan.Status)), + Spec: gateway.PlanSpec{ + Goal: strings.TrimSpace(plan.Spec.Goal), + Steps: append([]string(nil), plan.Spec.Steps...), + Constraints: append([]string(nil), plan.Spec.Constraints...), + OpenQuestions: append([]string(nil), plan.Spec.OpenQuestions...), + }, + Summary: gateway.PlanSummaryView{ + Goal: strings.TrimSpace(plan.Summary.Goal), + KeySteps: append([]string(nil), plan.Summary.KeySteps...), + Constraints: append([]string(nil), plan.Summary.Constraints...), + ActiveTodoIDs: append([]string(nil), plan.Summary.ActiveTodoIDs...), + }, + CreatedAt: plan.CreatedAt, + UpdatedAt: plan.UpdatedAt, + } + if len(plan.Spec.Todos) > 0 { + converted.Spec.Todos = make([]gateway.PlanTodoItem, 0, len(plan.Spec.Todos)) + for _, item := range plan.Spec.Todos { + converted.Spec.Todos = append(converted.Spec.Todos, convertRuntimePlanTodoItem(item)) + } + } + return converted +} + // convertRuntimeSessionToGatewaySession 将 runtime 会话结构映射为 gateway 契约返回值。 func convertRuntimeSessionToGatewaySession(session agentsession.Session) gateway.Session { return gateway.Session{ - ID: strings.TrimSpace(session.ID), - Title: strings.TrimSpace(session.Title), - CreatedAt: session.CreatedAt, - UpdatedAt: session.UpdatedAt, - Workdir: strings.TrimSpace(session.Workdir), - Provider: strings.TrimSpace(session.Provider), - Model: strings.TrimSpace(session.Model), - AgentMode: strings.TrimSpace(string(session.AgentMode)), - Messages: convertSessionMessages(session.Messages), + ID: strings.TrimSpace(session.ID), + Title: strings.TrimSpace(session.Title), + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + Workdir: strings.TrimSpace(session.Workdir), + Provider: strings.TrimSpace(session.Provider), + Model: strings.TrimSpace(session.Model), + AgentMode: strings.TrimSpace(string(session.AgentMode)), + CurrentPlan: convertRuntimePlanArtifact(session.CurrentPlan), + Messages: convertSessionMessages(session.Messages), } } diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index 117750fc..dc52148c 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -1471,6 +1471,44 @@ func TestConvertGatewayRunInputAndSessionHelpers(t *testing.T) { } } +func TestConvertRuntimeSessionToGatewaySessionIncludesCurrentPlan(t *testing.T) { + required := true + session := agentsession.New("plan session") + session.AgentMode = agentsession.AgentModePlan + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-1", + Revision: 2, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "修复 web plan 展示", + Steps: []string{"发事件", "渲染卡片"}, + Constraints: []string{"不创建执行 todo"}, + OpenQuestions: []string{"是否需要审批按钮"}, + Todos: []agentsession.TodoItem{{ + ID: "todo-1", + Content: "legacy todo", + Status: agentsession.TodoStatusPending, + Required: &required, + }}, + }, + Summary: agentsession.SummaryView{ + Goal: "修复 web plan 展示", + KeySteps: []string{"发事件"}, + }, + } + + converted := convertRuntimeSessionToGatewaySession(session) + if converted.CurrentPlan == nil { + t.Fatal("expected current_plan to be present") + } + if converted.CurrentPlan.ID != "plan-1" || converted.CurrentPlan.Spec.Goal != "修复 web plan 展示" { + t.Fatalf("unexpected current_plan: %+v", converted.CurrentPlan) + } + if len(converted.CurrentPlan.Spec.Todos) != 1 || !converted.CurrentPlan.Spec.Todos[0].Required { + t.Fatalf("unexpected plan todos: %+v", converted.CurrentPlan.Spec.Todos) + } +} + func TestGatewayRuntimePortBridgeDeleteSession(t *testing.T) { t.Run("success", func(t *testing.T) { store := &bridgeSessionStoreStub{ diff --git a/internal/context/source_plan_mode.go b/internal/context/source_plan_mode.go index 59ab26d6..d86c1098 100644 --- a/internal/context/source_plan_mode.go +++ b/internal/context/source_plan_mode.go @@ -34,7 +34,7 @@ func (planModeContextSource) Sections(ctx context.Context, input BuildInput) ([] if stage == "plan" { noPlanHint := promptSection{ Title: "Current Plan", - Content: "status: none\n\nNo current plan exists. You must create one by outputting a `plan_spec` + `summary_candidate` JSON before this turn ends.", + Content: "status: none\n\nNo current plan exists. You must create one before this turn ends by outputting a visible Markdown plan, followed by one compact `plan_spec` + `summary_candidate` JSON object inside an HTML comment.", } sections = append(sections, noPlanHint) } diff --git a/internal/context/source_plan_mode_test.go b/internal/context/source_plan_mode_test.go index 25cae39d..76e19dd4 100644 --- a/internal/context/source_plan_mode_test.go +++ b/internal/context/source_plan_mode_test.go @@ -50,6 +50,37 @@ func TestPlanModeSectionsReturnsWithoutPlanWhenNil(t *testing.T) { } } +func TestPlanModeSectionsNoCurrentPlanUsesHTMLCommentContract(t *testing.T) { + t.Parallel() + + source := planModeContextSource{} + sections, err := source.Sections(context.Background(), BuildInput{ + AgentMode: agentsession.AgentModePlan, + PlanStage: "plan", + CurrentPlan: nil, + }) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + var currentPlanContent string + for _, section := range sections { + if section.Title == "Current Plan" { + currentPlanContent = section.Content + break + } + } + if currentPlanContent == "" { + t.Fatal("expected Current Plan section when plan stage has no current plan") + } + if !strings.Contains(currentPlanContent, "visible Markdown plan") || + !strings.Contains(currentPlanContent, "inside an HTML comment") { + t.Fatalf("Current Plan hint = %q, want Markdown plus HTML comment JSON contract", currentPlanContent) + } + if strings.Contains(currentPlanContent, "outputting a `plan_spec` + `summary_candidate` JSON") { + t.Fatalf("Current Plan hint should not use old JSON-only wording: %q", currentPlanContent) + } +} + func TestPlanModeSectionsContextError(t *testing.T) { t.Parallel() diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 9812b0f8..657b3a51 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -680,6 +680,46 @@ type SessionMessage struct { IsError bool `json:"is_error,omitempty"` } +// PlanTodoItem 表示计划正文中保留的 legacy todo 项,仅用于展示和兼容读取。 +type PlanTodoItem struct { + ID string `json:"id"` + Content string `json:"content"` + Status string `json:"status,omitempty"` + Required bool `json:"required,omitempty"` + Artifacts []string `json:"artifacts,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` + BlockedReason string `json:"blocked_reason,omitempty"` + Revision int64 `json:"revision,omitempty"` +} + +// PlanSpec 表示当前完整计划的公开结构。 +type PlanSpec struct { + Goal string `json:"goal"` + Steps []string `json:"steps,omitempty"` + Constraints []string `json:"constraints,omitempty"` + Todos []PlanTodoItem `json:"todos,omitempty"` + OpenQuestions []string `json:"open_questions,omitempty"` +} + +// PlanSummaryView 表示完整计划的紧凑摘要。 +type PlanSummaryView struct { + Goal string `json:"goal"` + KeySteps []string `json:"key_steps,omitempty"` + Constraints []string `json:"constraints,omitempty"` + ActiveTodoIDs []string `json:"active_todo_ids,omitempty"` +} + +// PlanArtifact 表示会话当前计划快照。 +type PlanArtifact struct { + ID string `json:"id"` + Revision int `json:"revision"` + Status string `json:"status"` + Spec PlanSpec `json:"spec"` + Summary PlanSummaryView `json:"summary"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // Session 表示网关视角的会话详情。 type Session struct { // ID 是会话标识。 @@ -698,6 +738,8 @@ type Session struct { Model string `json:"model,omitempty"` // AgentMode 是会话当前 Agent 工作模式。 AgentMode string `json:"agent_mode,omitempty"` + // CurrentPlan 是会话当前结构化计划快照。 + CurrentPlan *PlanArtifact `json:"current_plan,omitempty"` // Messages 是会话消息快照。 Messages []SessionMessage `json:"messages,omitempty"` } diff --git a/internal/promptasset/assets_test.go b/internal/promptasset/assets_test.go index 4523e7fa..85f1db82 100644 --- a/internal/promptasset/assets_test.go +++ b/internal/promptasset/assets_test.go @@ -94,6 +94,12 @@ func TestPlanModePromptTemplates(t *testing.T) { strings.Contains(PlanModePrompt("plan"), "must not be empty") { t.Fatalf("expected plan prompt not to require execution todo ownership") } + if strings.Contains(PlanModePrompt("plan"), "Only output a JSON object") { + t.Fatalf("expected plan prompt not to require JSON-only output") + } + if !strings.Contains(PlanModePrompt("plan"), "inside an HTML comment") { + t.Fatalf("expected plan prompt to require machine-readable JSON in an HTML comment") + } if !strings.Contains(PlanModePrompt("plan"), "Do not create execution todos in plan mode") { t.Fatalf("expected plan prompt to keep todos in build execution") } diff --git a/internal/promptasset/templates/context/plan_mode_plan.md b/internal/promptasset/templates/context/plan_mode_plan.md index 4cfd7142..b5c422e6 100644 --- a/internal/promptasset/templates/context/plan_mode_plan.md +++ b/internal/promptasset/templates/context/plan_mode_plan.md @@ -3,9 +3,9 @@ You are currently in the planning stage. - You may research, analyze, ask clarifying questions, and produce a plan. - Do not perform any write action in this stage. - Do not rewrite the current full plan unless the conversation clearly requires creating or replacing the plan itself. -- **If no Current Plan section is attached, your first priority is to produce a plan.** The user has entered planning mode expecting a structured plan. Research the codebase as needed, then output a complete `plan_spec` + `summary_candidate` JSON. Do not end the turn with only a conversational answer when there is no existing plan. +- **If no Current Plan section is attached, your first priority is to produce a plan.** The user has entered planning mode expecting a structured plan. Research the codebase as needed, then output a visible Markdown plan followed by one compact machine-readable JSON object containing `plan_spec` and `summary_candidate` inside an HTML comment. Do not end the turn with only a conversational answer when there is no existing plan. - If a Current Plan is already present, you may refine, replace, or discuss it. When the user asks a clarifying question or wants to explore options without committing to a new plan revision, you may answer conversationally without outputting planning JSON. -- Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan. +- When explicitly creating or rewriting the current full plan, output the visible plan as Markdown first, then append the machine-readable JSON inside an HTML comment, not in a fenced code block. - `plan_spec` must include `goal`, `steps`, `constraints`, and `open_questions`. - `plan_spec.todos` is optional legacy data. Do not create execution todos in plan mode; build mode will create and maintain runtime todos when implementation starts. - `summary_candidate` must include `goal`, `key_steps`, and `constraints`. diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 5ba148e1..24c17fb4 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -5,6 +5,7 @@ import ( "neo-code/internal/runtime/acceptgate" "neo-code/internal/runtime/controlplane" + agentsession "neo-code/internal/session" ) // EventType 标识 runtime 事件类型。 @@ -101,6 +102,12 @@ type AcceptanceDecidedPayload struct { Results []acceptgate.CheckResult `json:"results,omitempty"` } +// PlanUpdatedPayload 描述 plan 模式生成或改写后的结构化计划快照。 +type PlanUpdatedPayload struct { + CurrentPlan *agentsession.PlanArtifact `json:"current_plan"` + DisplayText string `json:"display_text,omitempty"` +} + // LedgerReconciledPayload 为账本对账预留负载。 type LedgerReconciledPayload struct { AttemptSeq int `json:"attempt_seq"` @@ -320,6 +327,8 @@ const ( EventThinkingDelta EventType = "thinking_delta" // EventAgentDone 表示 assistant 正常结束。 EventAgentDone EventType = "agent_done" + // EventPlanUpdated 表示当前结构化计划已生成或更新。 + EventPlanUpdated EventType = "plan_updated" // EventToolStart 表示工具开始执行。 EventToolStart EventType = "tool_start" // EventToolResult 表示工具执行完成并写回会话。 diff --git a/internal/runtime/planning.go b/internal/runtime/planning.go index 97690541..0e4bbc31 100644 --- a/internal/runtime/planning.go +++ b/internal/runtime/planning.go @@ -174,8 +174,9 @@ func decodePlanTurnOutput(jsonText string) (planTurnOutput, error) { // stripPlanningJSONObjectText 从原始回复中移除结构化 JSON,并尽量保留自然段落间距。 func stripPlanningJSONObjectText(text string, candidate extractedPlanningJSONObject) string { - before := strings.TrimSpace(text[:candidate.Start]) - after := strings.TrimSpace(text[candidate.End:]) + start, end := planningJSONObjectRemovalRange(text, candidate) + before := strings.TrimSpace(text[:start]) + after := strings.TrimSpace(text[end:]) switch { case before == "": return after @@ -186,6 +187,28 @@ func stripPlanningJSONObjectText(text string, candidate extractedPlanningJSONObj } } +// planningJSONObjectRemovalRange 扩展结构化 JSON 的剥离范围,避免 HTML 注释外壳泄漏到可见计划正文。 +func planningJSONObjectRemovalRange(text string, candidate extractedPlanningJSONObject) (int, int) { + start := candidate.Start + end := candidate.End + if start < 0 || end < start || end > len(text) { + return candidate.Start, candidate.End + } + + prefix := text[:start] + open := strings.LastIndex(prefix, "") + if closeOffset < 0 || strings.TrimSpace(suffix[:closeOffset]) != "" { + return start, end + } + return open, end + closeOffset + len("-->") +} + // extractPlanningJSONObjectIfPresent 在文本中提取首个满足指定顶层键契约的 JSON 对象。 func extractPlanningJSONObjectIfPresent(text string, requiredKey string) (extractedPlanningJSONObject, bool) { start := strings.IndexByte(text, '{') @@ -258,11 +281,43 @@ func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput return plan, nil } -// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。 -func resolvePlanDisplayText(output planTurnOutput, spec agentsession.PlanSpec) string { - display := strings.TrimSpace(output.DisplayText) - if display != "" { - return display +// renderPlanMarkdown 将结构化计划渲染为前端可直接展示的规范 Markdown。 +func renderPlanMarkdown(spec agentsession.PlanSpec) string { + spec, err := agentsession.NormalizePlanSpec(spec) + if err != nil { + return "" + } + sections := make([]string, 0, 4) + sections = append(sections, "### 目标\n\n"+spec.Goal) + if len(spec.Steps) > 0 { + sections = append(sections, "### 实施步骤\n\n"+renderMarkdownBulletList(spec.Steps)) + } + if len(spec.Constraints) > 0 { + sections = append(sections, "### 约束\n\n"+renderMarkdownBulletList(spec.Constraints)) + } + if len(spec.OpenQuestions) > 0 { + sections = append(sections, "### 未决问题\n\n"+renderMarkdownBulletList(spec.OpenQuestions)) + } + return strings.TrimSpace(strings.Join(sections, "\n\n")) +} + +// renderMarkdownBulletList 将计划字段中的字符串列表渲染为 Markdown 无序列表。 +func renderMarkdownBulletList(items []string) string { + lines := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + lines = append(lines, "- "+trimmed) + } + return strings.Join(lines, "\n") +} + +// resolvePlanDisplayText 在解析出机器可读计划后固定返回规范化计划正文,不保留模型额外说明。 +func resolvePlanDisplayText(_ planTurnOutput, spec agentsession.PlanSpec) string { + if markdown := renderPlanMarkdown(spec); markdown != "" { + return markdown } return strings.TrimSpace(agentsession.RenderPlanContent(spec)) } diff --git a/internal/runtime/planning_test.go b/internal/runtime/planning_test.go index 1c74fb8c..40693353 100644 --- a/internal/runtime/planning_test.go +++ b/internal/runtime/planning_test.go @@ -133,6 +133,29 @@ func TestMaybeParsePlanTurnOutputIgnoresBraceTextAndKeepsExplanation(t *testing. } } +func TestMaybeParsePlanTurnOutputStripsHTMLCommentJSON(t *testing.T) { + t.Parallel() + + markdown := "### Goal\n\nShip plan display\n\n### Steps\n\n- Align prompts" + text := markdown + "\n\n" + output, ok, err := maybeParsePlanTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(text)}, + }) + if err != nil { + t.Fatalf("maybeParsePlanTurnOutput() error = %v", err) + } + if !ok { + t.Fatal("expected HTML comment plan JSON to be detected") + } + if output.PlanSpec.Goal != "Ship plan display" { + t.Fatalf("PlanSpec.Goal = %q", output.PlanSpec.Goal) + } + if output.DisplayText != markdown { + t.Fatalf("DisplayText = %q, want %q", output.DisplayText, markdown) + } +} + func TestMaybeParsePlanTurnOutputFallsBackWhenSummaryIsInvalid(t *testing.T) { t.Parallel() diff --git a/internal/runtime/run.go b/internal/runtime/run.go index e3b969ba..77c7f296 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -380,6 +380,10 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, planMessage); err != nil { return s.handleRunError(err) } + s.emitRunScoped(ctx, EventPlanUpdated, &state, PlanUpdatedPayload{ + CurrentPlan: nextPlan.Clone(), + DisplayText: resolvePlanDisplayText(planOutput, nextPlan.Spec), + }) s.emitRunScoped(ctx, EventAgentDone, &state, planMessage) return nil } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 4a85d729..5475ef24 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3943,6 +3943,7 @@ func TestServiceRunPlanModePersistsDraftPlan(t *testing.T) { }); err != nil { t.Fatalf("Run() error = %v", err) } + events := collectRuntimeEvents(service.Events()) saved := onlySession(t, store) if saved.AgentMode != agentsession.AgentModePlan { @@ -3978,9 +3979,26 @@ func TestServiceRunPlanModePersistsDraftPlan(t *testing.T) { if got := renderPartsForTest(saved.Messages[2].Parts); !strings.Contains(got, "目标") { t.Fatalf("expected rendered plan content, got %q", got) } + var planEvent RuntimeEvent + for _, event := range events { + if event.Type == EventPlanUpdated { + planEvent = event + break + } + } + if planEvent.Type != EventPlanUpdated { + t.Fatalf("expected %s event, got events %+v", EventPlanUpdated, eventTypes(events)) + } + payload, ok := planEvent.Payload.(PlanUpdatedPayload) + if !ok { + t.Fatalf("plan event payload = %T, want PlanUpdatedPayload", planEvent.Payload) + } + if payload.CurrentPlan == nil || payload.CurrentPlan.Spec.Goal != "为 runtime 引入 plan/build 模式" { + t.Fatalf("unexpected plan event payload: %+v", payload.CurrentPlan) + } } -func TestServiceRunPlanModeShowsExplanationTextOutsidePlanningJSON(t *testing.T) { +func TestServiceRunPlanModePersistsCanonicalMarkdownInsteadOfPlanningJSON(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) @@ -4032,8 +4050,12 @@ func TestServiceRunPlanModeShowsExplanationTextOutsidePlanningJSON(t *testing.T) if strings.Contains(got, "\"plan_spec\"") { t.Fatalf("expected persisted assistant text to strip planning JSON, got %q", got) } - if !strings.Contains(got, "先确认范围") || !strings.Contains(got, "继续执行") { - t.Fatalf("expected prose explanation to be preserved, got %q", got) + if strings.Contains(got, "先确认范围") || strings.Contains(got, "继续执行") { + t.Fatalf("expected model prose to be replaced by canonical markdown, got %q", got) + } + if !strings.Contains(got, "### 目标") || !strings.Contains(got, "Preserve prose around planning JSON") || + !strings.Contains(got, "### 实施步骤") || !strings.Contains(got, "- persist plan") { + t.Fatalf("expected canonical markdown plan, got %q", got) } } diff --git a/internal/runtime/todo_bootstrap.go b/internal/runtime/todo_bootstrap.go index 3f05f933..75c1c1da 100644 --- a/internal/runtime/todo_bootstrap.go +++ b/internal/runtime/todo_bootstrap.go @@ -63,8 +63,9 @@ plan_bootstrap_required: You are in plan mode but no current plan exists. Before research, analysis, or conversational response, you MUST complete the following: 1. Research the codebase as needed using read-only tools. -2. Output a JSON object containing "plan_spec" and "summary_candidate" that defines the current plan. -3. Focus plan_spec on goal, steps, constraints, and open_questions. Do not create execution todos in plan mode. +2. Output the visible plan as Markdown first, using short sections for goal, steps, constraints, and open questions. +3. After the Markdown, include one compact machine-readable JSON object containing "plan_spec" and "summary_candidate". Put this JSON inside an HTML comment, not in a fenced code block. +4. Focus plan_spec on goal, steps, constraints, and open_questions. Do not create execution todos in plan mode. Do not end this turn without producing a plan.` diff --git a/web/src/api/protocol.ts b/web/src/api/protocol.ts index 69c4164e..96bab0ca 100644 --- a/web/src/api/protocol.ts +++ b/web/src/api/protocol.ts @@ -77,6 +77,7 @@ export const EventType = { UserMessage: "user_message", AgentChunk: "agent_chunk", AgentDone: "agent_done", + PlanUpdated: "plan_updated", ToolStart: "tool_start", ToolResult: "tool_result", ToolDiff: "tool_diff", @@ -296,6 +297,47 @@ export interface ToolCall { arguments: string; } +export interface PlanTodoItem { + id: string; + content: string; + status?: string; + required?: boolean; + artifacts?: string[]; + failure_reason?: string; + blocked_reason?: string; + revision?: number; +} + +export interface PlanSpec { + goal: string; + steps?: string[]; + constraints?: string[]; + todos?: PlanTodoItem[]; + open_questions?: string[]; +} + +export interface PlanSummaryView { + goal: string; + key_steps?: string[]; + constraints?: string[]; + active_todo_ids?: string[]; +} + +export interface PlanArtifact { + id: string; + revision: number; + status: string; + spec: PlanSpec; + summary: PlanSummaryView; + created_at: string; + updated_at: string; +} + +export interface PlanUpdatedPayload { + current_plan?: PlanArtifact; + display_text?: string; +} + /** 会话详情 */ export interface Session { id: string; @@ -306,6 +348,7 @@ export interface Session { provider?: string; model?: string; agent_mode?: string; + current_plan?: PlanArtifact; messages?: SessionMessage[]; } diff --git a/web/src/components/chat/MessageItem.test.tsx b/web/src/components/chat/MessageItem.test.tsx index c44f293b..af6b4ccb 100644 --- a/web/src/components/chat/MessageItem.test.tsx +++ b/web/src/components/chat/MessageItem.test.tsx @@ -1,46 +1,168 @@ -import { describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' -import MessageItem from './MessageItem' +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import MessageItem from "./MessageItem"; -vi.mock('./ToolCallCard', () => ({ default: () =>
tool-card
})) -vi.mock('./AcceptanceMessage', () => ({ default: () =>
acceptance-card
})) -vi.mock('./CodeBlock', () => ({ default: ({ code }: { code: string }) =>
{code}
})) -vi.mock('./MarkdownContent', () => ({ default: ({ content }: { content: string }) => {content} })) -vi.mock('@/context/RuntimeProvider', () => ({ useGatewayAPI: () => null })) +vi.mock("./ToolCallCard", () => ({ default: () =>
tool-card
})); +vi.mock("./AcceptanceMessage", () => ({ + default: () =>
acceptance-card
, +})); +vi.mock("./CodeBlock", () => ({ + default: ({ code }: { code: string }) =>
{code}
, +})); +vi.mock("./MarkdownContent", () => ({ + default: ({ content }: { content: string }) => {content}, +})); +vi.mock("@/context/RuntimeProvider", () => ({ useGatewayAPI: () => null })); -describe('MessageItem', () => { - it('renders system message', () => { - render() - expect(screen.getByText('sys')).toBeInTheDocument() - }) +describe("MessageItem", () => { + it("renders system message", () => { + render( + , + ); + expect(screen.getByText("sys")).toBeInTheDocument(); + }); - it('renders welcome message', () => { - render() - expect(screen.getByText('hello')).toBeInTheDocument() - }) + it("renders welcome message", () => { + render( + , + ); + expect(screen.getByText("hello")).toBeInTheDocument(); + }); - it('renders thinking message and toggles details', () => { - render( - , - ) - fireEvent.click(screen.getByText('AI 思考过程')) - expect(screen.getByText('reasoning')).toBeInTheDocument() - }) + it("renders thinking message and toggles details", () => { + render( + , + ); + fireEvent.click(screen.getByText("AI 思考过程")); + expect(screen.getByText("reasoning")).toBeInTheDocument(); + }); - it('renders tool and acceptance delegates', () => { - const { rerender } = render() - expect(screen.getByText('tool-card')).toBeInTheDocument() - rerender() - expect(screen.getByText('acceptance-card')).toBeInTheDocument() - }) + it("renders tool and acceptance delegates", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("tool-card")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("acceptance-card")).toBeInTheDocument(); + }); - it('renders code and plain assistant messages', () => { - const { rerender } = render() - expect(screen.getByText('const a=1')).toBeInTheDocument() - rerender() - expect(screen.getByText('answer')).toBeInTheDocument() - }) -}) + it("renders plan message card", () => { + render( + , + ); + expect(screen.getByText("计划")).toBeInTheDocument(); + expect(screen.getByText("修复计划展示")).toBeInTheDocument(); + expect(screen.getByText("发事件")).toBeInTheDocument(); + expect(screen.getByText("不显示 JSON")).toBeInTheDocument(); + expect(screen.getByText("是否需要审批")).toBeInTheDocument(); + }); + it("renders code and plain assistant messages", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("const a=1")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("answer")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index b2173e1d..837165f7 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -1,54 +1,81 @@ -import { memo, useState } from 'react' -import { type ChatMessage } from '@/stores/useChatStore' -import ToolCallCard from './ToolCallCard' -import AcceptanceMessage from './AcceptanceMessage' -import CodeBlock from './CodeBlock' -import MarkdownContent from './MarkdownContent' -import { Bot, ChevronRight, Info, Loader2 } from 'lucide-react' +import { memo, useState } from "react"; +import { type ChatMessage } from "@/stores/useChatStore"; +import { type PlanArtifact } from "@/api/protocol"; +import ToolCallCard from "./ToolCallCard"; +import AcceptanceMessage from "./AcceptanceMessage"; +import CodeBlock from "./CodeBlock"; +import MarkdownContent from "./MarkdownContent"; +import { Bot, ChevronRight, ClipboardList, Info, Loader2 } from "lucide-react"; interface MessageItemProps { - message: ChatMessage - isLast?: boolean + message: ChatMessage; + isLast?: boolean; /** 是否与上一条 AI/工具消息属于同一回合(同回合压缩 avatar 与上下间距) */ - groupedWithPrev?: boolean + groupedWithPrev?: boolean; } /** 单条消息渲染 */ -const MessageItem = memo(function MessageItem({ message, isLast = false, groupedWithPrev = false }: MessageItemProps) { - if (message.type === 'system') { - return +const MessageItem = memo(function MessageItem({ + message, + isLast = false, + groupedWithPrev = false, +}: MessageItemProps) { + if (message.type === "system") { + return ; } - if (message.type === 'welcome') { - return + if (message.type === "welcome") { + return ; } - if (message.type === 'thinking') { - return + if (message.type === "thinking") { + return ( + + ); + } + + if (message.type === "tool_call") { + return ; } - if (message.type === 'tool_call') { - return + if (message.type === "acceptance") { + return ( + + ); } - if (message.type === 'acceptance') { - return + if (message.type === "plan") { + return ; } - if (message.type === 'code') { + if (message.type === "code") { return ( - - + + - ) + ); } - if (message.role === 'user') { - return + if (message.role === "user") { + return ; } - return -}) + return ( + + ); +}); function UserMessage({ message }: { message: ChatMessage }) { return ( @@ -57,12 +84,25 @@ function UserMessage({ message }: { message: ChatMessage }) {
{message.content}
- ) + ); } -function AIMessage({ message, isLast, children, groupedWithPrev = false }: { message: ChatMessage; isLast: boolean; children?: React.ReactNode; groupedWithPrev?: boolean }) { +function AIMessage({ + message, + isLast, + children, + groupedWithPrev = false, +}: { + message: ChatMessage; + isLast: boolean; + children?: React.ReactNode; + groupedWithPrev?: boolean; +}) { return ( -
+
{groupedWithPrev ? (
) : ( @@ -73,7 +113,10 @@ function AIMessage({ message, isLast, children, groupedWithPrev = false }: { mes
{children || (
- + {isLast && !message.content && message.streaming && ( . @@ -85,46 +128,169 @@ function AIMessage({ message, isLast, children, groupedWithPrev = false }: { mes )}
- ) + ); } -function ThinkingMessage({ message, groupedWithPrev = false }: { message: ChatMessage; groupedWithPrev?: boolean }) { - const collapsed = message.thinkingData?.collapsed ?? false - const isStreaming = message.streaming === true - const [manualExpanded, setManualExpanded] = useState(null) +function ThinkingMessage({ + message, + groupedWithPrev = false, +}: { + message: ChatMessage; + groupedWithPrev?: boolean; +}) { + const collapsed = message.thinkingData?.collapsed ?? false; + const isStreaming = message.streaming === true; + const [manualExpanded, setManualExpanded] = useState(null); // streaming 时展开,collapsed 且无手动覆盖时折叠 - const expanded = manualExpanded !== null ? manualExpanded : (isStreaming || !collapsed) + const expanded = + manualExpanded !== null ? manualExpanded : isStreaming || !collapsed; return ( -
+
{groupedWithPrev ? (
) : ( -
+
)}
- {expanded && (
-
+            
               {message.content}
             
)}
- ) + ); +} + +function PlanMessage({ + message, + groupedWithPrev = false, +}: { + message: ChatMessage; + groupedWithPrev?: boolean; +}) { + const plan = message.planData; + if (!plan) { + return ( + + ); + } + const spec = plan.spec || { goal: "" }; + const steps = listOf(spec.steps); + const legacyTodoSteps = + steps.length > 0 ? steps : listOf(spec.todos?.map((todo) => todo.content)); + const constraints = listOf(spec.constraints); + const questions = listOf(spec.open_questions); + + return ( +
+ {groupedWithPrev ? ( +
+ ) : ( +
+ +
+ )} +
+
+
+ 计划 + + rev {plan.revision} · {plan.status || "draft"} + +
+ {spec.goal &&
{spec.goal}
} + + + +
+
+
+ ); +} + +function PlanSection({ title, items }: { title: string; items: string[] }) { + if (items.length === 0) return null; + return ( +
+
{title}
+
    + {items.map((item, index) => ( +
  • + {item} +
  • + ))} +
+
+ ); +} + +function listOf(value: PlanArtifact["spec"]["steps"]): string[] { + return Array.isArray(value) + ? value + .filter((item) => typeof item === "string" && item.trim()) + .map((item) => item.trim()) + : []; } function SystemMessage({ message }: { message: ChatMessage }) { @@ -136,56 +302,59 @@ function SystemMessage({ message }: { message: ChatMessage }) {
{message.content}
- ) + ); } function WelcomeMessage({ message }: { message: ChatMessage }) { return ( -
+
NeoCode

{message.content}

- ) + ); } const styles: Record = { userRow: { - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'flex-start', - padding: '12px 0 10px', - position: 'relative', + display: "flex", + justifyContent: "flex-end", + alignItems: "flex-start", + padding: "12px 0 10px", + position: "relative", gap: 6, }, userContent: { - maxWidth: '85%', + maxWidth: "85%", minWidth: 0, }, userBubble: { - background: 'var(--user-bubble)', - color: 'var(--user-bubble-text)', - padding: '10px 14px', - borderRadius: 'var(--radius-lg)', + background: "var(--user-bubble)", + color: "var(--user-bubble-text)", + padding: "10px 14px", + borderRadius: "var(--radius-lg)", fontSize: 14, lineHeight: 1.6, - border: '1px solid var(--border-primary)', - maxWidth: '100%', - whiteSpace: 'pre-wrap', - overflowWrap: 'anywhere', - wordBreak: 'break-word', - textWrap: 'pretty' as any, + border: "1px solid var(--border-primary)", + maxWidth: "100%", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + wordBreak: "break-word", + textWrap: "pretty" as any, }, aiRow: { - display: 'flex', + display: "flex", gap: 10, - padding: '8px 0 10px', + padding: "8px 0 10px", }, aiRowGrouped: { - display: 'flex', + display: "flex", gap: 10, - padding: '2px 0', + padding: "2px 0", }, avatarSpacer: { width: 28, @@ -194,12 +363,12 @@ const styles: Record = { aiAvatar: { width: 28, height: 28, - borderRadius: 'var(--radius-md)', - background: 'var(--accent-muted)', - color: 'var(--accent)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + borderRadius: "var(--radius-md)", + background: "var(--accent-muted)", + color: "var(--accent)", + display: "flex", + alignItems: "center", + justifyContent: "center", flexShrink: 0, marginTop: 2, }, @@ -210,99 +379,151 @@ const styles: Record = { aiText: { fontSize: 14, lineHeight: 1.7, - color: 'var(--text-primary)', - textWrap: 'pretty' as any, + color: "var(--text-primary)", + textWrap: "pretty" as any, }, typing: { marginLeft: 4, - color: 'var(--text-tertiary)', + color: "var(--text-tertiary)", }, thinkingToggle: { - display: 'flex', - alignItems: 'center', + display: "flex", + alignItems: "center", gap: 6, - padding: '4px 8px', - borderRadius: 'var(--radius-sm)', - border: 'none', - background: 'var(--bg-tertiary)', - color: 'var(--text-secondary)', + padding: "4px 8px", + borderRadius: "var(--radius-sm)", + border: "none", + background: "var(--bg-tertiary)", + color: "var(--text-secondary)", fontSize: 12, - cursor: 'pointer', - fontFamily: 'var(--font-ui)', + cursor: "pointer", + fontFamily: "var(--font-ui)", marginBottom: 8, }, thinkingLabel: { fontWeight: 500, }, thinkingContent: { - padding: '10px 12px', - borderRadius: 'var(--radius-md)', - background: 'var(--bg-tertiary)', - color: 'var(--text-secondary)', + padding: "10px 12px", + borderRadius: "var(--radius-md)", + background: "var(--bg-tertiary)", + color: "var(--text-secondary)", + marginBottom: 8, + }, + planCard: { + border: "1px solid var(--border-primary)", + borderRadius: "var(--radius-md)", + background: "var(--bg-secondary)", + padding: "12px 14px", + maxWidth: 720, + }, + planHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, marginBottom: 8, }, + planTitle: { + fontSize: 13, + fontWeight: 700, + color: "var(--warning)", + }, + planMeta: { + fontSize: 11, + color: "var(--text-tertiary)", + fontFamily: "var(--font-mono)", + whiteSpace: "nowrap", + }, + planGoal: { + fontSize: 14, + lineHeight: 1.6, + color: "var(--text-primary)", + marginBottom: 10, + overflowWrap: "anywhere", + }, + planSection: { + marginTop: 10, + }, + planSectionTitle: { + fontSize: 12, + fontWeight: 700, + color: "var(--text-secondary)", + marginBottom: 4, + }, + planList: { + margin: 0, + paddingLeft: 18, + color: "var(--text-primary)", + fontSize: 13, + lineHeight: 1.65, + }, + planListItem: { + margin: "2px 0", + overflowWrap: "anywhere", + }, welcomeCard: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', + display: "flex", + flexDirection: "column", + alignItems: "center", gap: 12, - padding: '24px 32px', - textAlign: 'center', + padding: "24px 32px", + textAlign: "center", maxWidth: 480, }, welcomeIcon: { width: 48, height: 48, - borderRadius: 'var(--radius-xl)', - background: 'var(--accent-muted)', - color: 'var(--accent)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + borderRadius: "var(--radius-xl)", + background: "var(--accent-muted)", + color: "var(--accent)", + display: "flex", + alignItems: "center", + justifyContent: "center", fontSize: 11, fontWeight: 700, - fontFamily: 'var(--font-mono)', + fontFamily: "var(--font-mono)", }, welcomeText: { fontSize: 14, lineHeight: 1.7, - color: 'var(--text-secondary)', + color: "var(--text-secondary)", margin: 0, }, systemRow: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', + display: "flex", + flexDirection: "column", + alignItems: "center", gap: 6, - padding: '10px 16px', - margin: '4px 0', + padding: "10px 16px", + margin: "4px 0", }, systemBadge: { - display: 'flex', - alignItems: 'center', + display: "flex", + alignItems: "center", gap: 4, - padding: '3px 10px', - borderRadius: 'var(--radius-md)', - background: 'var(--bg-tertiary)', - color: 'var(--text-tertiary)', + padding: "3px 10px", + borderRadius: "var(--radius-md)", + background: "var(--bg-tertiary)", + color: "var(--text-tertiary)", fontSize: 11, fontWeight: 600, }, systemLabel: { fontSize: 11, fontWeight: 600, - letterSpacing: '0.5px', + letterSpacing: "0.5px", }, systemPre: { fontSize: 13, lineHeight: 1.6, - color: 'var(--text-secondary)', - textAlign: 'left', - maxWidth: '85%', - whiteSpace: 'pre-wrap', - fontFamily: 'var(--font-mono)', + color: "var(--text-secondary)", + textAlign: "left", + maxWidth: "85%", + whiteSpace: "pre-wrap", + fontFamily: "var(--font-mono)", margin: 0, }, -} +}; -export default MessageItem +export default MessageItem; diff --git a/web/src/stores/useChatStore.ts b/web/src/stores/useChatStore.ts index 8e8f391c..52de5230 100644 --- a/web/src/stores/useChatStore.ts +++ b/web/src/stores/useChatStore.ts @@ -1,316 +1,384 @@ -import { create } from 'zustand' +import { create } from "zustand"; import { type TokenUsage, type PermissionRequestPayload, type AcceptanceDecidedPayload, type PendingUserQuestionSnapshot, -} from '@/api/protocol' -import { resetEventBridgeCursors } from '@/utils/eventBridge' + type PlanArtifact, +} from "@/api/protocol"; +import { resetEventBridgeCursors } from "@/utils/eventBridge"; /** 聊天消息 */ export interface ChatMessage { - id: string + id: string; /** 消息角色:user / assistant / tool */ - role: 'user' | 'assistant' | 'tool' + role: "user" | "assistant" | "tool"; /** 消息类型:text / thinking / tool_call / code / welcome / system / acceptance */ - type: 'text' | 'thinking' | 'tool_call' | 'code' | 'welcome' | 'system' | 'acceptance' + type: + | "text" + | "thinking" + | "tool_call" + | "code" + | "welcome" + | "system" + | "acceptance" + | "plan"; /** 文本内容 */ - content: string + content: string; /** 工具调用信息 */ - toolName?: string - toolCallId?: string - toolArgs?: string - toolResult?: string - toolStatus?: 'running' | 'done' | 'error' + toolName?: string; + toolCallId?: string; + toolArgs?: string; + toolResult?: string; + toolStatus?: "running" | "done" | "error"; /** Acceptance 决策数据,仅 `type === 'acceptance'` 时使用 */ - acceptanceData?: AcceptanceDecidedPayload + acceptanceData?: AcceptanceDecidedPayload; + /** Plan 数据,仅 `type === 'plan'` 时使用 */ + planData?: PlanArtifact; /** Thinking 附加数据,仅 `type === 'thinking'` 时使用 */ - thinkingData?: { collapsed: boolean } + thinkingData?: { collapsed: boolean }; /** 代码语言 */ - language?: string + language?: string; /** 代码文件名 */ - filename?: string + filename?: string; /** 时间戳 */ - timestamp: number + timestamp: number; /** 是否正在流式生成 */ - streaming?: boolean + streaming?: boolean; } /** 聊天状态 */ interface ChatState { /** 消息列表 */ - messages: ChatMessage[] + messages: ChatMessage[]; /** 是否正在生成 */ - isGenerating: boolean + isGenerating: boolean; /** 当前会话是否正在执行上下文压缩 */ - isCompacting: boolean + isCompacting: boolean; /** 当前压缩触发模式,用于展示压缩来源 */ - compactMode: string + compactMode: string; /** 压缩期间展示给用户的持续状态文案 */ - compactMessage: string + compactMessage: string; /** 当前 AI 回复缓冲 ID,用于流式追加 */ - streamingMessageId: string + streamingMessageId: string; /** 当前 thinking 流式消息 ID */ - streamingThinkingMessageId: string + streamingThinkingMessageId: string; /** 权限请求列表 */ - permissionRequests: PermissionRequestPayload[] + permissionRequests: PermissionRequestPayload[]; /** 当前待回答 ask_user 问题 */ - pendingUserQuestion: PendingUserQuestionSnapshot | null + pendingUserQuestion: PendingUserQuestionSnapshot | null; /** Token 用量 */ - tokenUsage: TokenUsage | null + tokenUsage: TokenUsage | null; /** 当前运行阶段 */ - phase: string + phase: string; /** 停止原因 */ - stopReason: string + stopReason: string; /** 会话切换中标记,eventBridge 据此丢弃中间窗口期事件 */ - isTransitioning: boolean + isTransitioning: boolean; /** 当前 Agent 工作模式 */ - agentMode: 'build' | 'plan' + agentMode: "build" | "plan"; /** Build 模式下的权限审批策略 */ - permissionMode: 'default' | 'bypass' + permissionMode: "default" | "bypass"; // Actions - addMessage: (msg: ChatMessage) => void - setMessages: (messages: ChatMessage[]) => void - removeMessage: (id: string) => void + addMessage: (msg: ChatMessage) => void; + setMessages: (messages: ChatMessage[]) => void; + removeMessage: (id: string) => void; /** 从指定消息开始截断 messages,并清理生成相关状态 */ - truncateFromMessage: (messageId: string) => void - appendChunk: (text: string) => void + truncateFromMessage: (messageId: string) => void; + appendChunk: (text: string) => void; /** 原子操作:创建流式 assistant 消息并设置 streamingMessageId */ - startStreamingMessage: () => string - finalizeMessage: (id: string, content: string) => void + startStreamingMessage: () => string; + finalizeMessage: (id: string, content: string) => void; /** 创建流式 thinking 消息并设置 streamingThinkingMessageId */ - startThinkingMessage: () => string + startThinkingMessage: () => string; /** 追加 thinking 文本到当前流式消息 */ - appendThinkingChunk: (text: string) => void + appendThinkingChunk: (text: string) => void; /** 终结 thinking 消息并清空 streamingThinkingMessageId */ - finalizeThinkingMessage: () => void - updateToolCall: (toolCallId: string, result: string, status: ChatMessage['toolStatus']) => boolean - appendToolOutput: (toolCallId: string, chunk: string) => void + finalizeThinkingMessage: () => void; + updateToolCall: ( + toolCallId: string, + result: string, + status: ChatMessage["toolStatus"], + ) => boolean; + appendToolOutput: (toolCallId: string, chunk: string) => void; + upsertPlanMessage: (plan: PlanArtifact, displayText?: string) => void; /** 将所有运行中的工具条目标记为指定状态,用于终止事件兜底收敛 UI */ - finalizeRunningToolCalls: (status: 'done' | 'error') => void - setGenerating: (v: boolean) => void - startCompacting: (mode?: string, message?: string) => void - finishCompacting: () => void - setStreamingMessageId: (id: string) => void + finalizeRunningToolCalls: (status: "done" | "error") => void; + setGenerating: (v: boolean) => void; + startCompacting: (mode?: string, message?: string) => void; + finishCompacting: () => void; + setStreamingMessageId: (id: string) => void; /** 重置生成状态:终结当前流式消息并清除 isGenerating */ - resetGeneratingState: () => void - setTransitioning: (v: boolean) => void - addPermissionRequest: (req: PermissionRequestPayload) => void - removePermissionRequest: (requestId: string) => void - setPendingUserQuestion: (question: PendingUserQuestionSnapshot | null) => void - clearPendingUserQuestion: (requestId?: string) => void - updateTokenUsage: (usage: TokenUsage) => void - setPhase: (phase: string) => void - setStopReason: (reason: string) => void - clearMessages: () => void - addSystemMessage: (content: string) => void - setAgentMode: (mode: 'build' | 'plan') => void - setPermissionMode: (mode: 'default' | 'bypass') => void + resetGeneratingState: () => void; + setTransitioning: (v: boolean) => void; + addPermissionRequest: (req: PermissionRequestPayload) => void; + removePermissionRequest: (requestId: string) => void; + setPendingUserQuestion: ( + question: PendingUserQuestionSnapshot | null, + ) => void; + clearPendingUserQuestion: (requestId?: string) => void; + updateTokenUsage: (usage: TokenUsage) => void; + setPhase: (phase: string) => void; + setStopReason: (reason: string) => void; + clearMessages: () => void; + addSystemMessage: (content: string) => void; + setAgentMode: (mode: "build" | "plan") => void; + setPermissionMode: (mode: "default" | "bypass") => void; } -let msgIdCounter = 0 +let msgIdCounter = 0; function nextMsgId(): string { - return `msg_${++msgIdCounter}_${Date.now()}` + return `msg_${++msgIdCounter}_${Date.now()}`; } /** 创建用户消息 */ export function createUserMessage(text: string): ChatMessage { return { id: nextMsgId(), - role: 'user', - type: 'text', + role: "user", + type: "text", content: text, timestamp: Date.now(), - } + }; } /** 创建 AI 流式消息 */ export function createAssistantMessage(): ChatMessage { return { id: nextMsgId(), - role: 'assistant', - type: 'text', - content: '', + role: "assistant", + type: "text", + content: "", timestamp: Date.now(), streaming: true, - } + }; } /** 创建系统消息,用于展示 slash command 执行结果 */ export function createSystemMessage(text: string): ChatMessage { return { id: nextMsgId(), - role: 'assistant', - type: 'system', + role: "assistant", + type: "system", content: text, timestamp: Date.now(), - } + }; } /** 创建工具调用消息 */ -export function createToolCallMessage(toolName: string, toolCallId: string, args: string): ChatMessage { +export function createToolCallMessage( + toolName: string, + toolCallId: string, + args: string, +): ChatMessage { return { id: nextMsgId(), - role: 'tool', - type: 'tool_call', - content: '', + role: "tool", + type: "tool_call", + content: "", toolName, toolCallId, toolArgs: args, - toolStatus: 'running', + toolStatus: "running", timestamp: Date.now(), - } + }; } /** 创建 thinking 流式消息 */ function createThinkingMessage(): ChatMessage { return { id: nextMsgId(), - role: 'assistant', - type: 'thinking', - content: '', + role: "assistant", + type: "thinking", + content: "", timestamp: Date.now(), streaming: true, thinkingData: { collapsed: false }, - } + }; } export const useChatStore = create((set) => ({ messages: [], isGenerating: false, isCompacting: false, - compactMode: '', - compactMessage: '', - streamingMessageId: '', - streamingThinkingMessageId: '', + compactMode: "", + compactMessage: "", + streamingMessageId: "", + streamingThinkingMessageId: "", permissionRequests: [], pendingUserQuestion: null, tokenUsage: null, - phase: '', - stopReason: '', + phase: "", + stopReason: "", isTransitioning: false, - agentMode: 'build', - permissionMode: 'default', + agentMode: "build", + permissionMode: "default", addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), setMessages: (messages) => set({ messages: [...messages] }), - removeMessage: (id) => set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })), + removeMessage: (id) => + set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })), truncateFromMessage: (messageId) => set((s) => { - const idx = s.messages.findIndex((m) => m.id === messageId) - if (idx === -1) return s + const idx = s.messages.findIndex((m) => m.id === messageId); + if (idx === -1) return s; return { messages: s.messages.slice(0, idx), - streamingMessageId: '', - streamingThinkingMessageId: '', + streamingMessageId: "", + streamingThinkingMessageId: "", isGenerating: false, isCompacting: false, - compactMode: '', - compactMessage: '', + compactMode: "", + compactMessage: "", permissionRequests: [], pendingUserQuestion: null, - phase: '', - stopReason: '', - } + phase: "", + stopReason: "", + }; }), appendChunk: (text) => set((s) => { - if (!s.streamingMessageId) return s + if (!s.streamingMessageId) return s; return { messages: s.messages.map((m) => - m.id === s.streamingMessageId ? { ...m, content: m.content + text } : m + m.id === s.streamingMessageId + ? { ...m, content: m.content + text } + : m, ), - } + }; }), /** 原子操作:创建消息并设置 streamingMessageId,避免竞态 */ startStreamingMessage: () => { - const msg = createAssistantMessage() + const msg = createAssistantMessage(); set((s) => ({ messages: [...s.messages, msg], streamingMessageId: msg.id, - })) - return msg.id + })); + return msg.id; }, /** 仅当 id 匹配当前 streamingMessageId 时才清空 */ finalizeMessage: (id, content) => set((s) => ({ messages: s.messages.map((m) => - m.id === id ? { ...m, content, streaming: false } : m + m.id === id ? { ...m, content, streaming: false } : m, ), - streamingMessageId: s.streamingMessageId === id ? '' : s.streamingMessageId, + streamingMessageId: + s.streamingMessageId === id ? "" : s.streamingMessageId, })), startThinkingMessage: () => { - const msg = createThinkingMessage() + const msg = createThinkingMessage(); set((s) => ({ messages: [...s.messages, msg], streamingThinkingMessageId: msg.id, - })) - return msg.id + })); + return msg.id; }, appendThinkingChunk: (text) => set((s) => { - if (!s.streamingThinkingMessageId) return s + if (!s.streamingThinkingMessageId) return s; return { messages: s.messages.map((m) => - m.id === s.streamingThinkingMessageId ? { ...m, content: m.content + text } : m + m.id === s.streamingThinkingMessageId + ? { ...m, content: m.content + text } + : m, ), - } + }; }), finalizeThinkingMessage: () => set((s) => { - if (!s.streamingThinkingMessageId) return s + if (!s.streamingThinkingMessageId) return s; return { messages: s.messages.map((m) => m.id === s.streamingThinkingMessageId ? { ...m, streaming: false, thinkingData: { collapsed: true } } - : m + : m, ), - streamingThinkingMessageId: '', - } + streamingThinkingMessageId: "", + }; }), updateToolCall: (toolCallId, result, status) => { - let matched = false + let matched = false; set((s) => ({ messages: s.messages.map((m) => { if (m.toolCallId === toolCallId) { - matched = true - return { ...m, toolResult: result, toolStatus: status } + matched = true; + return { ...m, toolResult: result, toolStatus: status }; } - return m + return m; }), - })) - return matched + })); + return matched; }, appendToolOutput: (toolCallId, chunk) => set((s) => ({ messages: s.messages.map((m) => m.toolCallId === toolCallId - ? { ...m, toolResult: (m.toolResult ?? '') + chunk } - : m + ? { ...m, toolResult: (m.toolResult ?? "") + chunk } + : m, ), })), + upsertPlanMessage: (plan, displayText = "") => + set((s) => { + const planID = plan.id?.trim(); + const content = + displayText.trim() || plan.spec?.goal || plan.summary?.goal || ""; + let matched = false; + const messages = s.messages.map((m) => { + if (m.type !== "plan") return m; + const samePlanRevision = + planID && + m.planData?.id === planID && + m.planData?.revision === plan.revision; + if (!samePlanRevision) return m; + matched = true; + return { + ...m, + content, + planData: plan, + streaming: false, + }; + }); + if (matched) return { messages }; + return { + messages: [ + ...messages, + { + id: `plan_${plan.id || Date.now()}_${plan.revision || 0}`, + role: "assistant" as const, + type: "plan" as const, + content, + planData: plan, + timestamp: Date.now(), + }, + ], + }; + }), + finalizeRunningToolCalls: (status) => set((s) => ({ messages: s.messages.map((m) => - m.type === 'tool_call' && m.toolStatus === 'running' + m.type === "tool_call" && m.toolStatus === "running" ? { ...m, toolStatus: status } - : m + : m, ), })), setGenerating: (isGenerating) => set({ isGenerating }), - startCompacting: (compactMode = 'manual', compactMessage = 'Compacting context...') => + startCompacting: ( + compactMode = "manual", + compactMessage = "Compacting context...", + ) => set({ isCompacting: true, compactMode, @@ -319,36 +387,36 @@ export const useChatStore = create((set) => ({ finishCompacting: () => set({ isCompacting: false, - compactMode: '', - compactMessage: '', + compactMode: "", + compactMessage: "", }), setStreamingMessageId: (streamingMessageId) => set({ streamingMessageId }), /** 重置生成状态:终结当前流式消息并清除 isGenerating */ resetGeneratingState: () => set((s) => { - let msgs = s.messages + let msgs = s.messages; if (s.streamingMessageId) { msgs = msgs.map((m) => - m.id === s.streamingMessageId ? { ...m, streaming: false } : m - ) + m.id === s.streamingMessageId ? { ...m, streaming: false } : m, + ); } if (s.streamingThinkingMessageId) { msgs = msgs.map((m) => m.id === s.streamingThinkingMessageId ? { ...m, streaming: false, thinkingData: { collapsed: true } } - : m - ) + : m, + ); } return { messages: msgs, - streamingMessageId: '', - streamingThinkingMessageId: '', + streamingMessageId: "", + streamingThinkingMessageId: "", isGenerating: false, isCompacting: false, - compactMode: '', - compactMessage: '', - } + compactMode: "", + compactMessage: "", + }; }), setTransitioning: (isTransitioning) => set({ isTransitioning }), @@ -358,24 +426,29 @@ export const useChatStore = create((set) => ({ removePermissionRequest: (requestId) => set((s) => ({ - permissionRequests: s.permissionRequests.filter((r) => r.request_id !== requestId), + permissionRequests: s.permissionRequests.filter( + (r) => r.request_id !== requestId, + ), })), - setPendingUserQuestion: (question) => set({ - pendingUserQuestion: question - ? { - ...question, - options: Array.isArray(question.options) ? [...question.options] : undefined, - } - : null, - }), + setPendingUserQuestion: (question) => + set({ + pendingUserQuestion: question + ? { + ...question, + options: Array.isArray(question.options) + ? [...question.options] + : undefined, + } + : null, + }), clearPendingUserQuestion: (requestId) => set((s) => { - if (!s.pendingUserQuestion) return s - if (!requestId) return { pendingUserQuestion: null } - if (s.pendingUserQuestion.request_id !== requestId) return s - return { pendingUserQuestion: null } + if (!s.pendingUserQuestion) return s; + if (!requestId) return { pendingUserQuestion: null }; + if (s.pendingUserQuestion.request_id !== requestId) return s; + return { pendingUserQuestion: null }; }), updateTokenUsage: (tokenUsage) => set({ tokenUsage }), @@ -390,23 +463,23 @@ export const useChatStore = create((set) => ({ /** 清理全部聊天状态,并重置 eventBridge 游标,避免跨会话泄漏 */ clearMessages: () => { - resetEventBridgeCursors() + resetEventBridgeCursors(); set({ messages: [], - streamingMessageId: '', - streamingThinkingMessageId: '', + streamingMessageId: "", + streamingThinkingMessageId: "", isGenerating: false, isCompacting: false, - compactMode: '', - compactMessage: '', + compactMode: "", + compactMessage: "", permissionRequests: [], pendingUserQuestion: null, tokenUsage: null, - phase: '', - stopReason: '', + phase: "", + stopReason: "", isTransitioning: false, - agentMode: 'build', - permissionMode: 'default', - }) + agentMode: "build", + permissionMode: "default", + }); }, -})) +})); diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts index 3c20e6a4..47c1c508 100644 --- a/web/src/stores/useSessionStore.test.ts +++ b/web/src/stores/useSessionStore.test.ts @@ -1,533 +1,921 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mapHistoryMessages, useSessionStore } from './useSessionStore' -import { useChatStore } from './useChatStore' -import { useGatewayStore } from './useGatewayStore' -import { useRuntimeInsightStore } from './useRuntimeInsightStore' -import { useUIStore } from './useUIStore' +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mapHistoryMessages, useSessionStore } from "./useSessionStore"; +import { useChatStore } from "./useChatStore"; +import { useGatewayStore } from "./useGatewayStore"; +import { useRuntimeInsightStore } from "./useRuntimeInsightStore"; +import { useUIStore } from "./useUIStore"; beforeEach(() => { - useSessionStore.setState((useSessionStore.getInitialState?.() ?? { projects: [], currentSessionId: '', currentProjectId: '', loading: false }) as any) - useChatStore.setState({ messages: [], isGenerating: false, streamingMessageId: '', permissionRequests: [], tokenUsage: null, phase: '', stopReason: '' } as any) - useGatewayStore.setState({ connectionState: 'disconnected', currentRunId: '', token: '', authenticated: false } as any) - useRuntimeInsightStore.getState().reset() -}) + useSessionStore.setState( + (useSessionStore.getInitialState?.() ?? { + projects: [], + currentSessionId: "", + currentProjectId: "", + loading: false, + }) as any, + ); + useChatStore.setState({ + messages: [], + isGenerating: false, + streamingMessageId: "", + permissionRequests: [], + tokenUsage: null, + phase: "", + stopReason: "", + } as any); + useGatewayStore.setState({ + connectionState: "disconnected", + currentRunId: "", + token: "", + authenticated: false, + } as any); + useRuntimeInsightStore.getState().reset(); +}); afterEach(() => { - vi.restoreAllMocks() -}) + vi.restoreAllMocks(); +}); -describe('useSessionStore', () => { - it('mapHistoryMessages skips internal system acceptance reminders', () => { +describe("useSessionStore", () => { + it("mapHistoryMessages skips internal system acceptance reminders", () => { const mapped = mapHistoryMessages([ - { role: 'user', content: 'start' }, + { role: "user", content: "start" }, { - role: 'system', + role: "system", content: [ - '', - 'pending_todo', - '', - ].join(''), + "", + "pending_todo", + "", + ].join(""), }, - { role: 'assistant', content: 'visible answer' }, - ]) + { role: "assistant", content: "visible answer" }, + ]); - expect(mapped.map((m) => m.content)).toEqual(['start', 'visible answer']) - expect(mapped.every((m) => m.content.includes('acceptance_continue') === false)).toBe(true) - }) + expect(mapped.map((m) => m.content)).toEqual(["start", "visible answer"]); + expect( + mapped.every((m) => m.content.includes("acceptance_continue") === false), + ).toBe(true); + }); - it('mapHistoryMessages skips leaked assistant acceptance control text', () => { + it("mapHistoryMessages skips leaked assistant acceptance control text", () => { const mapped = mapHistoryMessages([ { - role: 'assistant', - content: '', + role: "assistant", + content: + "", }, - { role: 'assistant', content: 'normal assistant text' }, + { role: "assistant", content: "normal assistant text" }, { - role: 'assistant', - content: 'prefix pending_todo', + role: "assistant", + content: + "prefix pending_todo", }, - ]) + ]); expect(mapped.map((m) => m.content)).toEqual([ - 'normal assistant text', - 'prefix pending_todo', - ]) - }) + "normal assistant text", + "prefix pending_todo", + ]); + }); - it('mapHistoryMessages keeps tool results that contain acceptance-like text', () => { + it("mapHistoryMessages keeps tool results that contain acceptance-like text", () => { const mapped = mapHistoryMessages([ { - role: 'assistant', - content: '', + role: "assistant", + content: "", tool_calls: [ - { id: 'call-xml', name: 'filesystem_read_file', arguments: '{"path":"fixture.xml"}' }, + { + id: "call-xml", + name: "filesystem_read_file", + arguments: '{"path":"fixture.xml"}', + }, ], }, { - role: 'tool', - content: 'literal fixture\n', - tool_call_id: 'call-xml', + role: "tool", + content: + "literal fixture\n", + tool_call_id: "call-xml", }, - ]) + ]); - expect(mapped).toHaveLength(1) + expect(mapped).toHaveLength(1); expect(mapped[0]).toMatchObject({ - role: 'tool', - type: 'tool_call', - toolCallId: 'call-xml', - toolResult: 'literal fixture\n', - toolStatus: 'done', - }) - }) - - it('mapHistoryMessages keeps normal messages and merges tool results', () => { + role: "tool", + type: "tool_call", + toolCallId: "call-xml", + toolResult: + "literal fixture\n", + toolStatus: "done", + }); + }); + + it("mapHistoryMessages keeps normal messages and merges tool results", () => { const mapped = mapHistoryMessages([ - { role: 'user', content: 'please inspect' }, + { role: "user", content: "please inspect" }, { - role: 'assistant', - content: 'calling tool', + role: "assistant", + content: "calling tool", tool_calls: [ - { id: 'call-1', name: 'filesystem_read_file', arguments: '{"path":"README.md"}' }, + { + id: "call-1", + name: "filesystem_read_file", + arguments: '{"path":"README.md"}', + }, ], }, - { role: 'tool', content: 'file content', tool_call_id: 'call-1' }, - ]) + { role: "tool", content: "file content", tool_call_id: "call-1" }, + ]); - expect(mapped).toHaveLength(3) - expect(mapped[0]).toMatchObject({ role: 'user', type: 'text', content: 'please inspect' }) - expect(mapped[1]).toMatchObject({ role: 'assistant', type: 'text', content: 'calling tool' }) + expect(mapped).toHaveLength(3); + expect(mapped[0]).toMatchObject({ + role: "user", + type: "text", + content: "please inspect", + }); + expect(mapped[1]).toMatchObject({ + role: "assistant", + type: "text", + content: "calling tool", + }); expect(mapped[2]).toMatchObject({ - role: 'tool', - type: 'tool_call', - toolName: 'filesystem_read_file', - toolCallId: 'call-1', - toolResult: 'file content', - toolStatus: 'done', - }) - }) - - it('createSession clears messages and resets session state', () => { - useChatStore.getState().addMessage({ id: '1', role: 'user', content: 'hello', type: 'text', timestamp: 1 }) - useSessionStore.setState({ currentSessionId: 'sess-1' }) - - useSessionStore.getState().createSession() - - expect(useChatStore.getState().messages).toHaveLength(0) - expect(useSessionStore.getState().currentSessionId).toBe('') - }) - - it('createSession is blocked while generating', () => { - const showToast = vi.fn() - useChatStore.setState({ isGenerating: true } as any) - useUIStore.setState({ showToast } as any) - useSessionStore.setState({ currentSessionId: 'sess-1', currentProjectId: 'group-1' }) - - useSessionStore.getState().createSession() - - expect(useSessionStore.getState().currentSessionId).toBe('sess-1') - expect(useSessionStore.getState().currentProjectId).toBe('group-1') - expect(showToast).toHaveBeenCalledWith('Cannot start a new session while generating; stop the current run first.', 'info') - }) - - it('prepareNewChat also clears state and does not set temp id', () => { - useSessionStore.setState({ currentSessionId: 'sess-1' }) - useChatStore.getState().addMessage({ id: '1', role: 'user', content: 'hello', type: 'text', timestamp: 1 }) - - useSessionStore.getState().prepareNewChat() - - expect(useChatStore.getState().messages).toHaveLength(0) - expect(useSessionStore.getState().currentSessionId).toBe('') - expect(useSessionStore.getState().currentProjectId).toBe('') - }) - - it('prepareNewChat is blocked while generating', () => { - const showToast = vi.fn() - useChatStore.setState({ isGenerating: true } as any) - useUIStore.setState({ showToast } as any) - useSessionStore.setState({ currentSessionId: 'sess-1', currentProjectId: 'group-1' }) - - useSessionStore.getState().prepareNewChat() - - expect(useSessionStore.getState().currentSessionId).toBe('sess-1') - expect(useSessionStore.getState().currentProjectId).toBe('group-1') - expect(showToast).toHaveBeenCalledWith('Cannot start a new session while generating; stop the current run first.', 'info') - }) - - it('initializeActiveSession binds stream for valid session id', async () => { - const mockBindStream = vi.fn().mockResolvedValue({}) - const mockAPI = { bindStream: mockBindStream } as any - - useSessionStore.setState({ currentSessionId: 'sess-1' }) - await useSessionStore.getState().initializeActiveSession(mockAPI) - - expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-1', channel: 'all' }) - }) - - it('initializeActiveSession skips binding for empty session id', async () => { - const mockBindStream = vi.fn().mockResolvedValue({}) - const mockAPI = { bindStream: mockBindStream } as any - - useSessionStore.setState({ currentSessionId: '' }) - await useSessionStore.getState().initializeActiveSession(mockAPI) - - expect(mockBindStream).not.toHaveBeenCalled() - }) - - it('initializeActiveSession shows toast when bindStream fails', async () => { - const showToast = vi.fn() - const mockBindStream = vi.fn().mockRejectedValue(new Error('bind failed')) - const mockAPI = { bindStream: mockBindStream } as any - useUIStore.setState({ showToast } as any) - - useSessionStore.setState({ currentSessionId: 'sess-1', _initialBindDone: false } as any) - await useSessionStore.getState().initializeActiveSession(mockAPI) - - expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-1', channel: 'all' }) - expect(useSessionStore.getState()._initialBindDone).toBe(false) - expect(showToast).toHaveBeenCalledWith('Failed to bind event stream; real-time messages may not arrive.', 'error') - }) - - it('switchSession binds stream and loads session data', async () => { - const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') - const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') - const mockBindStream = vi.fn().mockResolvedValue({}) + role: "tool", + type: "tool_call", + toolName: "filesystem_read_file", + toolCallId: "call-1", + toolResult: "file content", + toolStatus: "done", + }); + }); + + it("switchSession restores current_plan as a plan message without duplicated rendered text", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockLoadSession = vi.fn().mockResolvedValue({ + payload: { + agent_mode: "plan", + current_plan: { + id: "plan-1", + revision: 1, + status: "draft", + spec: { + goal: "修复 web plan 展示", + steps: ["发事件", "渲染卡片"], + constraints: ["不显示 JSON"], + open_questions: ["是否需要审批"], + }, + summary: { goal: "修复 web plan 展示", key_steps: ["发事件"] }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + messages: [ + { role: "user", content: "先给计划" }, + { + role: "assistant", + content: + "### 目标\n\n修复 web plan 展示\n\n### 实施步骤\n\n- 发事件\n- 渲染卡片", + }, + ], + }, + }); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + listSessionTodos: vi.fn().mockResolvedValue({ payload: {} }), + getRuntimeSnapshot: vi.fn().mockResolvedValue({ payload: {} }), + } as any; + + await useSessionStore.getState().switchSession("sess-plan", mockAPI); + + const messages = useChatStore.getState().messages; + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ role: "user", content: "先给计划" }); + expect(messages[1]).toMatchObject({ + type: "plan", + planData: { id: "plan-1" }, + }); + expect(useChatStore.getState().agentMode).toBe("plan"); + }); + + it("switchSession keeps current_plan at the original rendered message position", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); const mockLoadSession = vi.fn().mockResolvedValue({ payload: { + agent_mode: "plan", + current_plan: { + id: "plan-middle", + revision: 3, + status: "draft", + spec: { + goal: "保持计划顺序", + steps: ["替换原位置"], + }, + summary: { goal: "保持计划顺序", key_steps: ["替换原位置"] }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, messages: [ - { role: 'user', content: 'hello', tool_calls: [] }, + { role: "user", content: "先规划" }, + { + role: "assistant", + content: "### 目标\n\n保持计划顺序\n\n### 实施步骤\n\n- 替换原位置", + }, + { role: "user", content: "继续讨论" }, + { role: "assistant", content: "后续回复" }, ], }, - }) - const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + }); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + listSessionTodos: vi.fn().mockResolvedValue({ payload: {} }), + getRuntimeSnapshot: vi.fn().mockResolvedValue({ payload: {} }), + } as any; + + await useSessionStore.getState().switchSession("sess-plan-middle", mockAPI); + + const messages = useChatStore.getState().messages; + expect(messages).toHaveLength(4); + expect(messages.map((m) => m.content)).toEqual([ + "先规划", + "保持计划顺序", + "继续讨论", + "后续回复", + ]); + expect(messages[1]).toMatchObject({ + type: "plan", + planData: { id: "plan-middle", revision: 3 }, + }); + expect(messages.some((m) => m.content.includes("### 目标"))).toBe(false); + }); + + it("switchSession appends current_plan when no rendered plan text exists", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockLoadSession = vi.fn().mockResolvedValue({ + payload: { + agent_mode: "plan", + current_plan: { + id: "plan-append", + revision: 1, + status: "draft", + spec: { + goal: "追加计划卡片", + steps: ["兼容缺失文本"], + }, + summary: { goal: "追加计划卡片", key_steps: ["兼容缺失文本"] }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + messages: [ + { role: "user", content: "打开会话" }, + { role: "assistant", content: "普通回复" }, + ], + }, + }); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + listSessionTodos: vi.fn().mockResolvedValue({ payload: {} }), + getRuntimeSnapshot: vi.fn().mockResolvedValue({ payload: {} }), + } as any; + + await useSessionStore.getState().switchSession("sess-plan-append", mockAPI); + + const messages = useChatStore.getState().messages; + expect(messages).toHaveLength(3); + expect(messages.map((m) => m.content)).toEqual([ + "打开会话", + "普通回复", + "追加计划卡片", + ]); + expect(messages[2]).toMatchObject({ + type: "plan", + planData: { id: "plan-append", revision: 1 }, + }); + }); + + it("switchSession still de-duplicates legacy rendered plan text", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockLoadSession = vi.fn().mockResolvedValue({ + payload: { + agent_mode: "plan", + current_plan: { + id: "plan-legacy", + revision: 1, + status: "draft", + spec: { + goal: "兼容旧计划格式", + steps: ["保留旧前缀匹配"], + }, + summary: { goal: "兼容旧计划格式", key_steps: ["保留旧前缀匹配"] }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + messages: [ + { role: "user", content: "打开旧会话" }, + { + role: "assistant", + content: "目标\n兼容旧计划格式\n\n实施步骤\n- 保留旧前缀匹配", + }, + ], + }, + }); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + listSessionTodos: vi.fn().mockResolvedValue({ payload: {} }), + getRuntimeSnapshot: vi.fn().mockResolvedValue({ payload: {} }), + } as any; + + await useSessionStore.getState().switchSession("sess-plan-legacy", mockAPI); + + const messages = useChatStore.getState().messages; + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ role: "user", content: "打开旧会话" }); + expect(messages[1]).toMatchObject({ + type: "plan", + planData: { id: "plan-legacy" }, + }); + }); + + it("createSession clears messages and resets session state", () => { + useChatStore.getState().addMessage({ + id: "1", + role: "user", + content: "hello", + type: "text", + timestamp: 1, + }); + useSessionStore.setState({ currentSessionId: "sess-1" }); + + useSessionStore.getState().createSession(); + + expect(useChatStore.getState().messages).toHaveLength(0); + expect(useSessionStore.getState().currentSessionId).toBe(""); + }); + + it("createSession is blocked while generating", () => { + const showToast = vi.fn(); + useChatStore.setState({ isGenerating: true } as any); + useUIStore.setState({ showToast } as any); + useSessionStore.setState({ + currentSessionId: "sess-1", + currentProjectId: "group-1", + }); + + useSessionStore.getState().createSession(); + + expect(useSessionStore.getState().currentSessionId).toBe("sess-1"); + expect(useSessionStore.getState().currentProjectId).toBe("group-1"); + expect(showToast).toHaveBeenCalledWith( + "Cannot start a new session while generating; stop the current run first.", + "info", + ); + }); + + it("prepareNewChat also clears state and does not set temp id", () => { + useSessionStore.setState({ currentSessionId: "sess-1" }); + useChatStore.getState().addMessage({ + id: "1", + role: "user", + content: "hello", + type: "text", + timestamp: 1, + }); + + useSessionStore.getState().prepareNewChat(); + + expect(useChatStore.getState().messages).toHaveLength(0); + expect(useSessionStore.getState().currentSessionId).toBe(""); + expect(useSessionStore.getState().currentProjectId).toBe(""); + }); + + it("prepareNewChat is blocked while generating", () => { + const showToast = vi.fn(); + useChatStore.setState({ isGenerating: true } as any); + useUIStore.setState({ showToast } as any); + useSessionStore.setState({ + currentSessionId: "sess-1", + currentProjectId: "group-1", + }); + + useSessionStore.getState().prepareNewChat(); + + expect(useSessionStore.getState().currentSessionId).toBe("sess-1"); + expect(useSessionStore.getState().currentProjectId).toBe("group-1"); + expect(showToast).toHaveBeenCalledWith( + "Cannot start a new session while generating; stop the current run first.", + "info", + ); + }); + + it("initializeActiveSession binds stream for valid session id", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockAPI = { bindStream: mockBindStream } as any; + + useSessionStore.setState({ currentSessionId: "sess-1" }); + await useSessionStore.getState().initializeActiveSession(mockAPI); + + expect(mockBindStream).toHaveBeenCalledWith({ + session_id: "sess-1", + channel: "all", + }); + }); - await useSessionStore.getState().switchSession('sess-2', mockAPI) + it("initializeActiveSession skips binding for empty session id", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockAPI = { bindStream: mockBindStream } as any; - expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-2', channel: 'all' }) - expect(setMessagesSpy).toHaveBeenCalledTimes(1) - expect(addMessageSpy).not.toHaveBeenCalled() - expect(useChatStore.getState().messages).toHaveLength(1) - expect(useChatStore.getState().messages[0].role).toBe('user') - }) + useSessionStore.setState({ currentSessionId: "" }); + await useSessionStore.getState().initializeActiveSession(mockAPI); - it('switchSession keeps transitioning true until loadSession finishes', async () => { - const mockBindStream = vi.fn().mockResolvedValue({}) - let resolveLoad!: (value: any) => void + expect(mockBindStream).not.toHaveBeenCalled(); + }); + + it("initializeActiveSession shows toast when bindStream fails", async () => { + const showToast = vi.fn(); + const mockBindStream = vi.fn().mockRejectedValue(new Error("bind failed")); + const mockAPI = { bindStream: mockBindStream } as any; + useUIStore.setState({ showToast } as any); + + useSessionStore.setState({ + currentSessionId: "sess-1", + _initialBindDone: false, + } as any); + await useSessionStore.getState().initializeActiveSession(mockAPI); + + expect(mockBindStream).toHaveBeenCalledWith({ + session_id: "sess-1", + channel: "all", + }); + expect(useSessionStore.getState()._initialBindDone).toBe(false); + expect(showToast).toHaveBeenCalledWith( + "Failed to bind event stream; real-time messages may not arrive.", + "error", + ); + }); + + it("switchSession binds stream and loads session data", async () => { + const setMessagesSpy = vi.spyOn(useChatStore.getState(), "setMessages"); + const addMessageSpy = vi.spyOn(useChatStore.getState(), "addMessage"); + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockLoadSession = vi.fn().mockResolvedValue({ + payload: { + messages: [{ role: "user", content: "hello", tool_calls: [] }], + }, + }); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; + + await useSessionStore.getState().switchSession("sess-2", mockAPI); + + expect(mockBindStream).toHaveBeenCalledWith({ + session_id: "sess-2", + channel: "all", + }); + expect(setMessagesSpy).toHaveBeenCalledTimes(1); + expect(addMessageSpy).not.toHaveBeenCalled(); + expect(useChatStore.getState().messages).toHaveLength(1); + expect(useChatStore.getState().messages[0].role).toBe("user"); + }); + + it("switchSession keeps transitioning true until loadSession finishes", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + let resolveLoad!: (value: any) => void; const mockLoadSession = vi.fn().mockImplementation( - () => new Promise((resolve) => { resolveLoad = resolve }), - ) - const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; - const switchPromise = useSessionStore.getState().switchSession('sess-2', mockAPI) - await Promise.resolve() + const switchPromise = useSessionStore + .getState() + .switchSession("sess-2", mockAPI); + await Promise.resolve(); - expect(useChatStore.getState().isTransitioning).toBe(true) + expect(useChatStore.getState().isTransitioning).toBe(true); - resolveLoad({ payload: { messages: [] } }) - await switchPromise + resolveLoad({ payload: { messages: [] } }); + await switchPromise; - expect(useChatStore.getState().isTransitioning).toBe(false) - }) + expect(useChatStore.getState().isTransitioning).toBe(false); + }); - it('resetForWorkspaceSwitch aborts in-flight switchSession and blocks stale writeback', async () => { - const mockBindStream = vi.fn().mockResolvedValue({}) - let resolveLoad!: (value: any) => void + it("resetForWorkspaceSwitch aborts in-flight switchSession and blocks stale writeback", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + let resolveLoad!: (value: any) => void; const mockLoadSession = vi.fn().mockImplementation( - () => new Promise((resolve) => { resolveLoad = resolve }), - ) - const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; - const switchPromise = useSessionStore.getState().switchSession('sess-old', mockAPI) - await Promise.resolve() + const switchPromise = useSessionStore + .getState() + .switchSession("sess-old", mockAPI); + await Promise.resolve(); - useSessionStore.getState().resetForWorkspaceSwitch() + useSessionStore.getState().resetForWorkspaceSwitch(); resolveLoad({ payload: { - messages: [{ role: 'assistant', content: 'stale payload', tool_calls: [] }], - agent_mode: 'plan', + messages: [ + { role: "assistant", content: "stale payload", tool_calls: [] }, + ], + agent_mode: "plan", }, - }) - await switchPromise + }); + await switchPromise; - expect(useChatStore.getState().messages).toHaveLength(0) - expect(useChatStore.getState().agentMode).toBe('build') - }) + expect(useChatStore.getState().messages).toHaveLength(0); + expect(useChatStore.getState().agentMode).toBe("build"); + }); - it('switchSession applies only latest request when older request resolves later', async () => { - const mockBindStream = vi.fn().mockResolvedValue({}) - let resolveLoadA!: (value: any) => void - let resolveLoadB!: (value: any) => void + it("switchSession applies only latest request when older request resolves later", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); + let resolveLoadA!: (value: any) => void; + let resolveLoadB!: (value: any) => void; const mockLoadSession = vi .fn() - .mockImplementationOnce(() => new Promise((resolve) => { resolveLoadA = resolve })) - .mockImplementationOnce(() => new Promise((resolve) => { resolveLoadB = resolve })) - const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoadA = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoadB = resolve; + }), + ); + const mockAPI = { + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; - const switchA = useSessionStore.getState().switchSession('sess-a', mockAPI) - await Promise.resolve() - const switchB = useSessionStore.getState().switchSession('sess-b', mockAPI) - await Promise.resolve() + const switchA = useSessionStore.getState().switchSession("sess-a", mockAPI); + await Promise.resolve(); + const switchB = useSessionStore.getState().switchSession("sess-b", mockAPI); + await Promise.resolve(); resolveLoadB({ payload: { - messages: [{ role: 'assistant', content: 'new payload', tool_calls: [] }], - agent_mode: 'plan', + messages: [ + { role: "assistant", content: "new payload", tool_calls: [] }, + ], + agent_mode: "plan", }, - }) - await switchB + }); + await switchB; resolveLoadA({ payload: { - messages: [{ role: 'assistant', content: 'old payload', tool_calls: [] }], - agent_mode: 'build', + messages: [ + { role: "assistant", content: "old payload", tool_calls: [] }, + ], + agent_mode: "build", }, - }) - await switchA - - expect(useSessionStore.getState().currentSessionId).toBe('sess-b') - expect(useChatStore.getState().messages).toHaveLength(1) - expect(useChatStore.getState().messages[0].content).toBe('new payload') - expect(useChatStore.getState().agentMode).toBe('plan') - }) - - it('fetchSessions auto-selects first session and binds stream', async () => { - const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') - const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') + }); + await switchA; + + expect(useSessionStore.getState().currentSessionId).toBe("sess-b"); + expect(useChatStore.getState().messages).toHaveLength(1); + expect(useChatStore.getState().messages[0].content).toBe("new payload"); + expect(useChatStore.getState().agentMode).toBe("plan"); + }); + + it("fetchSessions auto-selects first session and binds stream", async () => { + const setMessagesSpy = vi.spyOn(useChatStore.getState(), "setMessages"); + const addMessageSpy = vi.spyOn(useChatStore.getState(), "addMessage"); const mockListSessions = vi.fn().mockResolvedValue({ payload: { - sessions: [{ - id: 'sess-a', - title: 'Alpha', - created_at: '2026-05-09T01:00:00Z', - updated_at: '2026-05-09T02:00:00Z', - }], + sessions: [ + { + id: "sess-a", + title: "Alpha", + created_at: "2026-05-09T01:00:00Z", + updated_at: "2026-05-09T02:00:00Z", + }, + ], }, - }) - const mockBindStream = vi.fn().mockResolvedValue({}) + }); + const mockBindStream = vi.fn().mockResolvedValue({}); const mockLoadSession = vi.fn().mockResolvedValue({ - payload: { messages: [{ role: 'assistant', content: 'loaded history', tool_calls: [] }] }, - }) - const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream, loadSession: mockLoadSession } as any - - await useSessionStore.getState().fetchSessions(mockAPI) - - expect(useSessionStore.getState().currentSessionId).toBe('sess-a') - expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-a', channel: 'all' }) - expect(setMessagesSpy).toHaveBeenCalled() - expect(addMessageSpy).not.toHaveBeenCalled() - expect(useChatStore.getState().messages[0]).toMatchObject({ role: 'assistant', content: 'loaded history' }) - }) - - it('fetchSessions does not auto-select when current session is valid', async () => { + payload: { + messages: [ + { role: "assistant", content: "loaded history", tool_calls: [] }, + ], + }, + }); + const mockAPI = { + listSessions: mockListSessions, + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; + + await useSessionStore.getState().fetchSessions(mockAPI); + + expect(useSessionStore.getState().currentSessionId).toBe("sess-a"); + expect(mockBindStream).toHaveBeenCalledWith({ + session_id: "sess-a", + channel: "all", + }); + expect(setMessagesSpy).toHaveBeenCalled(); + expect(addMessageSpy).not.toHaveBeenCalled(); + expect(useChatStore.getState().messages[0]).toMatchObject({ + role: "assistant", + content: "loaded history", + }); + }); + + it("fetchSessions does not auto-select when current session is valid", async () => { const mockListSessions = vi.fn().mockResolvedValue({ payload: { - sessions: [{ - id: 'sess-a', - title: 'Alpha', - created_at: '2026-05-09T01:00:00Z', - updated_at: '2026-05-09T02:00:00Z', - }], + sessions: [ + { + id: "sess-a", + title: "Alpha", + created_at: "2026-05-09T01:00:00Z", + updated_at: "2026-05-09T02:00:00Z", + }, + ], }, - }) - const mockBindStream = vi.fn().mockResolvedValue({}) - const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream } as any + }); + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockAPI = { + listSessions: mockListSessions, + bindStream: mockBindStream, + } as any; - useSessionStore.setState({ currentSessionId: 'sess-b' }) - await useSessionStore.getState().fetchSessions(mockAPI) + useSessionStore.setState({ currentSessionId: "sess-b" }); + await useSessionStore.getState().fetchSessions(mockAPI); - expect(useSessionStore.getState().currentSessionId).toBe('sess-b') - expect(mockBindStream).not.toHaveBeenCalled() - }) + expect(useSessionStore.getState().currentSessionId).toBe("sess-b"); + expect(mockBindStream).not.toHaveBeenCalled(); + }); - it('fetchSessions ignores stale late response from an older request', async () => { - let resolveFirst!: (value: any) => void - let resolveSecond!: (value: any) => void + it("fetchSessions ignores stale late response from an older request", async () => { + let resolveFirst!: (value: any) => void; + let resolveSecond!: (value: any) => void; const mockListSessions = vi .fn() .mockImplementationOnce( - () => new Promise((resolve) => { resolveFirst = resolve }), + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), ) .mockImplementationOnce( - () => new Promise((resolve) => { resolveSecond = resolve }), - ) + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); const mockAPI = { listSessions: mockListSessions, bindStream: vi.fn().mockResolvedValue({}), loadSession: vi.fn().mockResolvedValue({ payload: { messages: [] } }), - } as any + } as any; - useSessionStore.setState({ currentSessionId: 'sess-keep' }) + useSessionStore.setState({ currentSessionId: "sess-keep" }); - const firstRequest = useSessionStore.getState().fetchSessions(mockAPI, true) - const secondRequest = useSessionStore.getState().fetchSessions(mockAPI, true) + const firstRequest = useSessionStore + .getState() + .fetchSessions(mockAPI, true); + const secondRequest = useSessionStore + .getState() + .fetchSessions(mockAPI, true); resolveSecond({ payload: { - sessions: [{ - id: 'sess-new', - title: 'New', - created_at: '2026-05-10T01:00:00Z', - updated_at: '2026-05-10T01:00:00Z', - }], + sessions: [ + { + id: "sess-new", + title: "New", + created_at: "2026-05-10T01:00:00Z", + updated_at: "2026-05-10T01:00:00Z", + }, + ], }, - }) - await secondRequest + }); + await secondRequest; resolveFirst({ payload: { - sessions: [{ - id: 'sess-old', - title: 'Old', - created_at: '2026-05-09T01:00:00Z', - updated_at: '2026-05-09T01:00:00Z', - }], + sessions: [ + { + id: "sess-old", + title: "Old", + created_at: "2026-05-09T01:00:00Z", + updated_at: "2026-05-09T01:00:00Z", + }, + ], }, - }) - await firstRequest - - const sessions = useSessionStore.getState().projects.flatMap((project) => project.sessions) - expect(sessions.map((session) => session.id)).toEqual(['sess-new']) - }) - - it('fetchSessions shows toast when auto bind or load fails', async () => { - const showToast = vi.fn() - useUIStore.setState({ showToast } as any) + }); + await firstRequest; + + const sessions = useSessionStore + .getState() + .projects.flatMap((project) => project.sessions); + expect(sessions.map((session) => session.id)).toEqual(["sess-new"]); + }); + + it("fetchSessions shows toast when auto bind or load fails", async () => { + const showToast = vi.fn(); + useUIStore.setState({ showToast } as any); const mockListSessions = vi.fn().mockResolvedValue({ payload: { - sessions: [{ - id: 'sess-a', - title: 'Alpha', - created_at: '2026-05-09T01:00:00Z', - updated_at: '2026-05-09T02:00:00Z', - }], + sessions: [ + { + id: "sess-a", + title: "Alpha", + created_at: "2026-05-09T01:00:00Z", + updated_at: "2026-05-09T02:00:00Z", + }, + ], }, - }) - const mockBindStream = vi.fn().mockRejectedValue(new Error('bind failed')) - const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream } as any + }); + const mockBindStream = vi.fn().mockRejectedValue(new Error("bind failed")); + const mockAPI = { + listSessions: mockListSessions, + bindStream: mockBindStream, + } as any; - await useSessionStore.getState().fetchSessions(mockAPI) + await useSessionStore.getState().fetchSessions(mockAPI); - expect(useSessionStore.getState().currentSessionId).toBe('sess-a') - expect(showToast).toHaveBeenCalledWith('Failed to load session', 'error') - }) + expect(useSessionStore.getState().currentSessionId).toBe("sess-a"); + expect(showToast).toHaveBeenCalledWith("Failed to load session", "error"); + }); - it('fetchSessions clears projects when listSessions fails', async () => { + it("fetchSessions clears projects when listSessions fails", async () => { useSessionStore.setState({ - projects: [{ id: 'group', name: 'Group', sessions: [{ id: 'sess-1', title: 'A', time: '2026-05-10T00:00:00.000Z' }] }], - } as any) + projects: [ + { + id: "group", + name: "Group", + sessions: [ + { id: "sess-1", title: "A", time: "2026-05-10T00:00:00.000Z" }, + ], + }, + ], + } as any); const mockAPI = { - listSessions: vi.fn().mockRejectedValue(new Error('list failed')), - } as any + listSessions: vi.fn().mockRejectedValue(new Error("list failed")), + } as any; - await useSessionStore.getState().fetchSessions(mockAPI, true) + await useSessionStore.getState().fetchSessions(mockAPI, true); - expect(useSessionStore.getState().projects).toEqual([]) - expect(useSessionStore.getState().loading).toBe(false) - }) + expect(useSessionStore.getState().projects).toEqual([]); + expect(useSessionStore.getState().loading).toBe(false); + }); - it('fetchSessions uses the newer of created_at/updated_at as display time', async () => { + it("fetchSessions uses the newer of created_at/updated_at as display time", async () => { const mockListSessions = vi.fn().mockResolvedValue({ payload: { - sessions: [{ - id: 'sess-a', - title: 'Alpha', - created_at: '2026-05-09T09:30:00Z', - updated_at: '2026-05-09T08:30:00Z', - }], + sessions: [ + { + id: "sess-a", + title: "Alpha", + created_at: "2026-05-09T09:30:00Z", + updated_at: "2026-05-09T08:30:00Z", + }, + ], }, - }) - const mockBindStream = vi.fn().mockResolvedValue({}) - const mockLoadSession = vi.fn().mockResolvedValue({ payload: { messages: [] } }) - const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream, loadSession: mockLoadSession } as any + }); + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockLoadSession = vi + .fn() + .mockResolvedValue({ payload: { messages: [] } }); + const mockAPI = { + listSessions: mockListSessions, + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; - await useSessionStore.getState().fetchSessions(mockAPI) + await useSessionStore.getState().fetchSessions(mockAPI); - const session = useSessionStore.getState().projects[0].sessions[0] - expect(session.time).toBe('2026-05-09T09:30:00.000Z') - }) + const session = useSessionStore.getState().projects[0].sessions[0]; + expect(session.time).toBe("2026-05-09T09:30:00.000Z"); + }); - it('fetchSessions uses stable fallback time when created_at and updated_at are both invalid', async () => { + it("fetchSessions uses stable fallback time when created_at and updated_at are both invalid", async () => { const mockListSessions = vi.fn().mockResolvedValue({ payload: { - sessions: [{ - id: 'sess-invalid-time', - title: 'InvalidTime', - created_at: 'not-a-date', - updated_at: '', - }], + sessions: [ + { + id: "sess-invalid-time", + title: "InvalidTime", + created_at: "not-a-date", + updated_at: "", + }, + ], }, - }) - const mockBindStream = vi.fn().mockResolvedValue({}) - const mockLoadSession = vi.fn().mockResolvedValue({ payload: { messages: [] } }) - const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream, loadSession: mockLoadSession } as any + }); + const mockBindStream = vi.fn().mockResolvedValue({}); + const mockLoadSession = vi + .fn() + .mockResolvedValue({ payload: { messages: [] } }); + const mockAPI = { + listSessions: mockListSessions, + bindStream: mockBindStream, + loadSession: mockLoadSession, + } as any; - await useSessionStore.getState().fetchSessions(mockAPI) + await useSessionStore.getState().fetchSessions(mockAPI); - const session = useSessionStore.getState().projects[0].sessions[0] - expect(session.time).toBe('1970-01-01T00:00:00.000Z') - }) + const session = useSessionStore.getState().projects[0].sessions[0]; + expect(session.time).toBe("1970-01-01T00:00:00.000Z"); + }); - it('switchSession concurrently fetches todos and runtime snapshot', async () => { - const mockBindStream = vi.fn().mockResolvedValue({}) + it("switchSession concurrently fetches todos and runtime snapshot", async () => { + const mockBindStream = vi.fn().mockResolvedValue({}); const mockLoadSession = vi.fn().mockResolvedValue({ - payload: { messages: [{ role: 'user', content: 'hello', tool_calls: [] }] }, - }) + payload: { + messages: [{ role: "user", content: "hello", tool_calls: [] }], + }, + }); const mockListSessionTodos = vi.fn().mockResolvedValue({ payload: { - items: [{ id: 't1', content: 'x', status: 'open', required: true, revision: 1 }], - summary: { total: 1, required_total: 1, required_completed: 0, required_failed: 0, required_open: 1 }, + items: [ + { + id: "t1", + content: "x", + status: "open", + required: true, + revision: 1, + }, + ], + summary: { + total: 1, + required_total: 1, + required_completed: 0, + required_failed: 0, + required_open: 1, + }, }, - }) - const mockGetRuntimeSnapshot = vi.fn().mockResolvedValue({ payload: {} }) + }); + const mockGetRuntimeSnapshot = vi.fn().mockResolvedValue({ payload: {} }); const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession, listSessionTodos: mockListSessionTodos, getRuntimeSnapshot: mockGetRuntimeSnapshot, - } as any + } as any; - await useSessionStore.getState().switchSession('sess-2', mockAPI) + await useSessionStore.getState().switchSession("sess-2", mockAPI); - expect(mockLoadSession).toHaveBeenCalledWith('sess-2') - expect(mockListSessionTodos).toHaveBeenCalledWith('sess-2') - expect(mockGetRuntimeSnapshot).toHaveBeenCalledWith('sess-2') + expect(mockLoadSession).toHaveBeenCalledWith("sess-2"); + expect(mockListSessionTodos).toHaveBeenCalledWith("sess-2"); + expect(mockGetRuntimeSnapshot).toHaveBeenCalledWith("sess-2"); - const insightStore = useRuntimeInsightStore.getState() - expect(insightStore.todoSnapshot?.items?.[0].id).toBe('t1') - }) + const insightStore = useRuntimeInsightStore.getState(); + expect(insightStore.todoSnapshot?.items?.[0].id).toBe("t1"); + }); - it('removeSessionLocally prunes empty groups', () => { + it("removeSessionLocally prunes empty groups", () => { useSessionStore.setState({ projects: [ { - id: 'group-a', - name: 'A', + id: "group-a", + name: "A", sessions: [ - { id: 'sess-1', title: 'One', time: '2026-05-10T00:00:00.000Z' }, + { id: "sess-1", title: "One", time: "2026-05-10T00:00:00.000Z" }, ], }, { - id: 'group-b', - name: 'B', + id: "group-b", + name: "B", sessions: [ - { id: 'sess-2', title: 'Two', time: '2026-05-10T00:00:00.000Z' }, - { id: 'sess-3', title: 'Three', time: '2026-05-10T00:00:00.000Z' }, + { id: "sess-2", title: "Two", time: "2026-05-10T00:00:00.000Z" }, + { id: "sess-3", title: "Three", time: "2026-05-10T00:00:00.000Z" }, ], }, ], - } as any) + } as any); - useSessionStore.getState().removeSessionLocally('sess-1') - useSessionStore.getState().removeSessionLocally('sess-2') + useSessionStore.getState().removeSessionLocally("sess-1"); + useSessionStore.getState().removeSessionLocally("sess-2"); expect(useSessionStore.getState().projects).toEqual([ { - id: 'group-b', - name: 'B', + id: "group-b", + name: "B", sessions: [ - { id: 'sess-3', title: 'Three', time: '2026-05-10T00:00:00.000Z' }, + { id: "sess-3", title: "Three", time: "2026-05-10T00:00:00.000Z" }, ], }, - ]) - }) -}) + ]); + }); +}); diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index 6cee1938..5967c715 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -1,236 +1,329 @@ -import { create } from 'zustand' -import { type GatewayAPI } from '@/api/gateway' -import { type SessionSummary as APISessionSummary } from '@/api/protocol' -import { useChatStore } from '@/stores/useChatStore' -import { useUIStore } from '@/stores/useUIStore' -import { useRuntimeInsightStore } from '@/stores/useRuntimeInsightStore' -import { parseDateTime } from '@/utils/format' +import { create } from "zustand"; +import { type GatewayAPI } from "@/api/gateway"; +import { + type PlanArtifact, + type SessionSummary as APISessionSummary, +} from "@/api/protocol"; +import { useChatStore } from "@/stores/useChatStore"; +import { useUIStore } from "@/stores/useUIStore"; +import { useRuntimeInsightStore } from "@/stores/useRuntimeInsightStore"; +import { parseDateTime } from "@/utils/format"; /** 判断 sessionId 是否有效(非空且不是临时草稿前缀) */ export function isValidSessionId(id: string): boolean { - return !!id && !id.startsWith('new_') + return !!id && !id.startsWith("new_"); } /** 会话摘要(UI 层展示用) */ export interface SessionSummary { - id: string - title: string - time: string - createdAt?: string - updatedAt?: string + id: string; + title: string; + time: string; + createdAt?: string; + updatedAt?: string; } /** 项目分组 */ export interface Project { - id: string - name: string - sessions: SessionSummary[] + id: string; + name: string; + sessions: SessionSummary[]; } /** 会话状态 */ interface SessionState { /** 项目列表 */ - projects: Project[] + projects: Project[]; /** 当前活跃会话 ID */ - currentSessionId: string + currentSessionId: string; /** 当前活跃会话所在项目 ID */ - currentProjectId: string + currentProjectId: string; /** 是否正在加载 */ - loading: boolean + loading: boolean; /** 上一次 switchSession 的 abort controller */ - _switchAbort: AbortController | null + _switchAbort: AbortController | null; /** 初始化时是否已完成 bindStream(避免 fetchSessions 和 initializeActiveSession 重复绑定) */ - _initialBindDone: boolean + _initialBindDone: boolean; // Actions - setProjects: (projects: Project[]) => void - setCurrentSessionId: (id: string) => void - setCurrentProjectId: (id: string) => void - setLoading: (loading: boolean) => void + setProjects: (projects: Project[]) => void; + setCurrentSessionId: (id: string) => void; + setCurrentProjectId: (id: string) => void; + setLoading: (loading: boolean) => void; /** 从后端拉取会话列表并映射为项目分组 */ - fetchSessions: (gatewayAPI: GatewayAPI, force?: boolean) => Promise + fetchSessions: (gatewayAPI: GatewayAPI, force?: boolean) => Promise; /** 切换到指定会话:清空消息 → 绑定流 → 加载历史消息 */ - switchSession: (sessionId: string, gatewayAPI: GatewayAPI) => Promise + switchSession: (sessionId: string, gatewayAPI: GatewayAPI) => Promise; /** 创建新会话:清空消息,等待 run 成功后由事件回写真实 session_id */ - createSession: () => void + createSession: () => void; /** 初始化当前活跃会话:如存在有效会话则绑定流 */ - initializeActiveSession: (gatewayAPI: GatewayAPI) => Promise + initializeActiveSession: (gatewayAPI: GatewayAPI) => Promise; /** 准备新的聊天输入状态 */ - prepareNewChat: () => void + prepareNewChat: () => void; /** 重置内部状态(工作区切换时调用,确保 fetchSessions 不使用过期数据) */ - resetForWorkspaceSwitch: () => void + resetForWorkspaceSwitch: () => void; /** 从本地 projects 列表中移除一个 session(乐观更新) */ - removeSessionLocally: (sessionId: string) => void + removeSessionLocally: (sessionId: string) => void; } /** 将后端扁平会话列表映射为项目分组结构 */ function mapSessionsToProjects(apiSessions: APISessionSummary[]): Project[] { - const now = new Date() - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const yesterday = new Date(today.getTime() - 86400000) - const weekAgo = new Date(today.getTime() - 7 * 86400000) + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86400000); + const weekAgo = new Date(today.getTime() - 7 * 86400000); const groups: Record = { - '今天': [], - '昨天': [], - '最近7天': [], - '更早': [], - } + 今天: [], + 昨天: [], + 最近7天: [], + 更早: [], + }; for (const s of apiSessions) { - const sessionTime = selectSessionDisplayTime(s) + const sessionTime = selectSessionDisplayTime(s); if (sessionTime >= today) { - groups['今天'].push(s) + groups["今天"].push(s); } else if (sessionTime >= yesterday) { - groups['昨天'].push(s) + groups["昨天"].push(s); } else if (sessionTime >= weekAgo) { - groups['最近7天'].push(s) + groups["最近7天"].push(s); } else { - groups['更早'].push(s) + groups["更早"].push(s); } } - const projects: Project[] = [] + const projects: Project[] = []; for (const [name, sessions] of Object.entries(groups)) { - if (sessions.length === 0) continue + if (sessions.length === 0) continue; projects.push({ id: `group_${name}`, name, sessions: sessions.map((s) => ({ id: s.id, - title: s.title || '未命名会话', + title: s.title || "未命名会话", time: selectSessionDisplayTime(s).toISOString(), createdAt: s.created_at, updatedAt: s.updated_at, })), - }) + }); } - return projects + return projects; } /** 选择会话展示时间:优先取 created/updated 中较新的有效时间,规避单字段异常。 */ function selectSessionDisplayTime(session: APISessionSummary): Date { - const created = parseDateTime(session.created_at) - const updated = parseDateTime(session.updated_at) - const createdValid = !isNaN(created.getTime()) - const updatedValid = !isNaN(updated.getTime()) + const created = parseDateTime(session.created_at); + const updated = parseDateTime(session.updated_at); + const createdValid = !isNaN(created.getTime()); + const updatedValid = !isNaN(updated.getTime()); if (createdValid && updatedValid) { - return updated.getTime() >= created.getTime() ? updated : created + return updated.getTime() >= created.getTime() ? updated : created; } - if (updatedValid) return updated - if (createdValid) return created + if (updatedValid) return updated; + if (createdValid) return created; // 当后端时间字段都无效时,不伪造“当前时间”,避免损坏记录冒充“刚刚更新”并扰乱排序。 - return new Date(0) + return new Date(0); } export type BackendMessage = { - role: string - content: string - tool_calls?: Array<{ id: string; name: string; arguments: string }> - tool_call_id?: string - is_error?: boolean -} + role: string; + content: string; + tool_calls?: Array<{ id: string; name: string; arguments: string }>; + tool_call_id?: string; + is_error?: boolean; +}; + +type BackendSessionData = { + messages?: BackendMessage[]; + agent_mode?: string; + current_plan?: PlanArtifact; +}; /** 并发拉取 session 详情 + todos + runtime snapshot,并把后者写入对应 store。 * todos / runtime snapshot 失败用 .catch 兜底,不阻断主流程的 loadSession。 */ -export async function loadSessionWithInsights(gatewayAPI: GatewayAPI, sessionId: string) { +export async function loadSessionWithInsights( + gatewayAPI: GatewayAPI, + sessionId: string, +) { const [sessionFrame, todosResult, runtimeSnapshotResult] = await Promise.all([ gatewayAPI.loadSession(sessionId), - (gatewayAPI.listSessionTodos?.(sessionId) ?? Promise.resolve(null)).catch(() => null), - (gatewayAPI.getRuntimeSnapshot?.(sessionId) ?? Promise.resolve(null)).catch(() => null), - ]) - const insightStore = useRuntimeInsightStore.getState() + (gatewayAPI.listSessionTodos?.(sessionId) ?? Promise.resolve(null)).catch( + () => null, + ), + (gatewayAPI.getRuntimeSnapshot?.(sessionId) ?? Promise.resolve(null)).catch( + () => null, + ), + ]); + const insightStore = useRuntimeInsightStore.getState(); if (todosResult?.payload) { - insightStore.setTodoSnapshot(todosResult.payload) + insightStore.setTodoSnapshot(todosResult.payload); } - const pendingQuestion = runtimeSnapshotResult?.payload?.pending_user_question + const pendingQuestion = runtimeSnapshotResult?.payload?.pending_user_question; if (pendingQuestion) { - useChatStore.getState().setPendingUserQuestion(pendingQuestion) + useChatStore.getState().setPendingUserQuestion(pendingQuestion); } else { - useChatStore.getState().clearPendingUserQuestion() + useChatStore.getState().clearPendingUserQuestion(); } - return sessionFrame + return sessionFrame; } /** isInternalHistoryMessage 识别仅供 runtime/provider 续跑使用、不能回放到 Web 聊天流的内部控制消息。 */ function isInternalHistoryMessage(msg: BackendMessage): boolean { - const role = msg.role.trim().toLowerCase() + const role = msg.role.trim().toLowerCase(); // 所有 system 角色消息仅供模型内部使用,不展示给用户 - if (role === 'system') return true - if (role !== 'assistant') return false + if (role === "system") return true; + if (role !== "assistant") return false; - const content = msg.content.trim() - if (!content) return false - return /^$/.test(content) + const content = msg.content.trim(); + if (!content) return false; + return /^$/.test(content); } /** 将后端历史消息映射为前端 ChatMessage 列表,正确合并 tool_result 回 tool_call */ -export function mapHistoryMessages(backendMessages: BackendMessage[]): Array['messages'][0]> { - let _idCounter = 0 +export function mapHistoryMessages( + backendMessages: BackendMessage[], +): Array["messages"][0]> { + let _idCounter = 0; // Phase 1: Collect tool results by tool_call_id - const toolResults = new Map() + const toolResults = new Map(); for (const msg of backendMessages) { - if (isInternalHistoryMessage(msg)) continue + if (isInternalHistoryMessage(msg)) continue; if (msg.tool_call_id) { - toolResults.set(msg.tool_call_id, { content: msg.content, isError: !!msg.is_error }) + toolResults.set(msg.tool_call_id, { + content: msg.content, + isError: !!msg.is_error, + }); } } // Phase 2: Map messages, merging tool results into corresponding tool_calls - const result: Array['messages'][0]> = [] + const result: Array["messages"][0]> = + []; for (const msg of backendMessages) { - if (isInternalHistoryMessage(msg)) continue + if (isInternalHistoryMessage(msg)) continue; // Skip bare tool result messages — they are merged into tool_call messages - if (msg.tool_call_id) continue + if (msg.tool_call_id) continue; if (msg.tool_calls && msg.tool_calls.length > 0) { // If assistant message also has text content, emit that first - if (msg.content && msg.role === 'assistant') { + if (msg.content && msg.role === "assistant") { result.push({ id: `hist_${Date.now()}_${_idCounter++}`, - role: 'assistant', - type: 'text', + role: "assistant", + type: "text", content: msg.content, timestamp: Date.now(), - }) + }); } // Map each tool call, merging its result if available for (const tc of msg.tool_calls) { - const tr = toolResults.get(tc.id) + const tr = toolResults.get(tc.id); result.push({ id: `hist_tc_${tc.id}_${_idCounter++}`, - role: 'tool', - type: 'tool_call', - content: '', + role: "tool", + type: "tool_call", + content: "", toolName: tc.name, toolCallId: tc.id, toolArgs: tc.arguments, toolResult: tr?.content, - toolStatus: tr ? (tr.isError ? 'error' as const : 'done' as const) : 'done' as const, + toolStatus: tr + ? tr.isError + ? ("error" as const) + : ("done" as const) + : ("done" as const), timestamp: Date.now(), - }) + }); } } else { result.push({ id: `hist_${msg.role}_${Date.now()}_${_idCounter++}`, - role: (msg.role as 'user' | 'assistant' | 'tool') || 'assistant', - type: 'text', + role: (msg.role as "user" | "assistant" | "tool") || "assistant", + type: "text", content: msg.content, timestamp: Date.now(), - }) + }); + } + } + return result; +} + +function createHistoryPlanMessage( + plan: PlanArtifact, +): ReturnType["messages"][0] { + return { + id: `hist_plan_${plan.id}_${plan.revision}`, + role: "assistant", + type: "plan", + content: plan.spec?.goal || plan.summary?.goal || "", + planData: plan, + timestamp: Date.now(), + }; +} + +function isRenderedCurrentPlanMessage( + msg: BackendMessage, + plan: PlanArtifact, +): boolean { + if (msg.role.trim().toLowerCase() !== "assistant") return false; + if (msg.tool_calls && msg.tool_calls.length > 0) return false; + const content = msg.content.trim(); + const goal = plan.spec?.goal?.trim(); + if (!content || !goal) return false; + return ( + content.startsWith(`### 目标\n\n${goal}`) || + content.startsWith(`### 目标\r\n\r\n${goal}`) || + content.startsWith(`目标\n${goal}`) || + content.startsWith(`目标\r\n${goal}`) + ); +} + +function mapSessionMessages( + sessionData: BackendSessionData, +): Array["messages"][0]> { + const plan = sessionData.current_plan; + const sourceMessages = sessionData.messages || []; + if (!plan) return mapHistoryMessages(sourceMessages); + + const mapped: Array["messages"][0]> = + []; + let pendingSegment: BackendMessage[] = []; + let insertedPlan = false; + + const flushPendingSegment = () => { + if (pendingSegment.length === 0) return; + mapped.push(...mapHistoryMessages(pendingSegment)); + pendingSegment = []; + }; + + for (const msg of sourceMessages) { + if (isRenderedCurrentPlanMessage(msg, plan)) { + flushPendingSegment(); + if (!insertedPlan) { + mapped.push(createHistoryPlanMessage(plan)); + insertedPlan = true; + } + continue; } + pendingSegment.push(msg); + } + flushPendingSegment(); + + if (!insertedPlan) { + mapped.push(createHistoryPlanMessage(plan)); } - return result + return mapped; } -let _latestCheckpointRestoreReloadSeq = 0 +let _latestCheckpointRestoreReloadSeq = 0; /** beginCheckpointRestoreReloadSeq 申请一次 checkpoint 回退重载序号,用于丢弃过期重载结果。 */ export function beginCheckpointRestoreReloadSeq(): number { - _latestCheckpointRestoreReloadSeq += 1 - return _latestCheckpointRestoreReloadSeq + _latestCheckpointRestoreReloadSeq += 1; + return _latestCheckpointRestoreReloadSeq; } /** checkpoint 回退后全量重载会话状态,统一刷新消息、insight 与文件变更面板。 */ @@ -239,35 +332,41 @@ export async function reloadSessionAfterCheckpointRestore( sessionId: string, reloadSeq: number = beginCheckpointRestoreReloadSeq(), ): Promise { - const normalizedSessionId = sessionId.trim() - if (!normalizedSessionId) return false - - useUIStore.getState().clearFileChanges() - useChatStore.getState().clearMessages() - useRuntimeInsightStore.getState().reset() - - const sessionFrame = await loadSessionWithInsights(gatewayAPI, normalizedSessionId) - if (reloadSeq !== _latestCheckpointRestoreReloadSeq) return false - const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } - - if (sessionData.messages && sessionData.messages.length > 0) { - const mapped = mapHistoryMessages(sessionData.messages) - useChatStore.getState().setMessages(mapped) + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) return false; + + useUIStore.getState().clearFileChanges(); + useChatStore.getState().clearMessages(); + useRuntimeInsightStore.getState().reset(); + + const sessionFrame = await loadSessionWithInsights( + gatewayAPI, + normalizedSessionId, + ); + if (reloadSeq !== _latestCheckpointRestoreReloadSeq) return false; + const sessionData = sessionFrame.payload as BackendSessionData; + + if ( + (sessionData.messages && sessionData.messages.length > 0) || + sessionData.current_plan + ) { + const mapped = mapSessionMessages(sessionData); + useChatStore.getState().setMessages(mapped); } - const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' - useChatStore.getState().setAgentMode(restoredMode) - return true + const restoredMode = sessionData.agent_mode === "plan" ? "plan" : "build"; + useChatStore.getState().setAgentMode(restoredMode); + return true; } -let _fetchSessionsPromise: Promise | null = null -let _fetchSessionsSeq = 0 -let _switchSessionSeq = 0 +let _fetchSessionsPromise: Promise | null = null; +let _fetchSessionsSeq = 0; +let _switchSessionSeq = 0; export const useSessionStore = create((set, get) => ({ projects: [], - currentSessionId: '', - currentProjectId: '', + currentSessionId: "", + currentProjectId: "", loading: false, _switchAbort: null, _initialBindDone: false, @@ -278,186 +377,238 @@ export const useSessionStore = create((set, get) => ({ setLoading: (loading) => set({ loading }), switchSession: async (sessionId: string, gatewayAPI: GatewayAPI) => { - if (!sessionId) return + if (!sessionId) return; // 生成中拒绝切换会话 if (useChatStore.getState().isGenerating) { - useUIStore.getState().showToast('Cannot switch session while generating; stop the current run first.', 'info') - return + useUIStore + .getState() + .showToast( + "Cannot switch session while generating; stop the current run first.", + "info", + ); + return; } // Abort previous switchSession if still in progress - const prevAbort = get()._switchAbort + const prevAbort = get()._switchAbort; if (prevAbort) { - prevAbort.abort() + prevAbort.abort(); } - const switchSeq = ++_switchSessionSeq - const abortCtrl = new AbortController() - set({ _switchAbort: abortCtrl, loading: true }) + const switchSeq = ++_switchSessionSeq; + const abortCtrl = new AbortController(); + set({ _switchAbort: abortCtrl, loading: true }); - const prevSessionId = get().currentSessionId + const prevSessionId = get().currentSessionId; try { // 1. Clear messages first, then enter transitioning state to keep event drop window effective - const chatStore = useChatStore.getState() - chatStore.clearMessages() - chatStore.setTransitioning(true) - useRuntimeInsightStore.getState().reset() - useUIStore.getState().clearCheckpointRollbackUndo() + const chatStore = useChatStore.getState(); + chatStore.clearMessages(); + chatStore.setTransitioning(true); + useRuntimeInsightStore.getState().reset(); + useUIStore.getState().clearCheckpointRollbackUndo(); // 2. Update session ID - set({ currentSessionId: sessionId }) + set({ currentSessionId: sessionId }); // 3. Bind stream (events will be discarded due to isTransitioning) - await gatewayAPI.bindStream({ session_id: sessionId, channel: 'all' }) - if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return + await gatewayAPI.bindStream({ session_id: sessionId, channel: "all" }); + if ( + abortCtrl.signal.aborted || + switchSeq !== _switchSessionSeq || + get()._switchAbort !== abortCtrl + ) + return; // 4. Load historical messages (concurrently fetch todos + runtime snapshot) - const sessionFrame = await loadSessionWithInsights(gatewayAPI, sessionId) - if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return - const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } + const sessionFrame = await loadSessionWithInsights(gatewayAPI, sessionId); + if ( + abortCtrl.signal.aborted || + switchSeq !== _switchSessionSeq || + get()._switchAbort !== abortCtrl + ) + return; + const sessionData = sessionFrame.payload as BackendSessionData; // Check if this request was superseded - if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return + if ( + abortCtrl.signal.aborted || + switchSeq !== _switchSessionSeq || + get()._switchAbort !== abortCtrl + ) + return; // 5. Load messages and stop transitioning - if (sessionData.messages && sessionData.messages.length > 0) { - const mapped = mapHistoryMessages(sessionData.messages) - useChatStore.getState().setMessages(mapped) + if ( + (sessionData.messages && sessionData.messages.length > 0) || + sessionData.current_plan + ) { + const mapped = mapSessionMessages(sessionData); + useChatStore.getState().setMessages(mapped); } // 恢复会话的 agent_mode - const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' - useChatStore.getState().setAgentMode(restoredMode) - chatStore.setTransitioning(false) + const restoredMode = sessionData.agent_mode === "plan" ? "plan" : "build"; + useChatStore.getState().setAgentMode(restoredMode); + chatStore.setTransitioning(false); } catch (err) { - if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return - console.error('switchSession failed:', err) + if ( + abortCtrl.signal.aborted || + switchSeq !== _switchSessionSeq || + get()._switchAbort !== abortCtrl + ) + return; + console.error("switchSession failed:", err); // Revert to previous session and re-bind its stream - set({ currentSessionId: prevSessionId }) + set({ currentSessionId: prevSessionId }); if (isValidSessionId(prevSessionId)) { - gatewayAPI.bindStream({ session_id: prevSessionId, channel: 'all' }).catch(() => {}) + gatewayAPI + .bindStream({ session_id: prevSessionId, channel: "all" }) + .catch(() => {}); } - useChatStore.getState().setTransitioning(false) + useChatStore.getState().setTransitioning(false); } finally { if (switchSeq === _switchSessionSeq && get()._switchAbort === abortCtrl) { - set({ loading: false, _switchAbort: null }) + set({ loading: false, _switchAbort: null }); } } }, createSession: () => { if (useChatStore.getState().isGenerating) { - useUIStore.getState().showToast('Cannot start a new session while generating; stop the current run first.', 'info') - return + useUIStore + .getState() + .showToast( + "Cannot start a new session while generating; stop the current run first.", + "info", + ); + return; } - useChatStore.getState().clearMessages() - useRuntimeInsightStore.getState().reset() - useUIStore.getState().clearCheckpointRollbackUndo() - set({ currentSessionId: '', currentProjectId: '' }) + useChatStore.getState().clearMessages(); + useRuntimeInsightStore.getState().reset(); + useUIStore.getState().clearCheckpointRollbackUndo(); + set({ currentSessionId: "", currentProjectId: "" }); }, initializeActiveSession: async (gatewayAPI) => { - const state = get() - const sessionId = state.currentSessionId + const state = get(); + const sessionId = state.currentSessionId; // fetchSessions already binds the first session, so skip if already bound if (isValidSessionId(sessionId) && !state._initialBindDone) { try { - await gatewayAPI.bindStream({ session_id: sessionId, channel: 'all' }) - set({ _initialBindDone: true }) + await gatewayAPI.bindStream({ session_id: sessionId, channel: "all" }); + set({ _initialBindDone: true }); } catch (err) { - console.error('initializeActiveSession bindStream failed:', err) - useUIStore.getState().showToast('Failed to bind event stream; real-time messages may not arrive.', 'error') + console.error("initializeActiveSession bindStream failed:", err); + useUIStore + .getState() + .showToast( + "Failed to bind event stream; real-time messages may not arrive.", + "error", + ); } } }, prepareNewChat: () => { if (useChatStore.getState().isGenerating) { - useUIStore.getState().showToast('Cannot start a new session while generating; stop the current run first.', 'info') - return + useUIStore + .getState() + .showToast( + "Cannot start a new session while generating; stop the current run first.", + "info", + ); + return; } - useChatStore.getState().clearMessages() - useRuntimeInsightStore.getState().reset() - useUIStore.getState().clearCheckpointRollbackUndo() - set({ currentSessionId: '', currentProjectId: '' }) + useChatStore.getState().clearMessages(); + useRuntimeInsightStore.getState().reset(); + useUIStore.getState().clearCheckpointRollbackUndo(); + set({ currentSessionId: "", currentProjectId: "" }); }, resetForWorkspaceSwitch: () => { - const currentAbort = get()._switchAbort + const currentAbort = get()._switchAbort; if (currentAbort) { - currentAbort.abort() + currentAbort.abort(); } - _fetchSessionsPromise = null - _fetchSessionsSeq += 1 - _switchSessionSeq += 1 - set({ _initialBindDone: false, loading: false, _switchAbort: null }) + _fetchSessionsPromise = null; + _fetchSessionsSeq += 1; + _switchSessionSeq += 1; + set({ _initialBindDone: false, loading: false, _switchAbort: null }); }, removeSessionLocally: (sessionId) => { - const projects = get().projects - .map((p) => ({ ...p, sessions: p.sessions.filter((s) => s.id !== sessionId) })) - .filter((p) => p.sessions.length > 0) - set({ projects }) + const projects = get() + .projects.map((p) => ({ + ...p, + sessions: p.sessions.filter((s) => s.id !== sessionId), + })) + .filter((p) => p.sessions.length > 0); + set({ projects }); }, fetchSessions: async (gatewayAPI, force) => { // 去重:若已有 fetch 在进行中,复用同一 promise(force 跳过去重) - if (!force && _fetchSessionsPromise) return _fetchSessionsPromise + if (!force && _fetchSessionsPromise) return _fetchSessionsPromise; - const requestSeq = ++_fetchSessionsSeq + const requestSeq = ++_fetchSessionsSeq; const fetchPromise = (async () => { - set({ loading: true }) + set({ loading: true }); try { - const result = await gatewayAPI.listSessions() - if (requestSeq !== _fetchSessionsSeq) return - const sessions = result.payload.sessions - const projects = mapSessionsToProjects(sessions) - set({ projects, loading: false }) - - let state = get() - if (requestSeq !== _fetchSessionsSeq) return - const currentSessionVisible = isValidSessionId(state.currentSessionId) && - sessions.some((session) => session.id === state.currentSessionId) - if (isValidSessionId(state.currentSessionId) && !currentSessionVisible) { - set({ currentSessionId: '', currentProjectId: '', _initialBindDone: false }) - state = get() - } + const result = await gatewayAPI.listSessions(); + if (requestSeq !== _fetchSessionsSeq) return; + const sessions = result.payload.sessions; + const projects = mapSessionsToProjects(sessions); + set({ projects, loading: false }); + + const state = get(); + if (requestSeq !== _fetchSessionsSeq) return; if (!isValidSessionId(state.currentSessionId) && sessions.length > 0) { - const firstSession = sessions[0] - set({ currentSessionId: firstSession.id }) + const firstSession = sessions[0]; + set({ currentSessionId: firstSession.id }); try { - await gatewayAPI.bindStream({ session_id: firstSession.id, channel: 'all' }) - if (requestSeq !== _fetchSessionsSeq) return - set({ _initialBindDone: true }) + await gatewayAPI.bindStream({ + session_id: firstSession.id, + channel: "all", + }); + if (requestSeq !== _fetchSessionsSeq) return; + set({ _initialBindDone: true }); // Load historical messages for the auto-selected session (concurrently fetch todos + runtime snapshot) - const sessionFrame = await loadSessionWithInsights(gatewayAPI, firstSession.id) - if (requestSeq !== _fetchSessionsSeq) return - const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } - if (sessionData.messages && sessionData.messages.length > 0) { - const mapped = mapHistoryMessages(sessionData.messages) - useChatStore.getState().setMessages(mapped) + const sessionFrame = await loadSessionWithInsights( + gatewayAPI, + firstSession.id, + ); + if (requestSeq !== _fetchSessionsSeq) return; + const sessionData = sessionFrame.payload as BackendSessionData; + if ( + (sessionData.messages && sessionData.messages.length > 0) || + sessionData.current_plan + ) { + const mapped = mapSessionMessages(sessionData); + useChatStore.getState().setMessages(mapped); } - const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' - useChatStore.getState().setAgentMode(restoredMode) + const restoredMode = + sessionData.agent_mode === "plan" ? "plan" : "build"; + useChatStore.getState().setAgentMode(restoredMode); } catch (err) { - if (requestSeq !== _fetchSessionsSeq) return - console.error('Auto bindStream or loadSession failed:', err) - useUIStore.getState().showToast('Failed to load session', 'error') + if (requestSeq !== _fetchSessionsSeq) return; + console.error("Auto bindStream or loadSession failed:", err); + useUIStore.getState().showToast("Failed to load session", "error"); } } } catch (err) { - if (requestSeq !== _fetchSessionsSeq) return - console.error('fetchSessions failed:', err) - set({ projects: [], loading: false }) + if (requestSeq !== _fetchSessionsSeq) return; + console.error("fetchSessions failed:", err); + set({ projects: [], loading: false }); } finally { if (requestSeq === _fetchSessionsSeq) { - _fetchSessionsPromise = null + _fetchSessionsPromise = null; } } - })() + })(); - _fetchSessionsPromise = fetchPromise - return fetchPromise + _fetchSessionsPromise = fetchPromise; + return fetchPromise; }, -})) +})); diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index c7710c37..c1148d27 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -30,11 +30,13 @@ beforeEach(() => { compactMode: "", compactMessage: "", streamingMessageId: "", + streamingThinkingMessageId: "", permissionRequests: [], pendingUserQuestion: null, tokenUsage: null, phase: "", stopReason: "", + agentMode: "build", } as any); useGatewayStore.setState({ connectionState: "disconnected", @@ -103,6 +105,278 @@ describe("eventBridge", () => { expect(useChatStore.getState().messages[0].content).toBe("Hello"); }); + it("suppresses plan chunks and renders plan_updated as a plan card", () => { + const api = createMockGatewayAPI(); + useChatStore.getState().setAgentMode("plan"); + + handleGatewayEvent( + { + type: EventType.AgentChunk, + payload: { + payload: { + runtime_event_type: EventType.AgentChunk, + payload: '{"plan_spec":', + }, + }, + run_id: "run-plan", + }, + api, + ); + expect(useChatStore.getState().messages).toHaveLength(0); + + handleGatewayEvent( + { + type: EventType.PlanUpdated, + payload: { + payload: { + runtime_event_type: EventType.PlanUpdated, + payload: { + current_plan: { + id: "plan-1", + revision: 1, + status: "draft", + spec: { + goal: "修复计划展示", + steps: ["发事件"], + constraints: ["不显示 JSON"], + open_questions: ["是否审批"], + }, + summary: { goal: "修复计划展示", key_steps: ["发事件"] }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + }, + }, + }, + run_id: "run-plan", + }, + api, + ); + + handleGatewayEvent( + { + type: EventType.AgentDone, + payload: { + payload: { + runtime_event_type: EventType.AgentDone, + payload: { parts: [{ text: "目标\n修复计划展示" }] }, + }, + }, + run_id: "run-plan", + }, + api, + ); + + const msgs = useChatStore.getState().messages; + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ + role: "assistant", + type: "plan", + planData: { id: "plan-1", revision: 1 }, + }); + }); + + it("removes leaked streaming planning JSON when plan_updated arrives", () => { + const api = createMockGatewayAPI(); + + handleGatewayEvent( + { + type: EventType.AgentChunk, + payload: { + payload: { + runtime_event_type: EventType.AgentChunk, + payload: '```json\n{"plan_spec":{"goal":"leaked json"', + }, + }, + run_id: "run-plan", + }, + api, + ); + expect(useChatStore.getState().messages).toHaveLength(1); + + handleGatewayEvent( + { + type: EventType.PlanUpdated, + payload: { + payload: { + runtime_event_type: EventType.PlanUpdated, + payload: { + current_plan: { + id: "plan-1", + revision: 1, + status: "draft", + spec: { + goal: "Readable markdown plan", + steps: ["Show a card"], + }, + summary: { + goal: "Readable markdown plan", + key_steps: ["Show a card"], + }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + display_text: + "### Goal\n\nReadable markdown plan\n\n### Steps\n\n- Show a card", + }, + }, + }, + run_id: "run-plan", + }, + api, + ); + + const msgs = useChatStore.getState().messages; + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ + type: "plan", + content: + "### Goal\n\nReadable markdown plan\n\n### Steps\n\n- Show a card", + }); + expect(msgs[0].content).not.toContain("plan_spec"); + }); + + it("appends a new plan card for a new plan revision", () => { + const api = createMockGatewayAPI(); + const planPayload = (revision: number, goal: string) => ({ + current_plan: { + id: "plan-1", + revision, + status: "draft", + spec: { + goal, + steps: [`step ${revision}`], + }, + summary: { + goal, + key_steps: [`step ${revision}`], + }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + display_text: `### Goal\n\n${goal}`, + }); + + handleGatewayEvent( + { + type: EventType.PlanUpdated, + payload: { + payload: { + runtime_event_type: EventType.PlanUpdated, + payload: planPayload(1, "first plan"), + }, + }, + run_id: "run-plan-1", + }, + api, + ); + useChatStore.getState().addMessage({ + id: "user-2", + role: "user", + type: "text", + content: "revise it", + timestamp: 2, + }); + handleGatewayEvent( + { + type: EventType.PlanUpdated, + payload: { + payload: { + runtime_event_type: EventType.PlanUpdated, + payload: planPayload(2, "second plan"), + }, + }, + run_id: "run-plan-2", + }, + api, + ); + + const msgs = useChatStore.getState().messages; + expect(msgs).toHaveLength(3); + expect(msgs[0]).toMatchObject({ + type: "plan", + planData: { id: "plan-1", revision: 1 }, + content: "### Goal\n\nfirst plan", + }); + expect(msgs[1]).toMatchObject({ role: "user", content: "revise it" }); + expect(msgs[2]).toMatchObject({ + type: "plan", + planData: { id: "plan-1", revision: 2 }, + content: "### Goal\n\nsecond plan", + }); + }); + + it("updates an existing card for the same plan revision", () => { + const api = createMockGatewayAPI(); + const payload = (display_text: string) => ({ + current_plan: { + id: "plan-1", + revision: 1, + status: "draft", + spec: { + goal: "same revision", + steps: ["step"], + }, + summary: { + goal: "same revision", + key_steps: ["step"], + }, + created_at: "2026-05-20T00:00:00Z", + updated_at: "2026-05-20T00:00:00Z", + }, + display_text, + }); + + for (const displayText of ["first display", "updated display"]) { + handleGatewayEvent( + { + type: EventType.PlanUpdated, + payload: { + payload: { + runtime_event_type: EventType.PlanUpdated, + payload: payload(displayText), + }, + }, + run_id: "run-plan-1", + }, + api, + ); + } + + const msgs = useChatStore.getState().messages; + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ + type: "plan", + planData: { id: "plan-1", revision: 1 }, + content: "updated display", + }); + }); + + it("AgentDone creates a message when provider did not stream chunks", () => { + const api = createMockGatewayAPI(); + handleGatewayEvent( + { + type: EventType.AgentDone, + payload: { + payload: { + runtime_event_type: EventType.AgentDone, + payload: { parts: [{ text: "final answer" }] }, + }, + }, + run_id: "run-plain", + }, + api, + ); + + const msgs = useChatStore.getState().messages; + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ + type: "text", + content: "final answer", + streaming: false, + }); + }); + it("drops stale session events after session switch for tool and chunk updates", () => { const api = createMockGatewayAPI(); useSessionStore.setState({ currentSessionId: "sess-new" } as any); @@ -150,7 +424,10 @@ describe("eventBridge", () => { { type: EventType.AgentChunk, payload: { - payload: { runtime_event_type: EventType.AgentChunk, payload: "stale chunk" }, + payload: { + runtime_event_type: EventType.AgentChunk, + payload: "stale chunk", + }, }, session_id: "sess-old", run_id: "run-old", @@ -171,7 +448,9 @@ describe("eventBridge", () => { useGatewayStore.getState(), "setCurrentRunId", ); - const listSessions = vi.fn().mockResolvedValue({ payload: { sessions: [] } }); + const listSessions = vi + .fn() + .mockResolvedValue({ payload: { sessions: [] } }); const api = createMockGatewayAPI({ listSessions }); useSessionStore.setState({ currentSessionId: "sess-new" } as any); useGatewayStore.setState({ currentRunId: "run-current" } as any); @@ -210,7 +489,9 @@ describe("eventBridge", () => { useGatewayStore.getState(), "setCurrentRunId", ); - const listSessions = vi.fn().mockResolvedValue({ payload: { sessions: [] } }); + const listSessions = vi + .fn() + .mockResolvedValue({ payload: { sessions: [] } }); const api = createMockGatewayAPI({ listSessions }); handleGatewayEvent( @@ -411,7 +692,9 @@ describe("eventBridge", () => { ); expect(useChatStore.getState().permissionRequests).toHaveLength(1); - expect(useChatStore.getState().permissionRequests[0].request_id).toBe("perm-1"); + expect(useChatStore.getState().permissionRequests[0].request_id).toBe( + "perm-1", + ); handleGatewayEvent( { @@ -804,7 +1087,12 @@ describe("eventBridge", () => { payload: { payload: { runtime_event_type: EventType.TokenUsage, - payload: { input_tokens: 3, output_tokens: 5, session_input_tokens: 3, session_output_tokens: 5 }, + payload: { + input_tokens: 3, + output_tokens: 5, + session_input_tokens: 3, + session_output_tokens: 5, + }, }, }, session_id: "sess-1", @@ -820,7 +1108,11 @@ describe("eventBridge", () => { payload: { payload: { runtime_event_type: EventType.BudgetEstimateFailed, - payload: { attempt_seq: 1, request_hash: "hash-1", message: "no price rule" }, + payload: { + attempt_seq: 1, + request_hash: "hash-1", + message: "no price rule", + }, }, }, session_id: "sess-1", @@ -828,9 +1120,9 @@ describe("eventBridge", () => { }, api, ); - expect(useRuntimeInsightStore.getState().budgetEstimateFailed?.message).toBe( - "no price rule", - ); + expect( + useRuntimeInsightStore.getState().budgetEstimateFailed?.message, + ).toBe("no price rule"); handleGatewayEvent( { @@ -854,9 +1146,9 @@ describe("eventBridge", () => { }, api, ); - expect(useRuntimeInsightStore.getState().ledgerReconciled?.output_tokens).toBe( - 5, - ); + expect( + useRuntimeInsightStore.getState().ledgerReconciled?.output_tokens, + ).toBe(5); }); it("CompactStart sets persistent compact state without a toast", () => { @@ -911,9 +1203,7 @@ describe("eventBridge", () => { it("CompactApplied clears compact state and shows completion toast", () => { const api = createMockGatewayAPI(); - useChatStore - .getState() - .startCompacting("manual", "Compacting context..."); + useChatStore.getState().startCompacting("manual", "Compacting context..."); handleGatewayEvent( { @@ -940,7 +1230,10 @@ describe("eventBridge", () => { const api = createMockGatewayAPI(); useChatStore .getState() - .startCompacting("proactive", "Context is near the limit. Auto-compacting..."); + .startCompacting( + "proactive", + "Context is near the limit. Auto-compacting...", + ); handleGatewayEvent( { @@ -963,9 +1256,7 @@ describe("eventBridge", () => { it("CompactError clears compact state and uses payload message", () => { const api = createMockGatewayAPI(); - useChatStore - .getState() - .startCompacting("manual", "Compacting context..."); + useChatStore.getState().startCompacting("manual", "Compacting context..."); handleGatewayEvent( { @@ -992,7 +1283,10 @@ describe("eventBridge", () => { const api = createMockGatewayAPI(); useChatStore .getState() - .startCompacting("reactive", "Model reported context too long. Compacting and retrying..."); + .startCompacting( + "reactive", + "Model reported context too long. Compacting and retrying...", + ); handleGatewayEvent( { @@ -1000,7 +1294,10 @@ describe("eventBridge", () => { payload: { payload: { runtime_event_type: EventType.CompactError, - payload: { TriggerMode: "reactive", Message: "context still too long" }, + payload: { + TriggerMode: "reactive", + Message: "context still too long", + }, }, }, session_id: "sess-1", @@ -1945,7 +2242,9 @@ describe("eventBridge", () => { ); } - const toastMessages = useUIStore.getState().toasts.map((toast) => toast.message); + const toastMessages = useUIStore + .getState() + .toasts.map((toast) => toast.message); expect(toastMessages).toContain("Skill activated: skill-a"); expect(toastMessages).toContain("Skill deactivated: skill-a"); expect(toastMessages).toContain("Skill unavailable: skill-b"); @@ -2010,10 +2309,12 @@ describe("eventBridge", () => { .messages.find((m) => m.type === "tool_call"); expect((toolMsg as any)?.checkpointId).toBeUndefined(); expect((toolMsg as any)?.checkpointStatus).toBeUndefined(); - expect(useRuntimeInsightStore.getState().checkpointEvents[0]).toMatchObject({ - checkpoint_id: "cp1", - reason: "pre_write", - }); + expect(useRuntimeInsightStore.getState().checkpointEvents[0]).toMatchObject( + { + checkpoint_id: "cp1", + reason: "pre_write", + }, + ); }); it("CheckpointCreated with pre_restore_guard does not override latest rollback baseline", () => { diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 925b6090..e947a2a2 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -13,6 +13,7 @@ import { type MessageFrame, type PermissionRequestPayload, type PendingUserQuestionSnapshot, + type PlanUpdatedPayload, type TodoEventPayload, type TokenUsage, type VerificationCompletedPayload, @@ -52,6 +53,9 @@ let _pendingNextRunRollbackCheckpointId: string | undefined; let _currentRollbackRunId: string | undefined; // 标记 pending 基线已应用到哪个 run;切到下一 run 时自动失效。 let _pendingRollbackAppliedRunId: string | undefined; +// plan 模式下先缓存文本流,等待结构化 plan_updated 决定最终展示。 +let _planChunkBufferByRunId = new Map(); +let _planUpdatedRunIds = new Set(); const CHECKPOINT_REASON_PRE_RESTORE_GUARD = "pre_restore_guard"; /** 重置模块级游标 —— 在截断聊天历史 / 切换会话等场景调用,避免后续事件挂到已被移除的消息上 */ @@ -68,6 +72,8 @@ export function resetEventBridgeCursors() { _pendingNextRunRollbackCheckpointId = keepCheckpointBaseline ? _pendingNextRunRollbackCheckpointId : undefined; + _planChunkBufferByRunId = new Map(); + _planUpdatedRunIds = new Set(); _latestRunDiffRequestId += 1; if (!keepCheckpointBaseline) { _latestRestoreSyncRequestId += 1; @@ -373,7 +379,8 @@ function _refreshRunFileChanges( .then((result) => { if (requestId !== _latestRunDiffRequestId) return; if (runId !== useGatewayStore.getState().currentRunId) return; - if (sessionId !== useSessionStore.getState().currentSessionId) return; + const currentSessionId = useSessionStore.getState().currentSessionId; + if (currentSessionId && sessionId !== currentSessionId) return; if (!result?.payload) return; if (result.payload.warning) { useUIStore @@ -533,14 +540,25 @@ function normalizeUserQuestionRequestedPayload( } const CRITICAL_EVENTS = new Set([EventType.Error]); -const SESSION_AGNOSTIC_EVENTS = new Set([ - EventType.Error, -]); +const SESSION_AGNOSTIC_EVENTS = new Set([EventType.Error]); function strField(payload: unknown, key: string): string { return ((payload as PayloadRecord)?.[key] as string) ?? ""; } +function getRunKey(frameRunId: string | undefined): string { + return (frameRunId || useGatewayStore.getState().currentRunId || "").trim(); +} + +function extractAgentDoneContent(eventPayload: unknown): string { + const parts = (eventPayload as { parts?: { text?: string }[] } | undefined) + ?.parts; + if (parts && Array.isArray(parts)) { + return parts.map((p) => p?.text ?? "").join(""); + } + return strField(eventPayload, "content"); +} + function resolveCompactMode(payload: unknown): string { if (typeof payload === "string") return payload.trim() || "manual"; return ( @@ -564,7 +582,9 @@ function compactMessageForMode(mode: string): string { function compactErrorMessageForMode(mode: string, message: string): string { if (mode === "proactive" || mode === "reactive") { - return message ? `Auto context compaction failed: ${message}` : "Auto context compaction failed"; + return message + ? `Auto context compaction failed: ${message}` + : "Auto context compaction failed"; } return message || "Compaction failed"; } @@ -678,6 +698,16 @@ export function handleGatewayEvent( } const text = eventPayload as string | undefined; if (!text) break; + if (chatStore.agentMode === "plan") { + const runKey = getRunKey(frameRunId); + if (runKey) { + _planChunkBufferByRunId.set( + runKey, + (_planChunkBufferByRunId.get(runKey) ?? "") + text, + ); + } + break; + } if (!chatStore.streamingMessageId) { chatStore.startStreamingMessage(); } @@ -685,20 +715,60 @@ export function handleGatewayEvent( break; } + case EventType.PlanUpdated: { + if (chatStore.streamingThinkingMessageId) { + chatStore.finalizeThinkingMessage(); + } + const payload = eventPayload as PlanUpdatedPayload | undefined; + if (payload?.current_plan) { + const activeStreamingID = useChatStore.getState().streamingMessageId; + if (activeStreamingID) { + useChatStore.getState().removeMessage(activeStreamingID); + useChatStore.getState().setStreamingMessageId(""); + } + const runKey = getRunKey(frameRunId); + if (runKey) { + _planUpdatedRunIds.add(runKey); + _planChunkBufferByRunId.delete(runKey); + } + useChatStore + .getState() + .upsertPlanMessage(payload.current_plan, payload.display_text); + } + break; + } + case EventType.AgentDone: { if (chatStore.streamingThinkingMessageId) { chatStore.finalizeThinkingMessage(); } + const runKey = getRunKey(frameRunId); + const content = extractAgentDoneContent(eventPayload); + if (runKey && _planUpdatedRunIds.has(runKey)) { + if (chatStore.streamingMessageId) { + const streamingID = chatStore.streamingMessageId; + chatStore.finalizeMessage(streamingID, ""); + useChatStore.getState().removeMessage(streamingID); + } + _planUpdatedRunIds.delete(runKey); + _planChunkBufferByRunId.delete(runKey); + chatStore.setGenerating(false); + chatStore.finalizeRunningToolCalls("done"); + if (frameSessionId) { + useSessionStore + .getState() + .fetchSessions(gatewayAPI, true) + .catch(() => {}); + } + break; + } if (chatStore.streamingMessageId) { - const parts = ( - eventPayload as { parts?: { text?: string }[] } | undefined - )?.parts; - const content = - parts && Array.isArray(parts) - ? parts.map((p) => p?.text ?? "").join("") - : strField(eventPayload, "content"); chatStore.finalizeMessage(chatStore.streamingMessageId, content); + } else if (content) { + const id = chatStore.startStreamingMessage(); + useChatStore.getState().finalizeMessage(id, content); } + if (runKey) _planChunkBufferByRunId.delete(runKey); chatStore.setGenerating(false); chatStore.finalizeRunningToolCalls("done"); if (frameSessionId) {