From 06f272316106cf3c9b737fee0ceec52d98268df3 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 01:04:56 +0000 Subject: [PATCH 1/7] Add persistent work snapshots and checkpoint contracts Why: preserve scoped work findings and truthful activity across reloads. Evidence: task JSON loss and validation/state regressions RED to GREEN; complete Core suite. Impact: optional card/document wire fields and bounded candidate validation; old Body remains supported. Refs: fast-slow U2/U3 Task1, R03 R05 R09 R17 --- activity.go | 27 ++--- data.go | 93 +++++++-------- task_snapshot.go | 258 ++++++++++++++++++++++++++++++++++++++++++ task_snapshot_test.go | 99 ++++++++++++++++ ts/types.ts | 24 +++- 5 files changed, 441 insertions(+), 60 deletions(-) create mode 100644 task_snapshot.go create mode 100644 task_snapshot_test.go diff --git a/activity.go b/activity.go index 2c2106a..dec01ff 100644 --- a/activity.go +++ b/activity.go @@ -12,19 +12,20 @@ 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 + Task *TaskSnapshot `json:"task,omitempty"` + 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"` } type CardKind string diff --git a/data.go b/data.go index 58d701c..5d1d4f8 100644 --- a/data.go +++ b/data.go @@ -13,18 +13,19 @@ type ParsedDocument struct { } type DocMeta struct { - Filename string `json:"filename"` - MIME string `json:"mime,omitempty"` - Parser string `json:"parser"` // builtin:md / builtin:docx / mineru / agent - Confidence float64 `json:"confidence,omitempty"` // 0–1;交叉验证差异块拉低 - CrossCheck *CrossCheck `json:"crossCheck,omitempty"` - ParsedAt time.Time `json:"parsedAt"` + Origin *DocumentOrigin `json:"origin,omitempty"` + Filename string `json:"filename"` + MIME string `json:"mime,omitempty"` + Parser string `json:"parser"` // builtin:md / builtin:docx / mineru / agent + Confidence float64 `json:"confidence,omitempty"` // 0–1;交叉验证差异块拉低 + CrossCheck *CrossCheck `json:"crossCheck,omitempty"` + ParsedAt time.Time `json:"parsedAt"` } type CrossCheck struct { - With string `json:"with"` // 交叉验证的另一解析器 - DiffBlocks int `json:"diffBlocks"` - Verdict string `json:"verdict"` // agree | differ + With string `json:"with"` // 交叉验证的另一解析器 + DiffBlocks int `json:"diffBlocks"` + Verdict string `json:"verdict"` // agree | differ } type DocBlockType string @@ -39,13 +40,13 @@ const ( ) type DocBlock struct { - Type DocBlockType `json:"type"` - Level int `json:"level,omitempty"` // heading - Text string `json:"text,omitempty"` // para/heading/list 项拼接/code - Items []string `json:"items,omitempty"` // list - Rows [][]string `json:"rows,omitempty"` // table(首行为表头) - Ref string `json:"ref,omitempty"` // image-ref 的对象键 - Confidence float64 `json:"confidence,omitempty"` + Type DocBlockType `json:"type"` + Level int `json:"level,omitempty"` // heading + Text string `json:"text,omitempty"` // para/heading/list 项拼接/code + Items []string `json:"items,omitempty"` // list + Rows [][]string `json:"rows,omitempty"` // table(首行为表头) + Ref string `json:"ref,omitempty"` // image-ref 的对象键 + Confidence float64 `json:"confidence,omitempty"` } // PlainText 把文档压成纯文本(附件注入与全文索引用);表格按行拼接。 @@ -88,60 +89,60 @@ func (d ParsedDocument) PlainText() string { // ParserDesc 注册表条目(来源标记:builtin/plugin/...;一切可禁用)。 type ParserDesc struct { - ID string `json:"id"` - Formats []string `json:"formats"` // 支持的扩展名 + ID string `json:"id"` + Formats []string `json:"formats"` // 支持的扩展名 Source SourceKind `json:"source"` - Note string `json:"note,omitempty"` + Note string `json:"note,omitempty"` } // ---- 检索(4.12)---- // KnowledgeDoc 是检索语料的一条文档(来自 ingest 或插件知识库,带来源标记)。 type KnowledgeDoc struct { - ID ID `json:"id"` - Owner ID `json:"owner,omitempty"` // 空 = 共享(插件级)知识 - ProjectID ID `json:"projectId,omitempty"` // 空 = 插件级知识库 - Source string `json:"source"` // upload | artifact | plugin: - Title string `json:"title"` - Ref string `json:"ref,omitempty"` // 工件/文件引用 - Chunk string `json:"chunk"` + ID ID `json:"id"` + Owner ID `json:"owner,omitempty"` // 空 = 共享(插件级)知识 + ProjectID ID `json:"projectId,omitempty"` // 空 = 插件级知识库 + Source string `json:"source"` // upload | artifact | plugin: + Title string `json:"title"` + Ref string `json:"ref,omitempty"` // 工件/文件引用 + Chunk string `json:"chunk"` CreatedAt time.Time `json:"createdAt"` } type RetrievalHit struct { - Doc KnowledgeDoc `json:"doc"` - Score float64 `json:"score"` - Kind string `json:"kind"` // fulltext | vector | plugin + Doc KnowledgeDoc `json:"doc"` + Score float64 `json:"score"` + Kind string `json:"kind"` // fulltext | vector | plugin } type RetrievalQuery struct { - ProjectID ID `json:"projectId,omitempty"` - Text string `json:"text"` - TopK int `json:"topK,omitempty"` + ProjectID ID `json:"projectId,omitempty"` + Text string `json:"text"` + TopK int `json:"topK,omitempty"` Sources []string `json:"sources,omitempty"` // 过滤来源 } // ---- 记忆(4.13 契约;实现远期)---- type MemoryEntry struct { - ID string `json:"id"` - Scope string `json:"scope"` // user | project - ProjectID ID `json:"projectId,omitempty"` - Type string `json:"type"` // fact | preference | decision | episode | procedure - Content string `json:"content"` - Source string `json:"source"` // run: | manual - Confidence float64 `json:"confidence,omitempty"` - CreatedAt time.Time `json:"createdAt"` - LastAccessed time.Time `json:"lastAccessed"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` + ID string `json:"id"` + Scope string `json:"scope"` // user | project + ProjectID ID `json:"projectId,omitempty"` + Type string `json:"type"` // fact | preference | decision | episode | procedure + Content string `json:"content"` + Source string `json:"source"` // run: | manual + Confidence float64 `json:"confidence,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastAccessed time.Time `json:"lastAccessed"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` } // ---- 上传文件 ---- type UploadInfo struct { - ArtifactID ID `json:"artifactId"` - Filename string `json:"filename"` - MIME string `json:"mime,omitempty"` - Size int64 `json:"size"` + ArtifactID ID `json:"artifactId"` + Filename string `json:"filename"` + MIME string `json:"mime,omitempty"` + Size int64 `json:"size"` Parsed *ParsedDocument `json:"parsed,omitempty"` } diff --git a/task_snapshot.go b/task_snapshot.go new file mode 100644 index 0000000..9db62df --- /dev/null +++ b/task_snapshot.go @@ -0,0 +1,258 @@ +package core + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type DocumentOrigin struct { + ArtifactID ID `json:"artifactId"` + ContentHash string `json:"contentHash"` + ParserRevision string `json:"parserRevision"` +} +type TaskPhase struct { + ID string `json:"id"` + Label string `json:"label"` + ObservedAt time.Time `json:"observedAt,omitempty"` +} +type TaskSourceRef struct { + ID string `json:"id"` + ArtifactID string `json:"artifactId"` + ContentHash string `json:"contentHash"` + ParserRevision string `json:"parserRevision"` + BlockIndex int `json:"blockIndex"` + Label string `json:"label"` + Quote string `json:"quote,omitempty"` + Availability string `json:"availability,omitempty"` +} +type TaskResultRef struct { + ArtifactID ID `json:"artifactId,omitempty"` + SurfaceID ID `json:"surfaceId,omitempty"` + Version string `json:"version,omitempty"` + ContentHash string `json:"contentHash,omitempty"` + Availability string `json:"availability"` + Name string `json:"name,omitempty"` +} +type TaskFinding struct { + ID string `json:"id"` + Text string `json:"text"` + Impact string `json:"impact,omitempty"` + Kind string `json:"kind"` + Status string `json:"status"` + Revision int64 `json:"revision"` + Sources []TaskSourceRef `json:"sources"` + Assumptions string `json:"assumptions,omitempty"` + Corrects string `json:"corrects,omitempty"` +} +type TaskSnapshot struct { + SchemaVersion int `json:"schemaVersion"` + TaskID ID `json:"taskId"` + AttemptID ID `json:"attemptId"` + CardID ID `json:"cardId"` + ProjectID ID `json:"projectId"` + ConversationID ID `json:"conversationId"` + SourceMessageID ID `json:"sourceMessageId,omitempty"` + ParentRunID ID `json:"parentRunId,omitempty"` + RunID ID `json:"runId,omitempty"` + OwnerInstanceID string `json:"ownerInstanceId"` + ExecutionEpoch int64 `json:"executionEpoch,omitempty"` + Revision int64 `json:"revision"` + SourceSeq int64 `json:"sourceSeq"` + StreamScope string `json:"streamScope"` + InputRevision string `json:"inputRevision,omitempty"` + Title string `json:"title"` + Goal string `json:"goal,omitempty"` + ExecutionState string `json:"executionState"` + OutcomeCompleteness string `json:"outcomeCompleteness"` + DeliveryState string `json:"deliveryState"` + Phase TaskPhase `json:"phase"` + Focus string `json:"focus,omitempty"` + Coverage string `json:"coverage,omitempty"` + Remaining string `json:"remaining,omitempty"` + NextStep string `json:"nextStep,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + Findings []TaskFinding `json:"findings,omitempty"` + Actions []UIAction `json:"actions,omitempty"` + ResultRefs []TaskResultRef `json:"resultRefs,omitempty"` + LastActivityAt time.Time `json:"lastActivityAt,omitempty"` + LastMeaningfulUpdateAt time.Time `json:"lastMeaningfulUpdateAt,omitempty"` + CheckpointKeys map[string]string `json:"checkpointKeys,omitempty"` + CheckpointCount int `json:"checkpointCount,omitempty"` + CheckpointSeq int64 `json:"checkpointSeq,omitempty"` +} +type TaskSourceQuote struct { + SourceID string `json:"sourceId"` + BlockIndex int `json:"blockIndex"` + Quote string `json:"quote"` +} +type WorkCheckpointCandidate struct { + CheckpointID string `json:"checkpointId"` + PhaseID string `json:"phaseId"` + Focus string `json:"focus,omitempty"` + Remaining string `json:"remaining,omitempty"` + NextStep string `json:"nextStep,omitempty"` + Findings []WorkFindingCandidate `json:"findings"` +} +type WorkFindingCandidate struct { + FindingKey string `json:"findingKey"` + Text string `json:"text"` + Impact string `json:"impact,omitempty"` + Kind string `json:"kind"` + Assumptions string `json:"assumptions,omitempty"` + Corrects string `json:"corrects,omitempty"` + Sources []TaskSourceQuote `json:"sources"` +} + +// Validate enforces wire limits; owner/source authorization belongs to Backend. +func (s TaskSnapshot) Validate() error { + if s.SchemaVersion != 1 { + return fmt.Errorf("task.schema: unsupported") + } + if s.TaskID == "" || s.AttemptID == "" || s.CardID == "" || s.ProjectID == "" || s.ConversationID == "" || s.OwnerInstanceID == "" { + return fmt.Errorf("task.scope: missing identity") + } + if s.StreamScope != "conversation:"+s.ConversationID { + return fmt.Errorf("task.scope: inconsistent stream") + } + if s.Revision < 1 || s.Revision > 9007199254740991 || s.SourceSeq < 0 || s.SourceSeq > 9007199254740991 || s.ExecutionEpoch < 0 { + return fmt.Errorf("task.revision: unsafe cursor") + } + switch s.ExecutionState { + case "queued", "running", "awaiting_user", "completed", "failed", "interrupted": + default: + return fmt.Errorf("task.state: unknown") + } + if (s.ExecutionState == "running" || s.ExecutionState == "awaiting_user") && s.RunID == "" { + return fmt.Errorf("task.run: active task requires run") + } + switch s.OutcomeCompleteness { + case "none", "partial", "complete": + default: + return fmt.Errorf("task.outcome: unknown") + } + switch s.DeliveryState { + case "pending", "available", "reported": + default: + return fmt.Errorf("task.delivery: unknown") + } + active := 0 + for _, f := range s.Findings { + if f.ID == "" || f.Revision < 1 { + return fmt.Errorf("task.finding: missing identity") + } + switch f.Kind { + case "material_record", "analysis", "provisional", "needs_user", "verified": + default: + return fmt.Errorf("task.finding.kind: unknown") + } + switch f.Status { + case "active": + active++ + case "corrected", "withdrawn", "superseded": + default: + return fmt.Errorf("task.finding.status: unknown") + } + } + if active > 12 { + return fmt.Errorf("task.findings: too many active") + } + b, err := json.Marshal(s) + if err != nil { + return err + } + if len(b) > 64*1024 { + return fmt.Errorf("task.size: exceeds 64KiB") + } + return nil +} +func (s TaskSnapshot) Terminal() bool { + return s.ExecutionState == "completed" || s.ExecutionState == "failed" || s.ExecutionState == "interrupted" +} +func ValidateWorkCheckpoint(c WorkCheckpointCandidate) error { + if strings.TrimSpace(c.CheckpointID) == "" || len(c.CheckpointID) > 128 { + return fmt.Errorf("checkpoint.id: required or too long") + } + switch c.PhaseID { + case "reading", "crosscheck", "summarizing", "draft-ready": + default: + return fmt.Errorf("checkpoint.phase: unknown") + } + if len(c.Findings) == 0 || len(c.Findings) > 6 { + return fmt.Errorf("checkpoint.findings: require 1..6") + } + keys := map[string]bool{} + for _, f := range c.Findings { + switch f.Kind { + case "material_record", "analysis", "provisional", "needs_user": + default: + return fmt.Errorf("checkpoint.kind: model cannot grant verified status") + } + if f.FindingKey == "" || keys[f.FindingKey] || strings.TrimSpace(f.Text) == "" { + return fmt.Errorf("checkpoint.finding: empty or duplicate key") + } + keys[f.FindingKey] = true + if len(f.Sources) == 0 { + return fmt.Errorf("checkpoint.sources: evidence required") + } + for _, src := range f.Sources { + if src.SourceID == "" || src.BlockIndex < 0 || strings.TrimSpace(src.Quote) == "" { + return fmt.Errorf("checkpoint.source: invalid") + } + } + if (f.Kind == "analysis" || f.Kind == "provisional") && strings.TrimSpace(f.Assumptions) == "" { + return fmt.Errorf("checkpoint.assumptions: required") + } + } + b, err := json.Marshal(c) + if err != nil { + return err + } + if len(b) > 16*1024 { + return fmt.Errorf("checkpoint.size: exceeds 16KiB") + } + return nil +} +func (s TaskSnapshot) SummaryBody() []UINode { + labels := map[string]string{"queued": "排队中", "running": "进行中", "awaiting_user": "需要你处理", "completed": "已完成", "failed": "执行失败", "interrupted": "已中断"} + var lines []string + for _, f := range s.Findings { + if f.Status == "active" { + line := f.Text + if f.Impact != "" { + line += ";" + f.Impact + } + lines = append(lines, line) + } + } + note := strings.Join(lines, "\n") + if note == "" { + note = TaskSummaryNoFindingsYet + if s.ExecutionState == "queued" { + note = "尚未开始处理,等待执行资源" + } + if s.Terminal() { + note = s.Conclusion + if note == "" { + note = "没有可用阶段成果" + } + } + } + v := TaskSummaryView{Focus: s.Focus, FindingsNote: note, Remaining: s.Remaining, NextStep: s.NextStep, OutcomeLine: s.Conclusion} + var body []UINode + if s.Terminal() { + body = v.FinishedBody(labels[s.ExecutionState], nil) + } else { + body = v.RunningBody(labels[s.ExecutionState], nil) + } + if s.Coverage != "" { + body = append(body, UINode{Type: UIKV, KV: [][2]string{{"提供范围", s.Coverage}}}) + } + for _, r := range s.ResultRefs { + if r.ArtifactID != "" && r.Availability == "available" { + body = append(body, UINode{Type: UIDocRef, DocID: r.ArtifactID, Title: r.Name}) + } + } + return body +} diff --git a/task_snapshot_test.go b/task_snapshot_test.go new file mode 100644 index 0000000..309d4ba --- /dev/null +++ b/task_snapshot_test.go @@ -0,0 +1,99 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" +) + +// Loss of the optional task object makes refresh discard stage findings. +func TestTaskSnapshotCardRoundTrip(t *testing.T) { + raw := []byte(`{"id":"card1","projectId":"p1","conversationId":"c1","kind":"progress","status":"active","task":{"schemaVersion":1,"taskId":"t1","attemptId":"a1","cardId":"card1","revision":4,"executionState":"running","focus":"核对材料","findings":[{"id":"f1","text":"日期不同","kind":"material_record","status":"active","revision":1}]}}`) + var card ActivityCard + if err := json.Unmarshal(raw, &card); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(card) + if err != nil { + t.Fatal(err) + } + var restored map[string]json.RawMessage + if err := json.Unmarshal(encoded, &restored); err != nil { + t.Fatal(err) + } + if _, ok := restored["task"]; !ok { + t.Fatal("task findings were lost in card persistence") + } + var task map[string]json.RawMessage + if err := json.Unmarshal(restored["task"], &task); err != nil { + t.Fatal(err) + } + if string(task["revision"]) != "4" || string(task["focus"]) != `"核对材料"` { + t.Fatal("revision/focus did not survive") + } +} + +func validTaskSnapshot() TaskSnapshot { + return TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: "card1", ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", StreamScope: "conversation:c1", Title: "整理材料"} +} +func TestTaskSnapshotRejectsUnsafeAndUnboundState(t *testing.T) { + for _, mutate := range []func(*TaskSnapshot){ + func(s *TaskSnapshot) { s.TaskID = "" }, func(s *TaskSnapshot) { s.AttemptID = "" }, + func(s *TaskSnapshot) { s.Revision = 9007199254740992 }, func(s *TaskSnapshot) { s.SourceSeq = -1 }, + func(s *TaskSnapshot) { s.ExecutionState = "imagined" }, func(s *TaskSnapshot) { s.StreamScope = "conversation:other" }, + func(s *TaskSnapshot) { s.ExecutionState = "running" }, + } { + s := validTaskSnapshot() + mutate(&s) + if s.Validate() == nil { + t.Errorf("invalid snapshot accepted: %+v", s) + } + } + if err := validTaskSnapshot().Validate(); err != nil { + t.Fatal(err) + } +} +func TestWorkCheckpointRejectsVerifiedAndEmptyEvidence(t *testing.T) { + c := WorkCheckpointCandidate{CheckpointID: "cp1", PhaseID: "crosscheck", Findings: []WorkFindingCandidate{{FindingKey: "date", Text: "日期不同", Kind: "verified", Sources: []TaskSourceQuote{{SourceID: "src1", Quote: "六月"}}}}} + if err := ValidateWorkCheckpoint(c); err == nil || !strings.Contains(err.Error(), "kind") { + t.Fatal("model granted verified status", err) + } + c.Findings[0].Kind = "material_record" + c.Findings[0].Sources = nil + if ValidateWorkCheckpoint(c) == nil { + t.Fatal("source-free fact accepted") + } + c.Findings[0].Sources = []TaskSourceQuote{{SourceID: "src1", Quote: "六月"}} + if err := ValidateWorkCheckpoint(c); err != nil { + t.Fatal(err) + } + c.Findings[0].Text = strings.Repeat("x", 17000) + if ValidateWorkCheckpoint(c) == nil { + t.Fatal("unbounded candidate accepted") + } +} +func TestTaskSnapshotBodyPreservesLimitsAndHonestStates(t *testing.T) { + s := validTaskSnapshot() + s.Remaining = "图片内容未纳入" + s.ExecutionState = "failed" + s.Conclusion = "处理失败,已有发现可查看" + s.Findings = []TaskFinding{{ID: "f1", Text: "金额缺失", Kind: "material_record", Status: "active", Revision: 1}} + raw, _ := json.Marshal(s.SummaryBody()) + for _, want := range []string{"执行失败", "图片内容未纳入", "金额缺失", "处理失败"} { + if !strings.Contains(string(raw), want) { + t.Errorf("lost critical summary %s", want) + } + } +} + +func TestTaskSnapshotUnstartedAndFinishedDoNotClaimReading(t *testing.T) { + for _, state := range []string{"queued", "completed", "interrupted"} { + s := validTaskSnapshot() + s.ExecutionState = state + s.Conclusion = "处理已结束" + b, _ := json.Marshal(s.SummaryBody()) + if strings.Contains(string(b), "正在读取") { + t.Errorf("%s falsely claims active reading", state) + } + } +} diff --git a/ts/types.ts b/ts/types.ts index dd4ecd6..1dece06 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -149,6 +149,7 @@ export interface ActivityCard { id: ID; projectId: ID; conversationId?: ID; sourceRunId?: ID; anchorMessageId?: ID; kind: CardKind; title: string; status: CardStatus; tone?: string; + task?: TaskSnapshot; body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[]; pinned?: boolean; createdAt: string; updatedAt: string; } @@ -334,7 +335,7 @@ export interface PluginPageDecl { export interface ParsedDocument { meta: { - filename: string; mime?: string; parser: string; confidence?: number; + filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin; crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' }; parsedAt: string; }; @@ -397,3 +398,24 @@ export interface SurfaceMeta { conversationId?: ID; updatedAt: string; } + +// Persistent work projection; host-owned identities and revisions. +export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string } +export interface TaskPhase { id: string; label: string; observedAt?: string } +export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string } +export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string } +export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string } +export interface TaskSnapshot { + schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID; + sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number; + revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string; + executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase; + focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string; + findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string; + checkpointKeys?: Record; checkpointCount?: number; checkpointSeq?: number; +} +export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string } +export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[] } +export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] } + +export interface UIAction { id: string; label: string; style?: string; command?: string } From 124309b98506a9524839e7257a96a3390e4a9cc2 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 01:35:53 +0000 Subject: [PATCH 2/7] Retain trusted input bindings for task result reuse Why: Completed tasks require current source authorization and content validation. Evidence: Optional fields preserve existing contracts; composition verification follows. Impact: Adds host-owned input source references with no new model authority. Refs: fast-slow-u2-u3 Tasks 2 and 4 --- task_snapshot.go | 1 + ts/types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/task_snapshot.go b/task_snapshot.go index 9db62df..7de4fdd 100644 --- a/task_snapshot.go +++ b/task_snapshot.go @@ -74,6 +74,7 @@ type TaskSnapshot struct { NextStep string `json:"nextStep,omitempty"` Conclusion string `json:"conclusion,omitempty"` Findings []TaskFinding `json:"findings,omitempty"` + Inputs []TaskSourceRef `json:"inputs,omitempty"` Actions []UIAction `json:"actions,omitempty"` ResultRefs []TaskResultRef `json:"resultRefs,omitempty"` LastActivityAt time.Time `json:"lastActivityAt,omitempty"` diff --git a/ts/types.ts b/ts/types.ts index 1dece06..691f160 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -406,6 +406,7 @@ export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string } export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string } export interface TaskSnapshot { + inputs?: TaskSourceRef[] schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID; sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number; revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string; From dbe1f544d70895fa0fd809114f9a249cc062aef1 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 01:59:40 +0000 Subject: [PATCH 3/7] Keep material coverage limits separate from outcome claims Why: A completed run can still lack readable or fully provided source material. Evidence: Composition validation follows the backend and UI consumers. Impact: Adds optional host-owned limitations without changing candidate authority. Refs: fast-slow-u2-u3 Tasks 4 through 6 --- task_snapshot.go | 1 + ts/types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/task_snapshot.go b/task_snapshot.go index 7de4fdd..d02722b 100644 --- a/task_snapshot.go +++ b/task_snapshot.go @@ -74,6 +74,7 @@ type TaskSnapshot struct { NextStep string `json:"nextStep,omitempty"` Conclusion string `json:"conclusion,omitempty"` Findings []TaskFinding `json:"findings,omitempty"` + Limitations []string `json:"limitations,omitempty"` Inputs []TaskSourceRef `json:"inputs,omitempty"` Actions []UIAction `json:"actions,omitempty"` ResultRefs []TaskResultRef `json:"resultRefs,omitempty"` diff --git a/ts/types.ts b/ts/types.ts index 691f160..a613d07 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -407,6 +407,7 @@ export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: stri export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string } export interface TaskSnapshot { inputs?: TaskSourceRef[] + limitations?: string[] schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID; sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number; revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string; From 6e385743e4435ecfdc0b14697fb82392b05b778c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 02:08:00 +0000 Subject: [PATCH 4/7] Retain coverage limits in legacy task summaries Why: Older clients must see the same coverage limits as structured task cards. Evidence: Core suite and composition checks follow this projection update. Impact: Projects optional limitations into existing list nodes. Refs: fast-slow-u2-u3 Task 6 --- task_snapshot.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/task_snapshot.go b/task_snapshot.go index d02722b..58e41fd 100644 --- a/task_snapshot.go +++ b/task_snapshot.go @@ -251,6 +251,9 @@ func (s TaskSnapshot) SummaryBody() []UINode { if s.Coverage != "" { body = append(body, UINode{Type: UIKV, KV: [][2]string{{"提供范围", s.Coverage}}}) } + if len(s.Limitations) > 0 { + body = append(body, UINode{Type: UIList, Title: "范围与限制", Items: s.Limitations}) + } for _, r := range s.ResultRefs { if r.ArtifactID != "" && r.Availability == "available" { body = append(body, UINode{Type: UIDocRef, DocID: r.ArtifactID, Title: r.Name}) From c722c16626564a44b060cfff8ced3b27b8ea3c09 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 16:03:33 +0000 Subject: [PATCH 5/7] Validate optional evidence in work checkpoint candidates Why: U3 needs bounded, source-indexed premises before the host can judge numeric and membership claims. Evidence: TestWorkCheckpointEvidenceModesAndBounds failed on missing types, then go test ./... -count=1 and git diff --check passed. Impact: Extend only the candidate wire contract; persisted task snapshots and display kinds stay unchanged. Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 evidence implementation plan Task 1 --- task_snapshot.go | 81 +++++++++++++++++++++++++++++++++++++++---- task_snapshot_test.go | 48 +++++++++++++++++++++++++ ts/types.ts | 4 ++- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/task_snapshot.go b/task_snapshot.go index 58e41fd..3144618 100644 --- a/task_snapshot.go +++ b/task_snapshot.go @@ -98,13 +98,77 @@ type WorkCheckpointCandidate struct { Findings []WorkFindingCandidate `json:"findings"` } type WorkFindingCandidate struct { - FindingKey string `json:"findingKey"` - Text string `json:"text"` - Impact string `json:"impact,omitempty"` - Kind string `json:"kind"` - Assumptions string `json:"assumptions,omitempty"` - Corrects string `json:"corrects,omitempty"` - Sources []TaskSourceQuote `json:"sources"` + FindingKey string `json:"findingKey"` + Text string `json:"text"` + Impact string `json:"impact,omitempty"` + Kind string `json:"kind"` + Assumptions string `json:"assumptions,omitempty"` + Corrects string `json:"corrects,omitempty"` + Sources []TaskSourceQuote `json:"sources"` + Evidence *WorkFindingEvidence `json:"evidence,omitempty"` +} + +// WorkFindingEvidence is a model-supplied candidate, not a persisted proof. +// Backend checks the quoted source text and computes any resulting statement. +type WorkEvidencePremise struct { + Role string `json:"role"` + SourceIndex int `json:"sourceIndex"` + Value *int `json:"value,omitempty"` +} + +type WorkFindingEvidence struct { + Mode string `json:"mode"` + Premises []WorkEvidencePremise `json:"premises"` + Result *int `json:"result,omitempty"` +} + +func validateWorkFindingEvidence(e *WorkFindingEvidence, sourceCount int) error { + if e == nil { + return nil + } + var required []string + switch e.Mode { + case "direct_relation": + required = []string{"direct_relation"} + if e.Result != nil { + return fmt.Errorf("checkpoint.evidence: direct relation cannot have result") + } + case "difference": + required = []string{"total", "phase"} + if e.Result == nil || *e.Result < 0 || *e.Result > 1000000 { + return fmt.Errorf("checkpoint.evidence: difference requires bounded result") + } + case "unresolved_relation": + required = []string{"total", "phase", "dependent"} + if e.Result != nil { + return fmt.Errorf("checkpoint.evidence: unresolved relation cannot have result") + } + default: + return fmt.Errorf("checkpoint.evidence: unknown mode") + } + if len(e.Premises) != len(required) { + return fmt.Errorf("checkpoint.evidence: wrong premise count") + } + roles := make(map[string]bool, len(required)) + for _, premise := range e.Premises { + if roles[premise.Role] || premise.SourceIndex < 0 || premise.SourceIndex >= sourceCount { + return fmt.Errorf("checkpoint.evidence: duplicate role or invalid source index") + } + roles[premise.Role] = true + if e.Mode == "direct_relation" { + if premise.Value != nil { + return fmt.Errorf("checkpoint.evidence: direct relation cannot have value") + } + } else if premise.Value == nil || *premise.Value < 0 || *premise.Value > 1000000 { + return fmt.Errorf("checkpoint.evidence: count premise requires bounded value") + } + } + for _, role := range required { + if !roles[role] { + return fmt.Errorf("checkpoint.evidence: missing premise role") + } + } + return nil } // Validate enforces wire limits; owner/source authorization belongs to Backend. @@ -203,6 +267,9 @@ func ValidateWorkCheckpoint(c WorkCheckpointCandidate) error { return fmt.Errorf("checkpoint.source: invalid") } } + if err := validateWorkFindingEvidence(f.Evidence, len(f.Sources)); err != nil { + return err + } if (f.Kind == "analysis" || f.Kind == "provisional") && strings.TrimSpace(f.Assumptions) == "" { return fmt.Errorf("checkpoint.assumptions: required") } diff --git a/task_snapshot_test.go b/task_snapshot_test.go index 309d4ba..a0de869 100644 --- a/task_snapshot_test.go +++ b/task_snapshot_test.go @@ -72,6 +72,54 @@ func TestWorkCheckpointRejectsVerifiedAndEmptyEvidence(t *testing.T) { t.Fatal("unbounded candidate accepted") } } + +func TestWorkCheckpointEvidenceModesAndBounds(t *testing.T) { + base := func() WorkCheckpointCandidate { + return WorkCheckpointCandidate{CheckpointID: "cp-evidence", PhaseID: "crosscheck", Findings: []WorkFindingCandidate{{ + FindingKey: "phase", Text: "待核对", Kind: "needs_user", + Sources: []TaskSourceQuote{{SourceID: "total", Quote: "总12项"}, {SourceID: "phase", Quote: "一期8项"}, {SourceID: "dependent", Quote: "其中3项依赖接口"}}, + }}} + } + n := func(v int) *int { return &v } + total := WorkEvidencePremise{Role: "total", SourceIndex: 0, Value: n(12)} + phase := WorkEvidencePremise{Role: "phase", SourceIndex: 1, Value: n(8)} + dependent := WorkEvidencePremise{Role: "dependent", SourceIndex: 2, Value: n(3)} + for _, tc := range []struct { + name string + evidence WorkFindingEvidence + valid bool + }{ + {"direct", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0}}}, true}, + {"difference", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}, Result: n(4)}, true}, + {"unresolved", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase, dependent}}, true}, + {"unknown mode", WorkFindingEvidence{Mode: "prediction", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"missing result", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"extra result", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase, dependent}, Result: n(3)}, false}, + {"duplicate role", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, total}, Result: n(4)}, false}, + {"missing premise", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"negative index", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: -1}}}, false}, + {"foreign index", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 3}}}, false}, + {"negative value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(-1)}, phase}, Result: n(4)}, false}, + {"large value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(1000001)}, phase}, Result: n(4)}, false}, + {"direct value", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0, Value: n(1)}}}, false}, + {"missing value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0}, phase}, Result: n(4)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c := base() + c.Findings[0].Evidence = &tc.evidence + err := ValidateWorkCheckpoint(c) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v err=%v", tc.valid, err) + } + }) + } + c := base() + c.Findings[0].Evidence = &WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}, Result: n(4)} + c.Findings[0].Text = strings.Repeat("字", 5500) + if err := ValidateWorkCheckpoint(c); err == nil || !strings.Contains(err.Error(), "16KiB") { + t.Fatal("evidence bypassed checkpoint size cap", err) + } +} func TestTaskSnapshotBodyPreservesLimitsAndHonestStates(t *testing.T) { s := validTaskSnapshot() s.Remaining = "图片内容未纳入" diff --git a/ts/types.ts b/ts/types.ts index f73d163..cfb6024 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -419,7 +419,9 @@ export interface TaskSnapshot { checkpointKeys?: Record; checkpointCount?: number; checkpointSeq?: number; } export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string } -export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[] } +export interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number } +export interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number } +export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence } export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] } export interface UIAction { id: string; label: string; style?: string; command?: string } From b4743949e525f5e6a6dbce6509e221cac6d15db5 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 16:48:01 +0000 Subject: [PATCH 6/7] Keep U3 TypeScript evidence helpers local Why: Generated mirrors gained unused named exports and tripped the frontend knip ratchet. Evidence: Exact Dev combination failed at 53 unused exports over 52 baseline; Core Go tests passed after narrowing the TS export surface. Impact: Preserve WorkFindingCandidate.evidence shape while avoiding unused exported helper names. Refs: Dev task feature/fast-slow-u2-u3-plan; failed combo receipt 275bebe2 --- ts/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/types.ts b/ts/types.ts index cfb6024..2456b15 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -419,8 +419,8 @@ export interface TaskSnapshot { checkpointKeys?: Record; checkpointCount?: number; checkpointSeq?: number; } export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string } -export interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number } -export interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number } +interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number } +interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number } export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence } export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] } From cbd3d3e3d0ee9fe88a62b3ba13c6747930559d2f Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 02:59:03 +0000 Subject: [PATCH 7/7] agent-exec transport 68889ca8d4b0eec4cfe89d0cc80b6516/5ca3b7798211 --- activity.go | 29 ++-- data.go | 93 ++++++------ task_snapshot.go | 330 ++++++++++++++++++++++++++++++++++++++++++ task_snapshot_test.go | 147 +++++++++++++++++++ ts/types.ts | 28 +++- 5 files changed, 566 insertions(+), 61 deletions(-) create mode 100644 task_snapshot.go create mode 100644 task_snapshot_test.go diff --git a/activity.go b/activity.go index abf9bd3..238815f 100644 --- a/activity.go +++ b/activity.go @@ -12,20 +12,21 @@ 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"` - Work *WorkCard `json:"work,omitempty"` // optional background-work snapshot; never a foreground approval - 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 + Task *TaskSnapshot `json:"task,omitempty"` + 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 diff --git a/data.go b/data.go index 58d701c..5d1d4f8 100644 --- a/data.go +++ b/data.go @@ -13,18 +13,19 @@ type ParsedDocument struct { } type DocMeta struct { - Filename string `json:"filename"` - MIME string `json:"mime,omitempty"` - Parser string `json:"parser"` // builtin:md / builtin:docx / mineru / agent - Confidence float64 `json:"confidence,omitempty"` // 0–1;交叉验证差异块拉低 - CrossCheck *CrossCheck `json:"crossCheck,omitempty"` - ParsedAt time.Time `json:"parsedAt"` + Origin *DocumentOrigin `json:"origin,omitempty"` + Filename string `json:"filename"` + MIME string `json:"mime,omitempty"` + Parser string `json:"parser"` // builtin:md / builtin:docx / mineru / agent + Confidence float64 `json:"confidence,omitempty"` // 0–1;交叉验证差异块拉低 + CrossCheck *CrossCheck `json:"crossCheck,omitempty"` + ParsedAt time.Time `json:"parsedAt"` } type CrossCheck struct { - With string `json:"with"` // 交叉验证的另一解析器 - DiffBlocks int `json:"diffBlocks"` - Verdict string `json:"verdict"` // agree | differ + With string `json:"with"` // 交叉验证的另一解析器 + DiffBlocks int `json:"diffBlocks"` + Verdict string `json:"verdict"` // agree | differ } type DocBlockType string @@ -39,13 +40,13 @@ const ( ) type DocBlock struct { - Type DocBlockType `json:"type"` - Level int `json:"level,omitempty"` // heading - Text string `json:"text,omitempty"` // para/heading/list 项拼接/code - Items []string `json:"items,omitempty"` // list - Rows [][]string `json:"rows,omitempty"` // table(首行为表头) - Ref string `json:"ref,omitempty"` // image-ref 的对象键 - Confidence float64 `json:"confidence,omitempty"` + Type DocBlockType `json:"type"` + Level int `json:"level,omitempty"` // heading + Text string `json:"text,omitempty"` // para/heading/list 项拼接/code + Items []string `json:"items,omitempty"` // list + Rows [][]string `json:"rows,omitempty"` // table(首行为表头) + Ref string `json:"ref,omitempty"` // image-ref 的对象键 + Confidence float64 `json:"confidence,omitempty"` } // PlainText 把文档压成纯文本(附件注入与全文索引用);表格按行拼接。 @@ -88,60 +89,60 @@ func (d ParsedDocument) PlainText() string { // ParserDesc 注册表条目(来源标记:builtin/plugin/...;一切可禁用)。 type ParserDesc struct { - ID string `json:"id"` - Formats []string `json:"formats"` // 支持的扩展名 + ID string `json:"id"` + Formats []string `json:"formats"` // 支持的扩展名 Source SourceKind `json:"source"` - Note string `json:"note,omitempty"` + Note string `json:"note,omitempty"` } // ---- 检索(4.12)---- // KnowledgeDoc 是检索语料的一条文档(来自 ingest 或插件知识库,带来源标记)。 type KnowledgeDoc struct { - ID ID `json:"id"` - Owner ID `json:"owner,omitempty"` // 空 = 共享(插件级)知识 - ProjectID ID `json:"projectId,omitempty"` // 空 = 插件级知识库 - Source string `json:"source"` // upload | artifact | plugin: - Title string `json:"title"` - Ref string `json:"ref,omitempty"` // 工件/文件引用 - Chunk string `json:"chunk"` + ID ID `json:"id"` + Owner ID `json:"owner,omitempty"` // 空 = 共享(插件级)知识 + ProjectID ID `json:"projectId,omitempty"` // 空 = 插件级知识库 + Source string `json:"source"` // upload | artifact | plugin: + Title string `json:"title"` + Ref string `json:"ref,omitempty"` // 工件/文件引用 + Chunk string `json:"chunk"` CreatedAt time.Time `json:"createdAt"` } type RetrievalHit struct { - Doc KnowledgeDoc `json:"doc"` - Score float64 `json:"score"` - Kind string `json:"kind"` // fulltext | vector | plugin + Doc KnowledgeDoc `json:"doc"` + Score float64 `json:"score"` + Kind string `json:"kind"` // fulltext | vector | plugin } type RetrievalQuery struct { - ProjectID ID `json:"projectId,omitempty"` - Text string `json:"text"` - TopK int `json:"topK,omitempty"` + ProjectID ID `json:"projectId,omitempty"` + Text string `json:"text"` + TopK int `json:"topK,omitempty"` Sources []string `json:"sources,omitempty"` // 过滤来源 } // ---- 记忆(4.13 契约;实现远期)---- type MemoryEntry struct { - ID string `json:"id"` - Scope string `json:"scope"` // user | project - ProjectID ID `json:"projectId,omitempty"` - Type string `json:"type"` // fact | preference | decision | episode | procedure - Content string `json:"content"` - Source string `json:"source"` // run: | manual - Confidence float64 `json:"confidence,omitempty"` - CreatedAt time.Time `json:"createdAt"` - LastAccessed time.Time `json:"lastAccessed"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` + ID string `json:"id"` + Scope string `json:"scope"` // user | project + ProjectID ID `json:"projectId,omitempty"` + Type string `json:"type"` // fact | preference | decision | episode | procedure + Content string `json:"content"` + Source string `json:"source"` // run: | manual + Confidence float64 `json:"confidence,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastAccessed time.Time `json:"lastAccessed"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` } // ---- 上传文件 ---- type UploadInfo struct { - ArtifactID ID `json:"artifactId"` - Filename string `json:"filename"` - MIME string `json:"mime,omitempty"` - Size int64 `json:"size"` + ArtifactID ID `json:"artifactId"` + Filename string `json:"filename"` + MIME string `json:"mime,omitempty"` + Size int64 `json:"size"` Parsed *ParsedDocument `json:"parsed,omitempty"` } diff --git a/task_snapshot.go b/task_snapshot.go new file mode 100644 index 0000000..3144618 --- /dev/null +++ b/task_snapshot.go @@ -0,0 +1,330 @@ +package core + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type DocumentOrigin struct { + ArtifactID ID `json:"artifactId"` + ContentHash string `json:"contentHash"` + ParserRevision string `json:"parserRevision"` +} +type TaskPhase struct { + ID string `json:"id"` + Label string `json:"label"` + ObservedAt time.Time `json:"observedAt,omitempty"` +} +type TaskSourceRef struct { + ID string `json:"id"` + ArtifactID string `json:"artifactId"` + ContentHash string `json:"contentHash"` + ParserRevision string `json:"parserRevision"` + BlockIndex int `json:"blockIndex"` + Label string `json:"label"` + Quote string `json:"quote,omitempty"` + Availability string `json:"availability,omitempty"` +} +type TaskResultRef struct { + ArtifactID ID `json:"artifactId,omitempty"` + SurfaceID ID `json:"surfaceId,omitempty"` + Version string `json:"version,omitempty"` + ContentHash string `json:"contentHash,omitempty"` + Availability string `json:"availability"` + Name string `json:"name,omitempty"` +} +type TaskFinding struct { + ID string `json:"id"` + Text string `json:"text"` + Impact string `json:"impact,omitempty"` + Kind string `json:"kind"` + Status string `json:"status"` + Revision int64 `json:"revision"` + Sources []TaskSourceRef `json:"sources"` + Assumptions string `json:"assumptions,omitempty"` + Corrects string `json:"corrects,omitempty"` +} +type TaskSnapshot struct { + SchemaVersion int `json:"schemaVersion"` + TaskID ID `json:"taskId"` + AttemptID ID `json:"attemptId"` + CardID ID `json:"cardId"` + ProjectID ID `json:"projectId"` + ConversationID ID `json:"conversationId"` + SourceMessageID ID `json:"sourceMessageId,omitempty"` + ParentRunID ID `json:"parentRunId,omitempty"` + RunID ID `json:"runId,omitempty"` + OwnerInstanceID string `json:"ownerInstanceId"` + ExecutionEpoch int64 `json:"executionEpoch,omitempty"` + Revision int64 `json:"revision"` + SourceSeq int64 `json:"sourceSeq"` + StreamScope string `json:"streamScope"` + InputRevision string `json:"inputRevision,omitempty"` + Title string `json:"title"` + Goal string `json:"goal,omitempty"` + ExecutionState string `json:"executionState"` + OutcomeCompleteness string `json:"outcomeCompleteness"` + DeliveryState string `json:"deliveryState"` + Phase TaskPhase `json:"phase"` + Focus string `json:"focus,omitempty"` + Coverage string `json:"coverage,omitempty"` + Remaining string `json:"remaining,omitempty"` + NextStep string `json:"nextStep,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + Findings []TaskFinding `json:"findings,omitempty"` + Limitations []string `json:"limitations,omitempty"` + Inputs []TaskSourceRef `json:"inputs,omitempty"` + Actions []UIAction `json:"actions,omitempty"` + ResultRefs []TaskResultRef `json:"resultRefs,omitempty"` + LastActivityAt time.Time `json:"lastActivityAt,omitempty"` + LastMeaningfulUpdateAt time.Time `json:"lastMeaningfulUpdateAt,omitempty"` + CheckpointKeys map[string]string `json:"checkpointKeys,omitempty"` + CheckpointCount int `json:"checkpointCount,omitempty"` + CheckpointSeq int64 `json:"checkpointSeq,omitempty"` +} +type TaskSourceQuote struct { + SourceID string `json:"sourceId"` + BlockIndex int `json:"blockIndex"` + Quote string `json:"quote"` +} +type WorkCheckpointCandidate struct { + CheckpointID string `json:"checkpointId"` + PhaseID string `json:"phaseId"` + Focus string `json:"focus,omitempty"` + Remaining string `json:"remaining,omitempty"` + NextStep string `json:"nextStep,omitempty"` + Findings []WorkFindingCandidate `json:"findings"` +} +type WorkFindingCandidate struct { + FindingKey string `json:"findingKey"` + Text string `json:"text"` + Impact string `json:"impact,omitempty"` + Kind string `json:"kind"` + Assumptions string `json:"assumptions,omitempty"` + Corrects string `json:"corrects,omitempty"` + Sources []TaskSourceQuote `json:"sources"` + Evidence *WorkFindingEvidence `json:"evidence,omitempty"` +} + +// WorkFindingEvidence is a model-supplied candidate, not a persisted proof. +// Backend checks the quoted source text and computes any resulting statement. +type WorkEvidencePremise struct { + Role string `json:"role"` + SourceIndex int `json:"sourceIndex"` + Value *int `json:"value,omitempty"` +} + +type WorkFindingEvidence struct { + Mode string `json:"mode"` + Premises []WorkEvidencePremise `json:"premises"` + Result *int `json:"result,omitempty"` +} + +func validateWorkFindingEvidence(e *WorkFindingEvidence, sourceCount int) error { + if e == nil { + return nil + } + var required []string + switch e.Mode { + case "direct_relation": + required = []string{"direct_relation"} + if e.Result != nil { + return fmt.Errorf("checkpoint.evidence: direct relation cannot have result") + } + case "difference": + required = []string{"total", "phase"} + if e.Result == nil || *e.Result < 0 || *e.Result > 1000000 { + return fmt.Errorf("checkpoint.evidence: difference requires bounded result") + } + case "unresolved_relation": + required = []string{"total", "phase", "dependent"} + if e.Result != nil { + return fmt.Errorf("checkpoint.evidence: unresolved relation cannot have result") + } + default: + return fmt.Errorf("checkpoint.evidence: unknown mode") + } + if len(e.Premises) != len(required) { + return fmt.Errorf("checkpoint.evidence: wrong premise count") + } + roles := make(map[string]bool, len(required)) + for _, premise := range e.Premises { + if roles[premise.Role] || premise.SourceIndex < 0 || premise.SourceIndex >= sourceCount { + return fmt.Errorf("checkpoint.evidence: duplicate role or invalid source index") + } + roles[premise.Role] = true + if e.Mode == "direct_relation" { + if premise.Value != nil { + return fmt.Errorf("checkpoint.evidence: direct relation cannot have value") + } + } else if premise.Value == nil || *premise.Value < 0 || *premise.Value > 1000000 { + return fmt.Errorf("checkpoint.evidence: count premise requires bounded value") + } + } + for _, role := range required { + if !roles[role] { + return fmt.Errorf("checkpoint.evidence: missing premise role") + } + } + return nil +} + +// Validate enforces wire limits; owner/source authorization belongs to Backend. +func (s TaskSnapshot) Validate() error { + if s.SchemaVersion != 1 { + return fmt.Errorf("task.schema: unsupported") + } + if s.TaskID == "" || s.AttemptID == "" || s.CardID == "" || s.ProjectID == "" || s.ConversationID == "" || s.OwnerInstanceID == "" { + return fmt.Errorf("task.scope: missing identity") + } + if s.StreamScope != "conversation:"+s.ConversationID { + return fmt.Errorf("task.scope: inconsistent stream") + } + if s.Revision < 1 || s.Revision > 9007199254740991 || s.SourceSeq < 0 || s.SourceSeq > 9007199254740991 || s.ExecutionEpoch < 0 { + return fmt.Errorf("task.revision: unsafe cursor") + } + switch s.ExecutionState { + case "queued", "running", "awaiting_user", "completed", "failed", "interrupted": + default: + return fmt.Errorf("task.state: unknown") + } + if (s.ExecutionState == "running" || s.ExecutionState == "awaiting_user") && s.RunID == "" { + return fmt.Errorf("task.run: active task requires run") + } + switch s.OutcomeCompleteness { + case "none", "partial", "complete": + default: + return fmt.Errorf("task.outcome: unknown") + } + switch s.DeliveryState { + case "pending", "available", "reported": + default: + return fmt.Errorf("task.delivery: unknown") + } + active := 0 + for _, f := range s.Findings { + if f.ID == "" || f.Revision < 1 { + return fmt.Errorf("task.finding: missing identity") + } + switch f.Kind { + case "material_record", "analysis", "provisional", "needs_user", "verified": + default: + return fmt.Errorf("task.finding.kind: unknown") + } + switch f.Status { + case "active": + active++ + case "corrected", "withdrawn", "superseded": + default: + return fmt.Errorf("task.finding.status: unknown") + } + } + if active > 12 { + return fmt.Errorf("task.findings: too many active") + } + b, err := json.Marshal(s) + if err != nil { + return err + } + if len(b) > 64*1024 { + return fmt.Errorf("task.size: exceeds 64KiB") + } + return nil +} +func (s TaskSnapshot) Terminal() bool { + return s.ExecutionState == "completed" || s.ExecutionState == "failed" || s.ExecutionState == "interrupted" +} +func ValidateWorkCheckpoint(c WorkCheckpointCandidate) error { + if strings.TrimSpace(c.CheckpointID) == "" || len(c.CheckpointID) > 128 { + return fmt.Errorf("checkpoint.id: required or too long") + } + switch c.PhaseID { + case "reading", "crosscheck", "summarizing", "draft-ready": + default: + return fmt.Errorf("checkpoint.phase: unknown") + } + if len(c.Findings) == 0 || len(c.Findings) > 6 { + return fmt.Errorf("checkpoint.findings: require 1..6") + } + keys := map[string]bool{} + for _, f := range c.Findings { + switch f.Kind { + case "material_record", "analysis", "provisional", "needs_user": + default: + return fmt.Errorf("checkpoint.kind: model cannot grant verified status") + } + if f.FindingKey == "" || keys[f.FindingKey] || strings.TrimSpace(f.Text) == "" { + return fmt.Errorf("checkpoint.finding: empty or duplicate key") + } + keys[f.FindingKey] = true + if len(f.Sources) == 0 { + return fmt.Errorf("checkpoint.sources: evidence required") + } + for _, src := range f.Sources { + if src.SourceID == "" || src.BlockIndex < 0 || strings.TrimSpace(src.Quote) == "" { + return fmt.Errorf("checkpoint.source: invalid") + } + } + if err := validateWorkFindingEvidence(f.Evidence, len(f.Sources)); err != nil { + return err + } + if (f.Kind == "analysis" || f.Kind == "provisional") && strings.TrimSpace(f.Assumptions) == "" { + return fmt.Errorf("checkpoint.assumptions: required") + } + } + b, err := json.Marshal(c) + if err != nil { + return err + } + if len(b) > 16*1024 { + return fmt.Errorf("checkpoint.size: exceeds 16KiB") + } + return nil +} +func (s TaskSnapshot) SummaryBody() []UINode { + labels := map[string]string{"queued": "排队中", "running": "进行中", "awaiting_user": "需要你处理", "completed": "已完成", "failed": "执行失败", "interrupted": "已中断"} + var lines []string + for _, f := range s.Findings { + if f.Status == "active" { + line := f.Text + if f.Impact != "" { + line += ";" + f.Impact + } + lines = append(lines, line) + } + } + note := strings.Join(lines, "\n") + if note == "" { + note = TaskSummaryNoFindingsYet + if s.ExecutionState == "queued" { + note = "尚未开始处理,等待执行资源" + } + if s.Terminal() { + note = s.Conclusion + if note == "" { + note = "没有可用阶段成果" + } + } + } + v := TaskSummaryView{Focus: s.Focus, FindingsNote: note, Remaining: s.Remaining, NextStep: s.NextStep, OutcomeLine: s.Conclusion} + var body []UINode + if s.Terminal() { + body = v.FinishedBody(labels[s.ExecutionState], nil) + } else { + body = v.RunningBody(labels[s.ExecutionState], nil) + } + if s.Coverage != "" { + body = append(body, UINode{Type: UIKV, KV: [][2]string{{"提供范围", s.Coverage}}}) + } + if len(s.Limitations) > 0 { + body = append(body, UINode{Type: UIList, Title: "范围与限制", Items: s.Limitations}) + } + for _, r := range s.ResultRefs { + if r.ArtifactID != "" && r.Availability == "available" { + body = append(body, UINode{Type: UIDocRef, DocID: r.ArtifactID, Title: r.Name}) + } + } + return body +} diff --git a/task_snapshot_test.go b/task_snapshot_test.go new file mode 100644 index 0000000..a0de869 --- /dev/null +++ b/task_snapshot_test.go @@ -0,0 +1,147 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" +) + +// Loss of the optional task object makes refresh discard stage findings. +func TestTaskSnapshotCardRoundTrip(t *testing.T) { + raw := []byte(`{"id":"card1","projectId":"p1","conversationId":"c1","kind":"progress","status":"active","task":{"schemaVersion":1,"taskId":"t1","attemptId":"a1","cardId":"card1","revision":4,"executionState":"running","focus":"核对材料","findings":[{"id":"f1","text":"日期不同","kind":"material_record","status":"active","revision":1}]}}`) + var card ActivityCard + if err := json.Unmarshal(raw, &card); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(card) + if err != nil { + t.Fatal(err) + } + var restored map[string]json.RawMessage + if err := json.Unmarshal(encoded, &restored); err != nil { + t.Fatal(err) + } + if _, ok := restored["task"]; !ok { + t.Fatal("task findings were lost in card persistence") + } + var task map[string]json.RawMessage + if err := json.Unmarshal(restored["task"], &task); err != nil { + t.Fatal(err) + } + if string(task["revision"]) != "4" || string(task["focus"]) != `"核对材料"` { + t.Fatal("revision/focus did not survive") + } +} + +func validTaskSnapshot() TaskSnapshot { + return TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: "card1", ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", StreamScope: "conversation:c1", Title: "整理材料"} +} +func TestTaskSnapshotRejectsUnsafeAndUnboundState(t *testing.T) { + for _, mutate := range []func(*TaskSnapshot){ + func(s *TaskSnapshot) { s.TaskID = "" }, func(s *TaskSnapshot) { s.AttemptID = "" }, + func(s *TaskSnapshot) { s.Revision = 9007199254740992 }, func(s *TaskSnapshot) { s.SourceSeq = -1 }, + func(s *TaskSnapshot) { s.ExecutionState = "imagined" }, func(s *TaskSnapshot) { s.StreamScope = "conversation:other" }, + func(s *TaskSnapshot) { s.ExecutionState = "running" }, + } { + s := validTaskSnapshot() + mutate(&s) + if s.Validate() == nil { + t.Errorf("invalid snapshot accepted: %+v", s) + } + } + if err := validTaskSnapshot().Validate(); err != nil { + t.Fatal(err) + } +} +func TestWorkCheckpointRejectsVerifiedAndEmptyEvidence(t *testing.T) { + c := WorkCheckpointCandidate{CheckpointID: "cp1", PhaseID: "crosscheck", Findings: []WorkFindingCandidate{{FindingKey: "date", Text: "日期不同", Kind: "verified", Sources: []TaskSourceQuote{{SourceID: "src1", Quote: "六月"}}}}} + if err := ValidateWorkCheckpoint(c); err == nil || !strings.Contains(err.Error(), "kind") { + t.Fatal("model granted verified status", err) + } + c.Findings[0].Kind = "material_record" + c.Findings[0].Sources = nil + if ValidateWorkCheckpoint(c) == nil { + t.Fatal("source-free fact accepted") + } + c.Findings[0].Sources = []TaskSourceQuote{{SourceID: "src1", Quote: "六月"}} + if err := ValidateWorkCheckpoint(c); err != nil { + t.Fatal(err) + } + c.Findings[0].Text = strings.Repeat("x", 17000) + if ValidateWorkCheckpoint(c) == nil { + t.Fatal("unbounded candidate accepted") + } +} + +func TestWorkCheckpointEvidenceModesAndBounds(t *testing.T) { + base := func() WorkCheckpointCandidate { + return WorkCheckpointCandidate{CheckpointID: "cp-evidence", PhaseID: "crosscheck", Findings: []WorkFindingCandidate{{ + FindingKey: "phase", Text: "待核对", Kind: "needs_user", + Sources: []TaskSourceQuote{{SourceID: "total", Quote: "总12项"}, {SourceID: "phase", Quote: "一期8项"}, {SourceID: "dependent", Quote: "其中3项依赖接口"}}, + }}} + } + n := func(v int) *int { return &v } + total := WorkEvidencePremise{Role: "total", SourceIndex: 0, Value: n(12)} + phase := WorkEvidencePremise{Role: "phase", SourceIndex: 1, Value: n(8)} + dependent := WorkEvidencePremise{Role: "dependent", SourceIndex: 2, Value: n(3)} + for _, tc := range []struct { + name string + evidence WorkFindingEvidence + valid bool + }{ + {"direct", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0}}}, true}, + {"difference", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}, Result: n(4)}, true}, + {"unresolved", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase, dependent}}, true}, + {"unknown mode", WorkFindingEvidence{Mode: "prediction", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"missing result", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"extra result", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase, dependent}, Result: n(3)}, false}, + {"duplicate role", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, total}, Result: n(4)}, false}, + {"missing premise", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"negative index", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: -1}}}, false}, + {"foreign index", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 3}}}, false}, + {"negative value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(-1)}, phase}, Result: n(4)}, false}, + {"large value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(1000001)}, phase}, Result: n(4)}, false}, + {"direct value", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0, Value: n(1)}}}, false}, + {"missing value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0}, phase}, Result: n(4)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c := base() + c.Findings[0].Evidence = &tc.evidence + err := ValidateWorkCheckpoint(c) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v err=%v", tc.valid, err) + } + }) + } + c := base() + c.Findings[0].Evidence = &WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}, Result: n(4)} + c.Findings[0].Text = strings.Repeat("字", 5500) + if err := ValidateWorkCheckpoint(c); err == nil || !strings.Contains(err.Error(), "16KiB") { + t.Fatal("evidence bypassed checkpoint size cap", err) + } +} +func TestTaskSnapshotBodyPreservesLimitsAndHonestStates(t *testing.T) { + s := validTaskSnapshot() + s.Remaining = "图片内容未纳入" + s.ExecutionState = "failed" + s.Conclusion = "处理失败,已有发现可查看" + s.Findings = []TaskFinding{{ID: "f1", Text: "金额缺失", Kind: "material_record", Status: "active", Revision: 1}} + raw, _ := json.Marshal(s.SummaryBody()) + for _, want := range []string{"执行失败", "图片内容未纳入", "金额缺失", "处理失败"} { + if !strings.Contains(string(raw), want) { + t.Errorf("lost critical summary %s", want) + } + } +} + +func TestTaskSnapshotUnstartedAndFinishedDoNotClaimReading(t *testing.T) { + for _, state := range []string{"queued", "completed", "interrupted"} { + s := validTaskSnapshot() + s.ExecutionState = state + s.Conclusion = "处理已结束" + b, _ := json.Marshal(s.SummaryBody()) + if strings.Contains(string(b), "正在读取") { + t.Errorf("%s falsely claims active reading", state) + } + } +} diff --git a/ts/types.ts b/ts/types.ts index de1ef69..a656493 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -149,6 +149,7 @@ export interface ActivityCard { id: ID; projectId: ID; conversationId?: ID; sourceRunId?: ID; anchorMessageId?: ID; kind: CardKind; title: string; status: CardStatus; tone?: string; + task?: TaskSnapshot; body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[]; pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard; } @@ -350,7 +351,7 @@ export interface PluginPageDecl { export interface ParsedDocument { meta: { - filename: string; mime?: string; parser: string; confidence?: number; + filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin; crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' }; parsedAt: string; }; @@ -413,3 +414,28 @@ export interface SurfaceMeta { conversationId?: ID; updatedAt: string; } + +// Persistent work projection; host-owned identities and revisions. +export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string } +export interface TaskPhase { id: string; label: string; observedAt?: string } +export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string } +export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string } +export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string } +export interface TaskSnapshot { + inputs?: TaskSourceRef[] + limitations?: string[] + schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID; + sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number; + revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string; + executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase; + focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string; + findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string; + checkpointKeys?: Record; checkpointCount?: number; checkpointSeq?: number; +} +export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string } +interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number } +interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number } +export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence } +export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] } + +export interface UIAction { id: string; label: string; style?: string; command?: string }