merge: candidate controlled-projection-project-binding-2026-09-28 into dev
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|---|---|---|---|---|---|
|
||||
| Cursor | controlled-projection-project-binding-2026-09-28 | fix/controlled-projection-project-binding-2026-09-28 | .worktrees/controlled-projection-project-binding-2026-09-28 | controlled-projection-binding | in_progress |
|
||||
| Cursor | docgov-shared-map-race-2026-09-28-r2 | fix/docgov-shared-map-race-2026-09-28 | .worktrees/docgov-shared-map-race-2026-09-28 | docgov-shared-map-race | done |
|
||||
| Cursor | promote-rc-beta6-closeout-2026-09-28 | feature/promote-rc-beta6-closeout-2026-09-28 | .worktrees/promote-rc-beta6-closeout-2026-09-28 | git:test+env:test | done |
|
||||
| Cursor | quality-gate-heading-mismatch-2026-09-28-r3 | fix/quality-gate-heading-mismatch-2026-09-28 | .worktrees/quality-gate-heading-mismatch-2026-09-28 | quality-gate-heading | in_progress |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "修复调研报告受控投影断链"
|
||||
task_id: controlled-projection-project-binding-2026-09-28
|
||||
status: in_progress
|
||||
created: 2026-09-28
|
||||
updated: 2026-09-28
|
||||
owner: Cursor
|
||||
task_type: fix
|
||||
policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21"
|
||||
branch: fix/controlled-projection-project-binding-2026-09-28
|
||||
worktree: .worktrees/controlled-projection-project-binding-2026-09-28
|
||||
base_dev_sha: "5d762fb2061113285ee21219471baaf11cb2d770"
|
||||
write_scope:
|
||||
- internal/server
|
||||
- internal/coordinator
|
||||
- internal/bizdev
|
||||
- .doc/ops/CURRENT.md
|
||||
- .doc/ops/short-term/2026-09-28-controlled-projection-project-binding.md
|
||||
- .doc/test-reports/2026-09-28-controlled-projection-project-binding-test-report.md
|
||||
mutex: controlled-projection-binding
|
||||
review_state: pending
|
||||
integration_state: branch_only
|
||||
deployment: not_loaded
|
||||
cleanup: pending
|
||||
remote_sync: not_requested
|
||||
---
|
||||
|
||||
# 修复调研报告受控投影断链
|
||||
|
||||
## 用户目标与授权
|
||||
|
||||
用户在项目会话里让助手编制调研报告。助手回复已生成并保存(含耗时与基准假设),刷新文档页仍是模板骨架。授权范围是修复受控投影断链:回填项目绑定、会话可追溯、无绑定时失败关闭、回复与账本状态一致。不改前端骨架回退,不删除工作区,不晋升环境,不 push。
|
||||
|
||||
## 检索
|
||||
|
||||
- 问题台账未另开新条;本任务记录根因与修复。catalog overlap 探测为 none。登记时未带 mutex/intent,避免与持有全仓范围 `.` 的夜间推进任务发生 same_scope 拒绝。
|
||||
- 经验闭环:本轮采纳「回复口径必须跟受控账本,而不是跟小队成功」。
|
||||
|
||||
## 根因
|
||||
|
||||
聊天入口只 `SetProjectID` 做访问校验,`UserCtx.Project.ID` 一直为空。协调器 `handleTriggerTask` 用后者解析 projectID,得到 0。`canonicalDocID` 因此返回空串,bizdev 整段跳过 docgov/docrevision 投影。`output.md` 只落在隔离工作区,回复仍写「正文已保存」。
|
||||
|
||||
证据工作区:`research_1790576535990100562`(project_id=0、session_id 空、completed)。`doc_proj6024_bd_research` 在 07:06 带绑定重跑前只有模板快照。随后 `research_1790584772615062606`(失败)和 `research_1790585141260333084`(completed)仍是 project_id=0。
|
||||
|
||||
## 修复
|
||||
|
||||
提交 `f8730791`:
|
||||
|
||||
- `bindSessionProjectContext`:`enrichSessionWorkflowStage` 在项目已授权且上下文项目 ID 为空时回填。已有绑定不覆盖。
|
||||
- `handleTriggerTask` / `handleTriggerAdhocTask` 把 `ChatSessionID` 写入 `TaskRunRequest.SessionID`。
|
||||
- 受控目录任务(目录键与 `rd_*`)在 `ProjectID <= 0` 时返回「会话未绑定项目,受控交付物无法入账」,不建孤儿工作区。未知自由任务不在此列。
|
||||
- 完成回复只在 `DocRecord` 有文档 ID 时称已写入受控文档;否则说明交付物在工作区、尚未入账。`CompletionSummary` 对受控键同样区分。
|
||||
|
||||
## 数据
|
||||
|
||||
`doc_proj6024_bd_research` 已由 07:06 带绑定重跑入账,不做数据修复。
|
||||
|
||||
孤儿工作区只登记、不删除:
|
||||
|
||||
| 路径 | cleanup | 原因 |
|
||||
|---|---|---|
|
||||
| `data/dev/workspaces/bizdev/research_1790576535990100562` | pending | project_id=0 的已完成调研,正文未入账 |
|
||||
| `data/dev/workspaces/bizdev/research_1790585141260333084` | pending | 修复前再次以 project_id=0 完成 |
|
||||
|
||||
cleanup_owner:本任务。复查触发:合入 local dev 并确认新会话不再产生 project_id=0 的受控工作区之后,由独立维护者决定是否归档。
|
||||
|
||||
## 验证
|
||||
|
||||
见 [测试报告](../../test-reports/2026-09-28-controlled-projection-project-binding-test-report.md)。
|
||||
|
||||
## 五态
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| review_state | pending |
|
||||
| integration_state | branch_only |
|
||||
| deployment | not_loaded(运行中的 dev 二进制早于本提交) |
|
||||
| cleanup | pending |
|
||||
| remote_sync | not_requested |
|
||||
@@ -0,0 +1,103 @@
|
||||
# 调研报告受控投影项目绑定 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
项目会话里触发调研报告后,助手称正文已保存,刷新文档页仍显示模板骨架。根因是会话授权项目 ID 没有回填到协调器读取的 `UserCtx.Project.ID`,受控投影被跳过。任务记录见 [短文](../ops/short-term/2026-09-28-controlled-projection-project-binding.md)。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 已授权会话在派发前把项目 ID 写入协调器上下文,且不覆盖已有绑定。
|
||||
- 协调器触发任务时 `TaskRunRequest` 带上项目 ID 与 `SessionID`。
|
||||
- 受控任务键在 `ProjectID <= 0` 时失败关闭,不建工作区。
|
||||
- 完成回复在没有受控账本记录时不称「正文已保存」。
|
||||
- 既有阶段编排、异步 run 与流式任务在带上项目 ID 后仍能完成。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS:Linux。工作树:`.worktrees/controlled-projection-project-binding-2026-09-28`。
|
||||
- 基线 `5d762fb2061113285ee21219471baaf11cb2d770`。被测提交 `f8730791b9a0a9d4ac67586f622acb6afaf43f10`。
|
||||
- 层级:Go 接口短测、`go vet ./...`、架构边界 `arch-check --mode block`。
|
||||
- 产品 PI 浏览器链路依赖已加载本提交的 dev 进程。本报告记录短测时,运行中的 `bin/agent-server` 仍是合入前的主目录制品。
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- `bindSessionProjectContext` 把空项目 ID 填成会话项目;已有值保持不变;未授权项目不回填。
|
||||
- 同步触发 research 时请求的 `ProjectID` 与 `SessionID` 与上下文一致。
|
||||
- `intake` / `research` 在项目 ID 为 0 时返回 `ErrUnboundControlledProject`,`DocRecord` 为空。
|
||||
- 无 `DocRecord` 的完成回复包含「尚未写入受控文档账本」,且不含「正文已保存」。
|
||||
- `go vet ./...` 无输出;三包 `go test -short` 通过;架构边界通过。
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 用例 1:项目绑定回填
|
||||
|
||||
- 输入:`go test -count=1 -short -timeout 180s ./internal/server/ -run TestBindSessionProjectContext`(含在用例 4 的整包短测中)。
|
||||
- 实际输出:`internal/server` 整包 `ok`,20.400s。用例覆盖回填 6024、保留已有 `"9"`、`SetProjectID(0)` 时不写入。
|
||||
- 判定:PASS
|
||||
- 耗时:见整包短测
|
||||
|
||||
### 用例 2:触发请求携带项目与会话
|
||||
|
||||
- 输入:`go test -count=1 -short ./internal/coordinator/ -run 'TestSyncTriggerTask|TestFormatTriggerTaskReply'`
|
||||
- 实际输出:`internal/coordinator` 整包 `ok`,0.015s。`TestSyncTriggerTask_EnablesAutoRepair` 断言 `ProjectID==9` 且 `SessionID==sess-research`。
|
||||
- 判定:PASS
|
||||
|
||||
### 用例 3:无项目绑定失败关闭,回复不声称入账
|
||||
|
||||
- 输入:`go test -count=1 -short ./internal/bizdev/ -run 'TestExecuteTask|TestTaskRunResult_'`
|
||||
- 实际输出:`internal/bizdev` 整包 `ok`,0.180s。受控键无项目时 `errors.Is` 为 `ErrUnboundControlledProject`。无账本的完成摘要包含「尚未写入受控文档账本」。未绑定项目的同步触发回复为该错误原文,状态 `StatusRejected`。
|
||||
- 判定:PASS
|
||||
|
||||
### 用例 4:三包短测、vet、架构边界
|
||||
|
||||
- 输入:
|
||||
|
||||
```text
|
||||
gofmt -w internal/server/task_run_observer_test.go
|
||||
go test -count=1 -short -timeout 180s ./internal/bizdev/ ./internal/coordinator/ ./internal/server/
|
||||
go vet ./...
|
||||
./scripts/arch-check.sh --mode block
|
||||
```
|
||||
|
||||
- 实际输出:
|
||||
|
||||
```text
|
||||
ok chat-agent-system/internal/bizdev 0.180s
|
||||
ok chat-agent-system/internal/coordinator 0.015s
|
||||
ok chat-agent-system/internal/server 20.400s
|
||||
VET:0
|
||||
arch-check mode=block
|
||||
ok chat-agent-system/internal/archtest 0.194s
|
||||
ok chat-agent-system/internal/archtest 0.191s
|
||||
arch-check block: production gate passed
|
||||
ARCH:0
|
||||
```
|
||||
|
||||
- 判定:PASS
|
||||
- 耗时:三包短测约 23s;vet 与架构检查约 2s。工作目录为任务 worktree。
|
||||
|
||||
说明:`TestTaskRun_DisconnectDoesNotCancelWorker` 曾因 worker 用 `RunRecord.ProjectID` 覆盖请求而失败。测试记录改为 `ProjectID: 1` 后通过。这与线上异步 run 以记录中的项目 ID 为准一致。
|
||||
|
||||
### 用例 5:dev 真实 PI 浏览器链路
|
||||
|
||||
- 输入:在已加载 `f8730791` 的项目会话中对话触发调研报告,核对 `workspace.json` 的 `project_id`、`document_versions` 的 agent_squad 提交,以及刷新后的文档正文。
|
||||
- 实际输出:未运行。当前 dev 的 `bin/agent-server` 早于本提交,主目录 `dev` 尚未合入该提交。
|
||||
- 判定:未运行
|
||||
- 风险:短测证明绑定与失败关闭,不能代替刷新后文档页看到正文。
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| `go test -short` `internal/bizdev` `internal/coordinator` `internal/server` | PASS |
|
||||
| `go vet ./...` | PASS |
|
||||
| `./scripts/arch-check.sh --mode block` | PASS |
|
||||
| 产品 PI 浏览器 | 未运行 |
|
||||
|
||||
未跑全仓 `go test -short ./...`。本改动的包与架构门禁已覆盖计划中的回归范围。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
授权内的代码与短测达到任务分支可审终点。合入 local dev 并重载 `agent-server` 之后,仍须用真实 PI 在项目会话触发调研报告,确认工作区 `project_id` 非 0、账本出现 agent_squad 版本,刷新文档页不再只剩模板骨架。
|
||||
|
||||
孤儿工作区 `research_1790576535990100562`、`research_1790585141260333084` 保持 cleanup=pending,不删除。`doc_proj6024_bd_research` 已有带绑定重跑入账,无额外数据修复。
|
||||
@@ -239,7 +239,7 @@ func TestExecuteTask_CreateDocumentFailureIsNotSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) {
|
||||
func TestExecuteTask_ControlledKeyWithoutProjectRejected(t *testing.T) {
|
||||
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -253,17 +253,14 @@ func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) {
|
||||
Backend: "stub",
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty docID ExecuteTask panicked or failed: %v", err)
|
||||
if !errors.Is(err, ErrUnboundControlledProject) {
|
||||
t.Fatalf("controlled task without project must fail closed, err=%v", err)
|
||||
}
|
||||
if res == nil || !res.Success {
|
||||
t.Fatalf("CLI-like stage path should succeed without doc ledger, got %#v", res)
|
||||
if res == nil || res.Success || res.DocRecord != nil {
|
||||
t.Fatalf("unbound controlled task must not create a ledger or report success: %#v", res)
|
||||
}
|
||||
if res.DocRecord != nil {
|
||||
t.Fatalf("empty docID must not create a document record, got %#v", res.DocRecord)
|
||||
}
|
||||
if _, stErr := svc.docRev.GetRevisionState(ctx, ""); !strings.Contains(stErr.Error(), "invalid doc_id") {
|
||||
t.Fatalf("empty GetRevisionState should be invalid, got %v", stErr)
|
||||
if !strings.Contains(res.ErrorReason, "会话未绑定项目") {
|
||||
t.Fatalf("ErrorReason=%q", res.ErrorReason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestExecuteTask_Uncontrolled_DoesNotForceAutoRepair(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
||||
res, err := svc.ExecuteTask(context.Background(), TaskRunRequest{
|
||||
TaskKey: "intake",
|
||||
TaskKey: "adhoc_notes",
|
||||
ProjectID: 0,
|
||||
DocID: "",
|
||||
AutoRepair: false,
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"chat-agent-system/internal/contracts/vocabulary"
|
||||
"chat-agent-system/internal/docduty"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/kb"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/projectcontext"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/squad"
|
||||
@@ -119,7 +119,32 @@ func (s *Service) hasCompletedMainInstance(ctx context.Context, projectID int64)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// ErrUnboundControlledProject 表示受控目录任务没有项目绑定,不能静默落到隔离工作区。
|
||||
var ErrUnboundControlledProject = errors.New("会话未绑定项目,受控交付物无法入账")
|
||||
|
||||
// controlledDeliverableRequiresProject 覆盖发布目录里的受控任务(旧键与新键)和研发设计控制 rd_*。
|
||||
// 未知自由任务两边都原样返回,不在此列。
|
||||
func controlledDeliverableRequiresProject(taskKey string) bool {
|
||||
key := strings.TrimSpace(taskKey)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(key, "rd_") {
|
||||
return true
|
||||
}
|
||||
return workflowdef.CanonicalTaskKey(key) != key || workflowdef.ImplementationTaskKey(key) != key
|
||||
}
|
||||
|
||||
func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||||
if controlledDeliverableRequiresProject(req.TaskKey) && req.ProjectID <= 0 {
|
||||
return &TaskRunResult{
|
||||
TaskKey: req.TaskKey,
|
||||
ArtifactTitle: squad.TaskArtifactTitle(req.TaskKey),
|
||||
Success: false,
|
||||
ErrorReason: ErrUnboundControlledProject.Error(),
|
||||
}, ErrUnboundControlledProject
|
||||
}
|
||||
|
||||
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); err != nil {
|
||||
return &TaskRunResult{
|
||||
TaskKey: req.TaskKey,
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestExecuteTaskIndependent(t *testing.T) {
|
||||
// 测试 1: 单独执行 intake (不依赖任何前序)
|
||||
intakeRes, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{
|
||||
TaskKey: "intake",
|
||||
ProjectID: 1,
|
||||
ProjectName: "微创吻合器研发",
|
||||
CustomerName: "北京天工",
|
||||
DeviceType: "腔镜微创器械",
|
||||
@@ -58,6 +59,7 @@ func TestExecuteTaskIndependent(t *testing.T) {
|
||||
// 测试 2: 单独执行 cdo_quote (直接输出 Excel 附件)
|
||||
quoteRes, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{
|
||||
TaskKey: "cdo_quote",
|
||||
ProjectID: 1,
|
||||
ProjectName: "微创吻合器研发",
|
||||
CustomerName: "北京天工",
|
||||
})
|
||||
@@ -118,6 +120,7 @@ func TestExecuteTaskWritesMaterials(t *testing.T) {
|
||||
materialContent := []byte("客户提供的补充需求说明")
|
||||
res, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{
|
||||
TaskKey: "intake",
|
||||
ProjectID: 1,
|
||||
ProjectName: "测试项目",
|
||||
CustomerName: "测试客户",
|
||||
WorkDir: workDir,
|
||||
|
||||
@@ -91,6 +91,9 @@ func (r *TaskRunResult) CompletionSummary() string {
|
||||
if r.QualityReport != nil && r.QualityReport.Passed {
|
||||
fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score)
|
||||
}
|
||||
if r.DocRecord == nil && controlledDeliverableRequiresProject(r.TaskKey) {
|
||||
b.WriteString("\n交付物已生成于工作区,尚未写入受控文档账本。")
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(&b, "《%s》未成功落盘。\n\n", title)
|
||||
if r.ErrorReason != "" {
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestTaskRunResult_ProposalIntercept_DistinctFromPendingAgent(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskRunResult_CompletedWithoutLedger_SaysNotProjected(t *testing.T) {
|
||||
res := &bizdev.TaskRunResult{
|
||||
Success: true,
|
||||
ArtifactTitle: "调研报告",
|
||||
TaskKey: "research",
|
||||
}
|
||||
sum := res.CompletionSummary()
|
||||
if !strings.Contains(sum, "尚未写入受控文档账本") {
|
||||
t.Fatalf("summary=%s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskRunResult_CompletedOnlyWhenCommitted(t *testing.T) {
|
||||
res := &bizdev.TaskRunResult{
|
||||
Success: true,
|
||||
|
||||
@@ -29,3 +29,13 @@ func MustNewService(t testing.TB, cfg *config.Config) *Service {
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// ClearDocumentGovernanceForTest 去掉账本,让阶段编排测试只覆盖小队产出。
|
||||
// 受控任务仍须带 ProjectID,否则 ExecuteTask 会拒绝未绑定项目。
|
||||
func ClearDocumentGovernanceForTest(s *Service) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.docGov = nil
|
||||
s.docRev = nil
|
||||
}
|
||||
|
||||
@@ -87,10 +87,12 @@ func TestExecuteStageV01AndV05(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := bizdev.MustNewService(t, cfg)
|
||||
bizdev.ClearDocumentGovernanceForTest(svc)
|
||||
|
||||
// 1. 运行 Stage 1 (V0.1 Triage)
|
||||
stage1Res, err := svc.ExecuteStage(context.Background(), bizdev.StageRunRequest{
|
||||
Stage: bizdev.StageV01_Triage,
|
||||
ProjectID: 1,
|
||||
ProjectName: "内窥镜手术动力系统",
|
||||
CustomerName: "北京天衡医疗",
|
||||
DeviceType: "有源手术动力装置",
|
||||
@@ -128,6 +130,7 @@ func TestExecuteStageV01AndV05(t *testing.T) {
|
||||
|
||||
stage2Res, err := svc.ExecuteStage(context.Background(), bizdev.StageRunRequest{
|
||||
Stage: bizdev.StageV05_Feasibility,
|
||||
ProjectID: 1,
|
||||
ProjectName: "内窥镜手术动力系统",
|
||||
CustomerName: "北京天衡医疗",
|
||||
DeviceType: "有源手术动力装置",
|
||||
@@ -163,6 +166,7 @@ func TestExecuteStageV01AndV05(t *testing.T) {
|
||||
|
||||
stage3Res, err := svc.ExecuteStage(context.Background(), bizdev.StageRunRequest{
|
||||
Stage: bizdev.StageV10_Quoting,
|
||||
ProjectID: 1,
|
||||
ProjectName: "内窥镜手术动力系统",
|
||||
CustomerName: "北京天衡医疗",
|
||||
DeviceType: "有源手术动力装置",
|
||||
|
||||
@@ -97,6 +97,7 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
|
||||
IntendedUse: userCtx.Project.IntendedUse,
|
||||
TargetMarket: userCtx.Project.TargetMarket,
|
||||
Fields: userCtx.BlackboardFacts,
|
||||
SessionID: userCtx.ChatSessionID,
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
// 会话触发与特设任务一致:质量门禁失败时给修复预算。
|
||||
@@ -128,6 +129,16 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
if taskRes != nil && strings.Contains(taskRes.ErrorReason, "会话未绑定项目") {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
RejectReason: taskRes.ErrorReason,
|
||||
NaturalReply: taskRes.ErrorReason,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) {
|
||||
msg := userFacingPrerequisite(err)
|
||||
if taskRes != nil && strings.Contains(taskRes.ErrorReason, "请先完成商务对接流程") {
|
||||
@@ -197,8 +208,10 @@ func formatTriggerTaskReply(taskRes *stageflow.TaskRunResult, outPath string) (r
|
||||
sb.WriteString(fmt.Sprintf("已为您自适应规划并完成生成【%s】!\n", taskRes.ArtifactTitle))
|
||||
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
|
||||
sb.WriteString(fmt.Sprintf("交付物已写入受控文档:%s\n", taskRes.DocRecord.DocID))
|
||||
sb.WriteString("正文已保存。\n")
|
||||
} else {
|
||||
sb.WriteString("交付物已生成于工作区,尚未写入受控文档账本。\n")
|
||||
}
|
||||
sb.WriteString("正文已保存。\n")
|
||||
sb.WriteString(fmt.Sprintf("耗时: %v\n", taskRes.Duration))
|
||||
if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 {
|
||||
sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n")
|
||||
@@ -278,6 +291,7 @@ func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *in
|
||||
Fields: userCtx.BlackboardFacts,
|
||||
Materials: []stageflow.MaterialInput{briefMaterial},
|
||||
AutoRepair: true,
|
||||
SessionID: userCtx.ChatSessionID,
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
@@ -149,8 +149,9 @@ func TestSyncTriggerTask_EnablesAutoRepair(t *testing.T) {
|
||||
}}
|
||||
eng := NewCoordinatorEngine(stub)
|
||||
userCtx := &UserProjectContext{
|
||||
User: UserInfo{ID: "1", ProjectRole: "author"},
|
||||
Project: ProjectInfo{ID: "9", Name: "吞咽电刺激", CurrentStage: maturity.StageV01_Triage},
|
||||
ChatSessionID: "sess-research",
|
||||
User: UserInfo{ID: "1", ProjectRole: "author"},
|
||||
Project: ProjectInfo{ID: "9", Name: "吞咽电刺激", CurrentStage: maturity.StageV01_Triage},
|
||||
}
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentTriggerTask,
|
||||
@@ -169,6 +170,29 @@ func TestSyncTriggerTask_EnablesAutoRepair(t *testing.T) {
|
||||
if stub.req.TaskKey != "research" {
|
||||
t.Fatalf("task key=%q", stub.req.TaskKey)
|
||||
}
|
||||
if stub.req.ProjectID != 9 || stub.req.SessionID != "sess-research" {
|
||||
t.Fatalf("project/session=%d %q", stub.req.ProjectID, stub.req.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTriggerTaskReply_WithoutDocRecordDoesNotClaimLedger(t *testing.T) {
|
||||
ok := &stageflow.TaskRunResult{
|
||||
Success: true,
|
||||
TaskKey: "research",
|
||||
ArtifactTitle: "调研报告",
|
||||
WorkDir: t.TempDir(),
|
||||
Duration: time.Millisecond,
|
||||
}
|
||||
reply, status := formatTriggerTaskReply(ok, ok.WorkDir+"/output.md")
|
||||
if status != stageflow.TaskStatusCompleted {
|
||||
t.Fatalf("status=%q", status)
|
||||
}
|
||||
if strings.Contains(reply, "正文已保存") || strings.Contains(reply, "已写入受控文档") {
|
||||
t.Fatalf("reply=%s", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "尚未写入受控文档账本") {
|
||||
t.Fatalf("reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTriggerTaskReply_PendingUsesCompletionSummary(t *testing.T) {
|
||||
@@ -216,6 +240,39 @@ func TestFormatTriggerTaskReply_QualityFailedOmitsHostPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTriggerTask_UnboundProjectIsUserVisible(t *testing.T) {
|
||||
reason := "会话未绑定项目,受控交付物无法入账"
|
||||
eng := NewCoordinatorEngine(&errReplyBizdev{
|
||||
res: &stageflow.TaskRunResult{Success: false, TaskKey: "research", ErrorReason: reason},
|
||||
err: fmt.Errorf("%s", reason),
|
||||
})
|
||||
userCtx := &UserProjectContext{
|
||||
User: UserInfo{ID: "4", ProjectRole: "author"},
|
||||
Project: ProjectInfo{Name: "吞咽电刺激", CurrentStage: maturity.StageV05_Feasibility},
|
||||
}
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentTriggerTask,
|
||||
FilledSlots: map[string]string{intent.SlotTargetTask: "research"},
|
||||
}
|
||||
res, err := eng.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{
|
||||
Branch: intent.BranchNewIntentPreempt,
|
||||
TopIntent: cand,
|
||||
RawInput: "编写调研报告",
|
||||
}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != StatusRejected {
|
||||
t.Fatalf("status=%s", res.Status)
|
||||
}
|
||||
if res.NaturalReply != reason {
|
||||
t.Fatalf("reply=%s", res.NaturalReply)
|
||||
}
|
||||
if strings.Contains(res.NaturalReply, "正文已保存") || strings.Contains(res.NaturalReply, "生成完成") {
|
||||
t.Fatalf("reply=%s", res.NaturalReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTriggerTask_PrerequisiteIsUserVisible(t *testing.T) {
|
||||
preErr := fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程")
|
||||
eng := NewCoordinatorEngine(&errReplyBizdev{
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestE2E_TasksRunStreamPipeline(t *testing.T) {
|
||||
|
||||
reqBody := TaskRunRequestDTO{
|
||||
TaskKey: "intake",
|
||||
ProjectID: 1,
|
||||
ProjectName: "超声内镜系统",
|
||||
CustomerName: "北京水木东方",
|
||||
DeviceType: "超声主机",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/chat"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
)
|
||||
|
||||
func TestBindSessionProjectContext_FillsEmptyProjectID(t *testing.T) {
|
||||
sess := chat.NewSession("sess-p6024-u4", &coordinator.UserProjectContext{
|
||||
Project: coordinator.ProjectInfo{Name: "吞咽电刺激"},
|
||||
}, nil, nil)
|
||||
sess.SetProjectID(6024)
|
||||
bindSessionProjectContext(sess)
|
||||
if sess.UserCtx.Project.ID != "6024" {
|
||||
t.Fatalf("project id=%q", sess.UserCtx.Project.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSessionProjectContext_KeepsExistingProjectID(t *testing.T) {
|
||||
sess := chat.NewSession("sess-keep", &coordinator.UserProjectContext{
|
||||
Project: coordinator.ProjectInfo{ID: "9", Name: "已绑定"},
|
||||
}, nil, nil)
|
||||
sess.SetProjectID(6024)
|
||||
bindSessionProjectContext(sess)
|
||||
if sess.UserCtx.Project.ID != "9" {
|
||||
t.Fatalf("project id=%q", sess.UserCtx.Project.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSessionProjectContext_SkipsUnboundSession(t *testing.T) {
|
||||
sess := chat.NewSession("sess-none", &coordinator.UserProjectContext{
|
||||
Project: coordinator.ProjectInfo{Name: "未选项目"},
|
||||
}, nil, nil)
|
||||
sess.SetProjectID(0)
|
||||
bindSessionProjectContext(sess)
|
||||
if sess.UserCtx.Project.ID != "" {
|
||||
t.Fatalf("project id=%q", sess.UserCtx.Project.ID)
|
||||
}
|
||||
}
|
||||
@@ -155,10 +155,23 @@ func (s *Server) allowActiveStageChatAccess(ctx context.Context, projectID, user
|
||||
return access.StageKeyAllowed(access.ActiveStageKey)
|
||||
}
|
||||
|
||||
// bindSessionProjectContext 把已授权的会话项目写回协调器上下文。
|
||||
// 聊天入口只调用 SetProjectID 做访问校验;协调器读取的是 UserCtx.Project.ID。
|
||||
// 只填空值,不覆盖已经明确的项目绑定。
|
||||
func bindSessionProjectContext(sess *chat.Session) {
|
||||
if sess == nil || sess.UserCtx == nil || sess.Project() <= 0 {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(sess.UserCtx.Project.ID) == "" {
|
||||
sess.UserCtx.Project.ID = strconv.FormatInt(sess.Project(), 10)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Session, userID int64) {
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
bindSessionProjectContext(sess)
|
||||
if sess.Project() > 0 && s.instSvc() != nil && s.instSvc().Available() {
|
||||
settings, err := s.instSvc().GetSettings(ctx, sess.Project(), userID, false)
|
||||
if err == nil && settings != nil {
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestTaskRun_DisconnectDoesNotCancelWorker(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
runID := "run_disconnect_test"
|
||||
record := &taskrun.RunRecord{RunID: runID, TaskKey: "intake", Status: "running"}
|
||||
taskReq := bizdev.TaskRunRequest{TaskKey: "intake", ProjectName: "disconnect-test"}
|
||||
record := &taskrun.RunRecord{RunID: runID, TaskKey: "intake", ProjectID: 1, Status: "running"}
|
||||
taskReq := bizdev.TaskRunRequest{TaskKey: "intake", ProjectID: 1, ProjectName: "disconnect-test"}
|
||||
_, err := srv.taskRuns().CreateAndStart(context.Background(), runID, record, taskReq, bizdev.StageRunRequest{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAndStart: %v", err)
|
||||
|
||||
@@ -81,6 +81,7 @@ func TestTaskStream_TransientInjection_DeadPiAgent(t *testing.T) {
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"task_key": "intake",
|
||||
"project_id": 1,
|
||||
"project_name": "超声内镜系统",
|
||||
"customer": "北京水木东方",
|
||||
"backend": "pi-agent",
|
||||
@@ -148,6 +149,7 @@ func TestTaskStream_TypedReject_ClassifiedFatal(t *testing.T) {
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"task_key": "intake",
|
||||
"project_id": 1,
|
||||
"project_name": "超声内镜系统",
|
||||
"customer": "北京水木东方",
|
||||
"backend": "pi-agent",
|
||||
@@ -207,6 +209,7 @@ func TestTaskStream_MaterialsInjection(t *testing.T) {
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"task_key": "intake",
|
||||
"project_id": 1,
|
||||
"project_name": "超声材料注入测试",
|
||||
"customer_name": "北京水木东方",
|
||||
"device_type": "超声诊断设备",
|
||||
|
||||
Reference in New Issue
Block a user