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 ? (
) : (
-
+
)}
-
- )
+ );
+}
+
+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) {