merge: candidate controlled-projection-project-binding-2026-09-28 into dev

This commit is contained in:
weichen
2026-09-28 17:43:41 +00:00
18 changed files with 384 additions and 18 deletions
+1
View File
@@ -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` 已有带绑定重跑入账,无额外数据修复。
+7 -10
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
+27 -2
View File
@@ -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,
+3
View File
@@ -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,
+3
View File
@@ -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,
+10
View File
@@ -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
}
+4
View File
@@ -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: "有源手术动力装置",
+15 -1
View File
@@ -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{
+1
View File
@@ -134,6 +134,7 @@ func TestE2E_TasksRunStreamPipeline(t *testing.T) {
reqBody := TaskRunRequestDTO{
TaskKey: "intake",
ProjectID: 1,
ProjectName: "超声内镜系统",
CustomerName: "北京水木东方",
DeviceType: "超声主机",
+41
View File
@@ -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)
}
}
+13
View File
@@ -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 {
+2 -2
View File
@@ -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)
+3
View File
@@ -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": "超声诊断设备",