From 00f182611c92bb67cdfb7d1b992814225c890594 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:46:23 +0000 Subject: [PATCH 1/3] Define durable nonblocking work card snapshots Why: Keep background results and optional decisions outside the foreground conversation lock. Evidence: Core package tests and combination checks are recorded in the Dev task. Impact: Add an optional backward-compatible work card payload without a database migration. Refs: feature/workbench-flow-completion --- activity.go | 58 +++++++++++++++++++++++++++++++++++++++++------------ ts/types.ts | 15 +++++++++++++- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/activity.go b/activity.go index 2c2106a..26229ff 100644 --- a/activity.go +++ b/activity.go @@ -12,19 +12,51 @@ import "time" type ActivityCard struct { ID ID `json:"id"` // ProjectID/ConversationID 定位归属;ConversationID 为空表示项目级卡片(远期跨会话收件箱)。 - ProjectID ID `json:"projectId"` - ConversationID ID `json:"conversationId,omitempty"` - SourceRunID ID `json:"sourceRunId,omitempty"` // 溯源:产生卡片的 run - AnchorMessageID ID `json:"anchorMessageId,omitempty"` // 互定位:出生消息(↙ 定位到对话) - Kind CardKind `json:"kind"` - Title string `json:"title"` - Status CardStatus `json:"status"` - Tone string `json:"tone,omitempty"` // ok | warn | danger | info - Body []UINode `json:"body,omitempty"` - Actions []UIAction `json:"actions,omitempty"` // 走命令总线;未绑定 command 降级为发消息 - Pinned bool `json:"pinned,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ProjectID ID `json:"projectId"` + ConversationID ID `json:"conversationId,omitempty"` + SourceRunID ID `json:"sourceRunId,omitempty"` // 溯源:产生卡片的 run + AnchorMessageID ID `json:"anchorMessageId,omitempty"` // 互定位:出生消息(↙ 定位到对话) + Kind CardKind `json:"kind"` + Title string `json:"title"` + Status CardStatus `json:"status"` + Tone string `json:"tone,omitempty"` // ok | warn | danger | info + Body []UINode `json:"body,omitempty"` + Actions []UIAction `json:"actions,omitempty"` // 走命令总线;未绑定 command 降级为发消息 + Pinned bool `json:"pinned,omitempty"` + Work *WorkCard `json:"work,omitempty"` // optional background-work snapshot; never a foreground approval + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// WorkCard keeps the complete saved result and an optional, non-blocking next step +// on the original task. Reading and answering do not change the result version. +type WorkCard struct { + Result string `json:"result,omitempty"` + ResultVersion int `json:"resultVersion"` + ReadVersion int `json:"readVersion"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + Decision *WorkDecision `json:"decision,omitempty"` +} + +type WorkDecision struct { + Mode string `json:"mode"` // single | multiple + State string `json:"state"` // open | deferred | submitted + Options []WorkOption `json:"options,omitempty"` + SelectedIDs []string `json:"selectedIds,omitempty"` + FreeText string `json:"freeText,omitempty"` + RespondedAt *time.Time `json:"respondedAt,omitempty"` +} + +type WorkOption struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// WorkCardAction is version-fenced. Presentation-only opens never mark a result read. +type WorkCardAction struct { + ResultVersion int `json:"resultVersion"` + SelectedIDs []string `json:"selectedIds,omitempty"` + FreeText string `json:"freeText,omitempty"` } type CardKind string diff --git a/ts/types.ts b/ts/types.ts index 254cb6f..5bdb95f 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -150,7 +150,20 @@ export interface ActivityCard { sourceRunId?: ID; anchorMessageId?: ID; kind: CardKind; title: string; status: CardStatus; tone?: string; body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[]; - pinned?: boolean; createdAt: string; updatedAt: string; + pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard; +} + +export interface WorkCard { + result?: string; resultVersion: number; readVersion: number; completedAt?: string; + decision?: WorkDecision; +} +export interface WorkDecision { + mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'submitted'; + options?: { id: string; label: string }[]; + selectedIds?: string[]; freeText?: string; respondedAt?: string; +} +export interface WorkCardAction { + resultVersion: number; selectedIds?: string[]; freeText?: string; } export interface CardPayload { card: ActivityCard } -- 2.54.0 From fcfe8149bd71720409efdb9084ff40786a8e5abe Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:49:33 +0000 Subject: [PATCH 2/3] Verify durable work results and legacy card compatibility Why: Retain full results and exact answers across persistence and reload. Evidence: go test ./... passed locally, including full-tail and legacy JSON coverage. Impact: Add contract roundtrip tests and a guarded dispatching state. Refs: feature/workbench-flow-completion --- activity.go | 2 +- activity_work_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++ ts/types.ts | 2 +- 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 activity_work_test.go diff --git a/activity.go b/activity.go index 26229ff..56fc862 100644 --- a/activity.go +++ b/activity.go @@ -40,7 +40,7 @@ type WorkCard struct { type WorkDecision struct { Mode string `json:"mode"` // single | multiple - State string `json:"state"` // open | deferred | submitted + State string `json:"state"` // open | deferred | dispatching | submitted Options []WorkOption `json:"options,omitempty"` SelectedIDs []string `json:"selectedIds,omitempty"` FreeText string `json:"freeText,omitempty"` diff --git a/activity_work_test.go b/activity_work_test.go new file mode 100644 index 0000000..ad4fced --- /dev/null +++ b/activity_work_test.go @@ -0,0 +1,46 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestWorkCardSurvivesPersistedSnapshot(t *testing.T) { + completed := time.Date(2026, 10, 15, 6, 0, 0, 0, time.UTC) + card := ActivityCard{ID: "card_work", Status: CardDone, Work: &WorkCard{ + Result: strings.Repeat("完整结果\n", 200) + "最后一项依据", + ResultVersion: 2, ReadVersion: 1, CompletedAt: &completed, + Decision: &WorkDecision{Mode: "multiple", State: "submitted", Options: []WorkOption{{ID: "summary", Label: "生成摘要"}}, + SelectedIDs: []string{"summary"}, FreeText: "请突出关键限制", RespondedAt: &completed}, + }} + data, err := json.Marshal(card) + if err != nil { + t.Fatal(err) + } + var restored ActivityCard + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + if restored.Work == nil || restored.Work.Result != card.Work.Result || restored.Work.ReadVersion != 1 || restored.Work.ResultVersion != 2 { + t.Fatalf("complete result or unread version lost: %+v", restored.Work) + } + if d := restored.Work.Decision; d.State != "submitted" || d.SelectedIDs[0] != "summary" || d.FreeText != "请突出关键限制" || !d.RespondedAt.Equal(completed) { + t.Fatalf("decision history lost: %+v", d) + } +} + +func TestLegacyCardOmitsOptionalWork(t *testing.T) { + var legacy ActivityCard + if err := json.Unmarshal([]byte(`{"id":"old","status":"resolved","body":[{"type":"markdown","markdown":"保留历史"}]}`), &legacy); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if legacy.Work != nil || strings.Contains(string(data), `"work"`) || legacy.Body[0].Markdown != "保留历史" { + t.Fatalf("legacy payload changed: %s", data) + } +} diff --git a/ts/types.ts b/ts/types.ts index 5bdb95f..2001311 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -158,7 +158,7 @@ export interface WorkCard { decision?: WorkDecision; } export interface WorkDecision { - mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'submitted'; + mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted'; options?: { id: string; label: string }[]; selectedIds?: string[]; freeText?: string; respondedAt?: string; } -- 2.54.0 From 1a5ef6b75cc74088786487d82b6f6b70bf977280 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:53:28 +0000 Subject: [PATCH 3/3] Anchor work decisions to a reserved follow-up run identity Why: Reconcile interrupted dispatch without spending another model request. Evidence: Core package tests pass locally; integration exercises acceptance and recovery. Impact: Add one optional persisted run identifier. Refs: feature/workbench-flow-completion --- activity.go | 5 +++-- ts/types.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/activity.go b/activity.go index 56fc862..abf9bd3 100644 --- a/activity.go +++ b/activity.go @@ -39,8 +39,9 @@ type WorkCard struct { } type WorkDecision struct { - Mode string `json:"mode"` // single | multiple - State string `json:"state"` // open | deferred | dispatching | submitted + RunID ID `json:"runId,omitempty"` // reserved child identity; reconciles acceptance after restart without rerunning + Mode string `json:"mode"` // single | multiple + State string `json:"state"` // open | deferred | dispatching | submitted Options []WorkOption `json:"options,omitempty"` SelectedIDs []string `json:"selectedIds,omitempty"` FreeText string `json:"freeText,omitempty"` diff --git a/ts/types.ts b/ts/types.ts index 2001311..de1ef69 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -158,6 +158,7 @@ export interface WorkCard { decision?: WorkDecision; } export interface WorkDecision { + runId?: ID; mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted'; options?: { id: string; label: string }[]; selectedIds?: string[]; freeText?: string; respondedAt?: string; -- 2.54.0