diff --git a/activity.go b/activity.go index 2c2106a..abf9bd3 100644 --- a/activity.go +++ b/activity.go @@ -12,19 +12,52 @@ 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 { + 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"` + 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/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 254cb6f..de1ef69 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -150,7 +150,21 @@ 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 { + runId?: ID; + mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | '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 }