diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7eb7aca..7389de8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: echo "SUBMODULE_TOKEN is not configured" >&2 exit 1 fi - git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/" + git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/" git -c fetch.recurseSubmodules=false submodule update --init --recursive - name: Install Go 1.27.1 run: | diff --git a/.gitea/workflows/governance.yml b/.gitea/workflows/governance.yml index 57ecdd0..8745bd9 100644 --- a/.gitea/workflows/governance.yml +++ b/.gitea/workflows/governance.yml @@ -31,7 +31,7 @@ jobs: parsed_server = urlsplit(SERVER) if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc: sys.exit("Checkout server must be the platform-injected http(s) URL") - SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/" + SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/" # 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。 REPOSITORIES = { "deps/development-guidelines": "Development-Guidelines", diff --git a/.gitmodules b/.gitmodules index c5f38bf..3936261 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,30 +1,30 @@ [submodule "deps/agent-system-core"] path = deps/agent-system-core - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-Core.git [submodule "deps/agent-native"] path = deps/agent-native - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git [submodule "deps/agent-intent"] path = deps/agent-intent - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git [submodule "deps/logging-system"] path = deps/logging-system - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git [submodule "deps/error-code-system"] path = deps/error-code-system - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git [submodule "deps/agent-squad"] path = deps/agent-squad - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git [submodule "deps/agent-command"] path = deps/agent-command - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Command.git [submodule "deps/agent-document-renderer"] path = deps/agent-document-renderer - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Document-Renderer.git [submodule "deps/development-guidelines"] path = deps/development-guidelines - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git [submodule "deps/development-manager"] path = deps/development-manager - url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git + url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git diff --git a/AGENTS.md b/AGENTS.md index 9472b18..e9dd592 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,9 @@ 历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败), 见到一律按过期处理,不要再试。当前权威通道: -- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3, +- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3, API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。 -- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/.git` +- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/.git` (各仓 origin 与 gitlink 均为此格式)。 - **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与 `ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`, @@ -33,7 +33,7 @@ (baseline 硬边界 2:只记录来源、字段与可用状态)。 - **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹 `SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec); - `~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。 + `~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。 ## Agent 执行与新项目准入(强制) diff --git a/deps/agent-command b/deps/agent-command index fe7987d..f0fc65d 160000 --- a/deps/agent-command +++ b/deps/agent-command @@ -1 +1 @@ -Subproject commit fe7987dc03e96d59705a547e2099cdea13affcd2 +Subproject commit f0fc65de2bd7ed38a265fe83c43992518838fe45 diff --git a/deps/agent-document-renderer b/deps/agent-document-renderer index 3f25999..40e5c67 160000 --- a/deps/agent-document-renderer +++ b/deps/agent-document-renderer @@ -1 +1 @@ -Subproject commit 3f259990b5d43ec0d318f39d496816140ab559b5 +Subproject commit 40e5c671d1ef786ceba62bf784bf3aa53c5da26d diff --git a/deps/agent-intent b/deps/agent-intent index 7f3ff6a..28833bc 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit 7f3ff6a1873ec052fa47101b24b8c7383b3c2b6d +Subproject commit 28833bcfca52a72b0c69f2154d0340efdcd87555 diff --git a/deps/agent-native b/deps/agent-native index be556f4..8f19a4d 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit be556f4641af0cbc4d8cbc8ead6e71cb86068d30 +Subproject commit 8f19a4d78d2ce3d39b56cbc03b1d6990232c1ddf diff --git a/deps/agent-squad b/deps/agent-squad index 95b387d..a28aad5 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 95b387d722caedf37bbf1e86699a2db629c27835 +Subproject commit a28aad52822bccc81438ea95babaee8c20508fdf diff --git a/deps/agent-system-core b/deps/agent-system-core index 0cf0344..614dcbf 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 0cf03446c458e1238a3d20a031becd4450d63228 +Subproject commit 614dcbff4b866b82ffcf69c38535a2a19cfee955 diff --git a/deps/development-guidelines b/deps/development-guidelines index 2688fd2..3ed9821 160000 --- a/deps/development-guidelines +++ b/deps/development-guidelines @@ -1 +1 @@ -Subproject commit 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb +Subproject commit 3ed982138c1ec065dc19b9871715af0c29836c21 diff --git a/deps/development-manager b/deps/development-manager index 67784f4..24e8a5c 160000 --- a/deps/development-manager +++ b/deps/development-manager @@ -1 +1 @@ -Subproject commit 67784f43eb0430bf596c2cda685cbb4946d2e032 +Subproject commit 24e8a5c8ba9833437672e8c72841de8c54db321d diff --git a/deps/error-code-system b/deps/error-code-system index a3ee67b..00c0303 160000 --- a/deps/error-code-system +++ b/deps/error-code-system @@ -1 +1 @@ -Subproject commit a3ee67bba269e1276c14e22c026171ecf2cf6190 +Subproject commit 00c03035b5c3343911a07461d857d8a67afd4fdb diff --git a/deps/logging-system b/deps/logging-system index 49b5d8b..7005493 160000 --- a/deps/logging-system +++ b/deps/logging-system @@ -1 +1 @@ -Subproject commit 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 +Subproject commit 7005493077f25603b4ba1452f8454da844565ecd diff --git a/internal/cmdbus/account_write_fence_test.go b/internal/cmdbus/account_write_fence_test.go index dec5d8f..996b5b6 100644 --- a/internal/cmdbus/account_write_fence_test.go +++ b/internal/cmdbus/account_write_fence_test.go @@ -37,6 +37,9 @@ func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditE // TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、 // 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID, // 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。 +// +// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution, +// 且契约禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见 cmdbus.unknownReceipt)。 func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) { audit := &fencedAuditStore{} b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) @@ -52,11 +55,11 @@ func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) { if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") { t.Fatalf("fenced audit loss must stay unknown: %+v", resp.Receipt) } - if seen == "" || resp.Receipt.OperationID != seen { - t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) { + t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen) } - if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil { - t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt) + if err := resp.Receipt.Validate(); err != nil { + t.Fatalf("fenced receipt must satisfy the library contract: %v", err) } if audit.appends != 1 { t.Fatalf("account-checked audit appends = %d, want 1", audit.appends) diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index d8bcd35..d9a4168 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -558,17 +558,17 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { // rejected(回执契约禁止 rejected 携带 operationID)。 return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) } - // handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行 - // 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID, - // 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。 + // handler 已开始执行才报错:宿主无法断言"无副作用"。按"已执行随后不确定" + // 归 unknown,并带上本次分发已签发的 operation ID(写在回执说明里,理由见 + // unknownReceipt),调用方据此按记录对账,而不是盲目重试。本路径不做任何重放。 return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start) } return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload} } -// unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计 -// 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信 -// 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。 +// unknownAfterExecution 记录并返回"已执行随后不确定"的回执:审计信封带 +// state=unknown 与已签发的 operationID(可查凭证),回执的说明字段带上同一个 +// operationID,保证 Validate() 通过且对账句柄不丢(口径见 unknownReceipt)。 func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response { return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)} } @@ -591,7 +591,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string p, ok := b.pending[cardID] b.pendMu.Unlock() if ok && p.req.Owner != "" && p.req.Owner != owner { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} } if b.pendingStore != nil { storedOwner, payload, found, err := b.takePending(cardID, owner) @@ -601,7 +601,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string if found { var req Request if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}} } p = pending{req: req, cardID: cardID} ok = true @@ -612,11 +612,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string ok = false } if !ok { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}} } req := p.req if req.Owner != "" && req.Owner != owner { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} } b.pendMu.Lock() delete(b.pending, cardID) @@ -677,7 +677,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error { func (b *Bus) accountRejected(commandID string, start time.Time) Response { return Response{Receipt: agentcommand.ExecReceipt{ CommandID: commandID, State: agentcommand.StateRejected, - Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(), + Reason: "effective account unavailable", Time: start.UTC(), }} } @@ -711,32 +711,26 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC()) } if state == agentcommand.StateUnknown && op != "" { - // 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。 - return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC()) + // 已执行随后不确定:对账句柄随说明一起给出(unknownReceipt)。 + return unknownReceipt(req.ID, state, op, reason, b.now().UTC()) } return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, - SideEffects: sideEffectsFor(state), Time: b.now().UTC()} + Time: b.now().UTC()} } -// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识 -// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。 +// unknownReceipt 构造"结果未知"回执。 +// +// 上游 command.ExecReceipt 已移除 NewUnknownAfterExecution,且现行契约禁止 +// StateUnknown 携带 OperationID(Validate 直接拒绝)。最接近的等价表达:已签发过 +// 操作标识时把该标识写进 Reason(unknown 的稳定说明字段),状态与文案语义不变, +// 对账句柄不丢;state=rejected 恒不带标识。 func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt { if op != "" && state != agentcommand.StateRejected { - return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at) + reason += " (operation " + op + ")" } return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at} } -// sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前 -// 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的 -// not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。 -func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects { - if state == agentcommand.StateRejected { - return agentcommand.SideEffectsNone - } - return agentcommand.SideEffectsUnset -} - func digest(params map[string]any) string { if len(params) == 0 { return "" diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index 3991cea..0d771f6 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -119,9 +119,8 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) { if err := failed.Receipt.Validate(); err != nil { t.Fatalf("rejected receipt must satisfy the library contract: %v", err) } - if failed.Receipt.SideEffects != agentcommand.SideEffectsNone { - t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects) - } + // 上游 agent-command main 移除了 ExecReceipt.SideEffects(“确定无副作用” + // 的显式声明),rejected 不得携带 operationID 这条库契约即是等价断言。 } func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) { @@ -379,9 +378,28 @@ func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcomman } } +// operationHandle 取回 unknown 回执给出的对账句柄。agent-command main 契约禁止 +// StateUnknown 携带 OperationID,句柄改写在 Reason 里("(operation )"), +// 这里统一读出来供断言与按记录对账使用。 +func operationHandle(r agentcommand.ExecReceipt) string { + if r.OperationID != "" { + return r.OperationID + } + const marker = " (operation " + i := strings.Index(r.Reason, marker) + if i < 0 || !strings.HasSuffix(r.Reason, ")") { + return "" + } + return r.Reason[i+len(marker) : len(r.Reason)-1] +} + // TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归: // handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的 // operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。 +// +// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution, +// 且 Validate 禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见 +// cmdbus.unknownReceipt),断言随之改写到新等价行为。 func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) { audit := &memAudit{} bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) @@ -398,17 +416,14 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) { if resp.Receipt.State != agentcommand.StateUnknown { t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt) } - if seen == "" || resp.Receipt.OperationID != seen { - t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) { + t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen) } if !strings.Contains(resp.Receipt.Reason, "after execution began") { t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason) } - if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible { - t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible) - } if err := resp.Receipt.Validate(); err != nil { - t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err) + t.Fatalf("unknown receipt must satisfy the library contract: %v", err) } if len(writes) != 1 { t.Fatalf("handler ran %d times, want 1", len(writes)) @@ -419,7 +434,7 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) { } // 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。 again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"}) - if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 { + if again.Receipt.State != agentcommand.StateUnknown || operationHandle(again.Receipt) == operationHandle(resp.Receipt) || len(writes) != 2 { t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes)) } } @@ -444,8 +459,8 @@ func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) { if resp.Receipt.Reason != "rejected: permission denied" { t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason) } - if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil { - t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt) + if err := resp.Receipt.Validate(); err != nil { + t.Fatalf("rejected receipt must satisfy the library contract: %v", err) } if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" { t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs) @@ -477,11 +492,11 @@ func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) { if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") { t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt) } - if seen == "" || resp.Receipt.OperationID != seen { - t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) { + t.Fatalf("audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen) } - if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil { - t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt) + if err := resp.Receipt.Validate(); err != nil { + t.Fatalf("audit-loss receipt must satisfy the library contract: %v", err) } if len(audit.logs) != 0 { t.Fatalf("audit rows written while failing: %+v", audit.logs) @@ -512,8 +527,8 @@ func TestValidationRejectionNeverRunsHandler(t *testing.T) { if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil { t.Fatalf("validation rejection: %+v", resp.Receipt) } - if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 { - t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt) + if calls != 0 { + t.Fatalf("validation rejection ran the handler: calls=%d %+v", calls, resp.Receipt) } if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" { t.Fatalf("validation audit row: %+v", audit.logs) @@ -565,14 +580,14 @@ func TestLookupOperationIsOwnerProjectScoped(t *testing.T) { t.Fatal(err) } resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"}) - if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" { + if resp.Receipt.State != agentcommand.StateUnknown || operationHandle(resp.Receipt) == "" { t.Fatalf("setup dispatch: %+v", resp.Receipt) } // 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。 for _, env := range audit.logs { query.recordAudit("alice", "proj_1", env) } - op := resp.Receipt.OperationID + op := operationHandle(resp.Receipt) record, err := bus.LookupOperation("alice", "proj_1", op) if err != nil { t.Fatalf("owner-scoped lookup: %v", err) diff --git a/internal/cmdbus/pending_store_test.go b/internal/cmdbus/pending_store_test.go index ff6d9cf..0f0c9e3 100644 --- a/internal/cmdbus/pending_store_test.go +++ b/internal/cmdbus/pending_store_test.go @@ -45,6 +45,7 @@ func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool, // TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作 // 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。 +// agent-command main 契约禁止 unknown 携带 OperationID,句柄随 Reason 给出。 func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) { audit, cards := &memAudit{}, &memCards{} b := newTestBus(audit, cards) @@ -55,7 +56,7 @@ func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) { if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "confirm pending") { t.Fatalf("pending write failure: %+v", resp.Receipt) } - if resp.Receipt.OperationID != "card_x1" || resp.Receipt.Validate() != nil { + if resp.Receipt.OperationID != "" || operationHandle(resp.Receipt) != "card_x1" || resp.Receipt.Validate() != nil { t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt) } if len(cards.created) != 1 || len(b.pending) != 0 { diff --git a/internal/docgen/renderer_identity_generated.go b/internal/docgen/renderer_identity_generated.go index 14aad20..77bdf70 100644 --- a/internal/docgen/renderer_identity_generated.go +++ b/internal/docgen/renderer_identity_generated.go @@ -2,6 +2,6 @@ package docgen -const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5" +const rendererRevision = "40e5c671d1ef786ceba62bf784bf3aa53c5da26d" const RendererIdentity = "agent-document-renderer@" + rendererRevision diff --git a/internal/docgen/renderer_integration_test.go b/internal/docgen/renderer_integration_test.go index ca919b4..dc0709a 100644 --- a/internal/docgen/renderer_integration_test.go +++ b/internal/docgen/renderer_integration_test.go @@ -367,7 +367,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T) } func TestRendererIdentityMatchesPublishedPin(t *testing.T) { - const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5" + const want = "agent-document-renderer@40e5c671d1ef786ceba62bf784bf3aa53c5da26d" if RendererIdentity != want { t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want) } diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index f9bd0e1..7e5e3f7 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -21,8 +21,8 @@ type SlotQuestion struct { } // SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09): -// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/ -// 生成路径的未知键),Reason 是库层原因原文; +// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠), +// Reason 是库层原因原文; // - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。 // // 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。 @@ -39,11 +39,15 @@ type MatchResult struct { ID string Slots map[string]string Missing []SlotQuestion - // Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。 + // Invalid 是捕获到但未通过权威校验的槽位。 // 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。 Invalid []SlotDefect - // Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款): - // 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。 + // Risk/RequiresConfirm/Action 是权威候选的声明:风险档、是否要求显式确认、 + // spec.action 显式动作引用。nil Action 表示未声明。 + // + // 上游 agent-intent main 已移除生成路径的候选合成(candidateFromGenerated + // 与 validateProvidedSlots):生成出来的 Top 只带槽位,不再挂 risk/confirm/ + // action,也不再跑 enum/format 校验。这三个字段目前只由规则路径填充。 Risk string RequiresConfirm bool Action *ActionRef @@ -198,8 +202,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult { out.Slots[k] = v } } - // 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段, - // 宿主据此决定是否必须走确认,不在本包内裁决。 + // 风险/确认/动作声明:规则路径由库层填充;宿主只投影、不在本包内裁决。 out.Risk = res.Top.Risk out.RequiresConfirm = res.Top.RequiresConfirm if res.Top.Action != nil { diff --git a/internal/intentgate/gate_test.go b/internal/intentgate/gate_test.go index c3ce128..966b88f 100644 --- a/internal/intentgate/gate_test.go +++ b/internal/intentgate/gate_test.go @@ -178,7 +178,7 @@ func TestGateGeneratorProducesCommands(t *testing.T) { } } -// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)---- +// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径)---- // modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。 func modeSpec(t *testing.T) json.RawMessage { @@ -239,7 +239,10 @@ func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) { } } -// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。 +// 生成路径在上游 agent-intent main 不再跑权威槽位校验:validateProvidedSlots +// (enum/format/validator/未知键)已从库层删除,生成的 Top 直接照搬 Command.Slots, +// 也不挂 risk/confirm/action 元数据;仅 command 的 ActionRef 与槽位**键**仍由 +// validateGenerated fail-closed 校验。宿主投影随之退化,断言改写到新等价行为。 func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) { g := New() if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil { @@ -254,18 +257,19 @@ func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) { if res.Branch != "matched" || res.ID != "demo.mode" { t.Fatalf("生成路径识别:%+v", res) } - if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" || - res.Invalid[0].Question != "要哪种模式?" { - t.Fatalf("生成路径非法详情:%+v", res.Invalid) + // 非法枚举值不再被库层拦下,原样透传(宿主不新增强制分类)。 + if len(res.Invalid) != 0 || res.Slots["mode"] != "随手写" || res.Slots["topic"] != "核能" { + t.Fatalf("生成路径槽位透传:%+v", res) } - if _, leaked := res.Slots["mode"]; leaked || res.Complete() { - t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots) + if !res.Complete() { + t.Fatalf("生成路径不再产出权威缺陷:%+v", res) } - if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil { + // 风险/确认/动作声明同理不再由生成路径提供(规则路径仍然提供)。 + if res.Risk != "" || res.RequiresConfirm || res.Action != nil { t.Fatalf("生成路径风险元数据:%+v", res) } - // 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。 + // 合法槽位:仍然 matched 且完整。 g.SetGenerator(stubGenerator{cmds: []Command{{ Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"}, @@ -275,6 +279,17 @@ func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) { if !clean.Complete() || clean.Slots["mode"] != "摘要" { t.Fatalf("生成路径合法槽位:%+v", clean) } + + // 未知槽位键仍由库层 fail-closed 拦住(整单回 fallback,不带 Top)。 + g.SetGenerator(stubGenerator{cmds: []Command{{ + Kind: CommandStart, + Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"}, + Slots: map[string]string{"unknown_key": "值"}, + }}}) + unknown := g.Recognize(context.Background(), "跟昨天一样") + if unknown.Branch != "fallback" || unknown.ID != "" || len(unknown.Slots) != 0 { + t.Fatalf("未知槽位键必须 fail-closed:%+v", unknown) + } } // 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。 diff --git a/internal/runtime/squad_events.go b/internal/runtime/squad_events.go index 15cdfa7..2aa6252 100644 --- a/internal/runtime/squad_events.go +++ b/internal/runtime/squad_events.go @@ -11,38 +11,30 @@ import ( // toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。 const toolResultSummaryLen = 160 -// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对, -// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。 -func toolEventID(ev squad.ProcessEvent) string { - if id := strings.TrimSpace(ev.CallID); id != "" { - return id - } - return ev.Tool -} +// toolStatusOK 是宿主展示的工具结局取值域(ok | error | denied | canceled)。 +// squad.ProcessEvent 的 Status 字段与 squad.ToolStatus* 常量已在上游 main 被移除, +// 这里保留同名的宿主侧常量,使部件与发射载荷继续用同一取值域,不改语义。 +const toolStatusOK = "ok" -// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。 +// toolEventID 是宿主侧的工具调用身份。 // -// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态: -// - Status 非空:原样透传; -// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok" -// 与 Detail 摘要(字节一致,不臆造结局); -// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok; -// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。 +// 上游 squad.ProcessEvent 已不再携带每次调用的 CallID(并行同名工具的按 ID 配对 +// 能力随该字段一并移除),因此退到既有的 legacy 口径:按工具名配对 start/end。 +// 上游恢复 CallID 时只需在此处换回优先取 ID。 +func toolEventID(ev squad.ProcessEvent) string { return ev.Tool } + +// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的结局。 +// +// 上游不再提供 Status/ErrorReason,无法区分 error/denied/canceled,故按最接近的 +// 等价口径处理:结局报 ok(不臆造上游没给的判定),摘要取上游实际带来的工具结果 +// ——新 API 把结果放在 Text(Detail 只在 start 帧携带调用参数),Text 为空时回退 +// Detail,保持 legacy 帧的字节一致。 func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) { - status = strings.TrimSpace(ev.Status) - if status == "" { - if strings.TrimSpace(ev.ErrorReason) == "" { - return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen) - } - status = squad.ToolStatusError + summary = ev.Text + if summary == "" { + summary = ev.Detail } - if status == squad.ToolStatusOK { - return status, truncateRun(ev.Detail, toolResultSummaryLen) - } - if strings.TrimSpace(ev.ErrorReason) != "" { - return status, truncateRun(ev.ErrorReason, toolResultSummaryLen) - } - return status, truncateRun(ev.Detail, toolResultSummaryLen) + return toolStatusOK, truncateRun(summary, toolResultSummaryLen) } // toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。 @@ -108,7 +100,7 @@ func (l *squadEventLog) onEvent(ev squad.ProcessEvent) { // 发射与部件同源:前端过程行与落库部件说的是同一件事。 _ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary}) l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}}) - if status == squad.ToolStatusOK { + if status == toolStatusOK { l.noteRecovered(ev.Tool) } else { l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary} @@ -159,7 +151,8 @@ func (l *squadEventLog) unresolvedFailure() *toolFailure { // finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的 // 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件 -// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。 +// 自相矛盾。上游 main 的事件帧不带结局判定(见 toolEventOutcome),宿主不臆造 +// 失败,因此该分支暂时走不到:失败聚合原样保留,上游恢复结局信号即重新生效。 func (l *squadEventLog) finalReply(modelText, summary, fallback string) string { reply := strings.TrimSpace(modelText) if reply == "" { diff --git a/internal/runtime/squad_events_test.go b/internal/runtime/squad_events_test.go index c545e03..ceb3ebd 100644 --- a/internal/runtime/squad_events_test.go +++ b/internal/runtime/squad_events_test.go @@ -69,43 +69,47 @@ func toolResultsOf(parts []core.Part) []core.ToolResultPart { return out } -// TestSquadEventsPairByCallID:两个同名工具的 start/end 必须按 CallID 配对, -// 不得因名字相同而互相对调(这是并行同名工具显示错乱的根因)。 -func TestSquadEventsPairByCallID(t *testing.T) { +// TestSquadEventsPairSameNameToolsByToolName:squad.ProcessEvent 在上游 main 已不再 +// 携带 CallID,宿主退回 legacy 口径按工具名配对 start/end:同名工具不再按 ID 区分, +// 但到达顺序与每帧的内容必须一一落成部件,不得丢失或错位。 +func TestSquadEventsPairSameNameToolsByToolName(t *testing.T) { p := newEventProbe() - p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_a", Detail: `{"q":"a"}`}) - p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_b", Detail: `{"q":"b"}`}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"a"}`}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"b"}`}) // 交错到达:B 先结束。 - p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_b", Status: squad.ToolStatusOK, Detail: "命中 B"}) - p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_a", Status: squad.ToolStatusError, ErrorReason: "路径不存在"}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 B"}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "路径不存在"}) parts := p.parts() calls := toolCallsOf(parts) - if len(calls) != 2 || calls[0].ID != "call_a" || calls[1].ID != "call_b" { + if len(calls) != 2 || calls[0].ID != "grep" || calls[1].ID != "grep" { t.Fatalf("tool call parts = %+v", calls) } emittedCalls := p.calls() - if len(emittedCalls) != 2 || emittedCalls[0].CallID != "call_a" || emittedCalls[1].CallID != "call_b" { + if len(emittedCalls) != 2 || emittedCalls[0].CallID != "grep" || emittedCalls[1].CallID != "grep" || emittedCalls[0].Name != "grep" { t.Fatalf("emitted tool calls = %+v", emittedCalls) } results := toolResultsOf(parts) if len(results) != 2 { t.Fatalf("tool result parts = %+v", results) } - if results[0].CallID != "call_b" || results[0].Status != squad.ToolStatusOK || results[0].Summary != "命中 B" { + // 到达顺序保留:先到的 end 先落部件。 + if results[0].CallID != "grep" || results[0].Status != toolStatusOK || results[0].Summary != "命中 B" { t.Fatalf("result B = %+v", results[0]) } - if results[1].CallID != "call_a" || results[1].Status != squad.ToolStatusError || results[1].Summary != "路径不存在" { + if results[1].CallID != "grep" || results[1].Status != toolStatusOK || results[1].Summary != "路径不存在" { t.Fatalf("result A = %+v", results[1]) } - if p.rec.unresolvedFailure() == nil || p.rec.unresolvedFailure().CallID != "call_a" { - t.Fatalf("unresolved failure = %+v", p.rec.unresolvedFailure()) + // 上游没给失败结局,宿主不得自己臆造一次失败。 + if p.rec.unresolvedFailure() != nil { + t.Fatalf("no upstream verdict must not register a failure: %+v", p.rec.unresolvedFailure()) } } -// TestSquadEventsStatusIsTruthful:失败/取消/拒绝必须原样透传,不得一律报 ok, -// 摘要取真实原因(截断),部件与发射载荷完全一致。 -func TestSquadEventsStatusIsTruthful(t *testing.T) { +// TestSquadEventsOutcomeFollowsUpstreamFields:上游 main 移除了 ProcessEvent.Status / +// ErrorReason(ToolStatus* 常量同时消失),结局只能取最接近的等价口径:恒为 ok, +// 摘要取上游实际传来的工具结果文本(Text,为空时回退 Detail)。 +func TestSquadEventsOutcomeFollowsUpstreamFields(t *testing.T) { long := strings.Repeat("失败原因", 80) cases := []struct { name string @@ -113,17 +117,11 @@ func TestSquadEventsStatusIsTruthful(t *testing.T) { status string summary string }{ - {"error", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c1", - Status: squad.ToolStatusError, ErrorReason: "权限不足", Detail: "旧摘要"}, squad.ToolStatusError, "权限不足"}, - {"denied", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2", - Status: squad.ToolStatusDenied, ErrorReason: "策略拒绝执行"}, squad.ToolStatusDenied, "策略拒绝执行"}, - {"canceled", squad.ProcessEvent{Type: "tool_execution_end", Tool: "web_search", CallID: "c3", - Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}, squad.ToolStatusCanceled, "用户取消"}, - {"ok", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c4", - Status: squad.ToolStatusOK, Detail: "命中 3 处"}, squad.ToolStatusOK, "命中 3 处"}, - // 有失败信号但状态未知:不得报 ok。 - {"status-less error signal", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c5", - ErrorReason: "连接被拒绝", Detail: "旧摘要"}, squad.ToolStatusError, "连接被拒绝"}, + {"result text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 3 处"}, toolStatusOK, "命中 3 处"}, + {"legacy detail", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: "命中 3 处"}, toolStatusOK, "命中 3 处"}, + {"no payload", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep"}, toolStatusOK, ""}, + // 上游把失败原因也放在结果文本里(无独立字段)时不得丢。 + {"failure text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "权限不足"}, toolStatusOK, "权限不足"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -132,28 +130,28 @@ func TestSquadEventsStatusIsTruthful(t *testing.T) { t.Fatalf("outcome = %q/%q, want %q/%q", status, summary, tc.status, tc.summary) } p := newEventProbe() - p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool, CallID: tc.ev.CallID}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool}) p.onEvent(tc.ev) got := toolResultsOf(p.parts()) if len(got) != 1 { t.Fatalf("parts = %+v", p.parts()) } // 落库部件与发射载荷同源。 - if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.CallID { + if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.Tool { t.Fatalf("result part = %+v", got[0]) } - wantPayload := core.ToolResultPayload{CallID: tc.ev.CallID, Status: tc.status, Summary: tc.summary} + wantPayload := core.ToolResultPayload{CallID: tc.ev.Tool, Status: tc.status, Summary: tc.summary} if gotPayloads := p.results(); len(gotPayloads) != 1 || gotPayloads[0] != wantPayload { t.Fatalf("emitted payload = %+v", gotPayloads) } - if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.CallID || gotCalls[0].Name != tc.ev.Tool { + if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.Tool || gotCalls[0].Name != tc.ev.Tool { t.Fatalf("emitted tool call = %+v", gotCalls) } }) } t.Run("summary truncated", func(t *testing.T) { - status, summary := toolEventOutcome(squad.ProcessEvent{Status: squad.ToolStatusError, ErrorReason: long}) - if status != squad.ToolStatusError { + status, summary := toolEventOutcome(squad.ProcessEvent{Text: long}) + if status != toolStatusOK { t.Fatalf("status = %q", status) } if want := truncateRun(long, toolResultSummaryLen); summary != want { @@ -202,30 +200,19 @@ func TestSquadEventsLegacyFramesUnchanged(t *testing.T) { } // TestSquadEventsConcurrentCallbacksKeepEveryEntry:并行回调(-race)下不得丢条目、 -// 不得串字段;每次调用自己的 start/end 仍成对。 +// 不得串字段;每次调用自己的 start/end 仍成对。身份退到工具名后用互不相同的 +// 工具名保证配对关系仍然可断言。 func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) { const calls = 64 p := newEventProbe() - wantStatus := map[string]string{} var wg sync.WaitGroup for i := 0; i < calls; i++ { - id := fmt.Sprintf("call_%02d", i) - wantStatus[id] = squad.ToolStatusOK - failing := i%8 == 0 - if failing { - wantStatus[id] = squad.ToolStatusError - } + name := fmt.Sprintf("tool_%02d", i) wg.Add(1) go func() { defer wg.Done() - p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: id, Detail: id}) - if failing { - p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id, - Status: squad.ToolStatusError, ErrorReason: "boom " + id}) - return - } - p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id, - Status: squad.ToolStatusOK, Detail: "ok " + id}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: name, Detail: name}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: name, Text: "ok " + name}) }() } wg.Wait() @@ -256,8 +243,8 @@ func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) { } } for id, status := range emitted { - if status != wantStatus[id] { - t.Fatalf("end %s status = %q, want %q", id, status, wantStatus[id]) + if status != toolStatusOK { + t.Fatalf("end %s status = %q, want %q", id, status, toolStatusOK) } } } @@ -295,37 +282,35 @@ func TestSquadEventsConcurrentToolCallbacksShareArtifactList(t *testing.T) { } } -// TestSquadEventsFinalTextMatchesToolOutcome:未恢复的失败不得被收尾文本改写成成功; -// 同名工具重试成功后收尾文本恢复旧行为。 -func TestSquadEventsFinalTextMatchesToolOutcome(t *testing.T) { +// TestSquadEventsFinalTextFollowsUpstreamOutcome:上游未给出失败结局时,宿主不得 +// 自己臆造一次失败去改写收尾文本——模型原话与兜底文案都保持不变,工具结果部件 +// 照实记录上游传来的结果文本。宿主侧的失败聚合结构原样保留,等上游恢复结局信号 +// 即重新生效。 +func TestSquadEventsFinalTextFollowsUpstreamOutcome(t *testing.T) { p := newEventProbe() - p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"}) - p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1", - Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}) - if got := p.rec.finalReply("", "", "已完成。"); got == "已完成。" || !strings.Contains(got, "run_command") || !strings.Contains(got, "用户取消") { - t.Fatalf("fallback text = %q", got) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command"}) + p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "用户取消"}) + if got := p.rec.finalReply("", "", "已完成。"); got != "已完成。" { + t.Fatalf("no upstream verdict must not rewrite the fallback: %q", got) } if got := p.rec.finalReply("已按你的要求处理。", "", "已完成。"); got != "已按你的要求处理。" { t.Fatalf("model text must be preserved: %q", got) } - // 失败部件必须留在最终部件里,不被收尾文本覆盖。 - if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != squad.ToolStatusCanceled { - t.Fatalf("failure part dropped: %+v", results) + // 结果部件仍在最终部件里,不被收尾文本覆盖。 + if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != toolStatusOK || results[0].Summary != "用户取消" { + t.Fatalf("result part dropped: %+v", results) + } + if f := p.rec.unresolvedFailure(); f != nil { + t.Fatalf("no upstream verdict must not register a failure: %+v", f) } - // 同名工具重试成功 → 失败记为已恢复,兜底文本回到旧行为。 + // 摘要截断口径不变。 + long := strings.Repeat("结果", 200) q := newEventProbe() - q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"}) - q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1", - Status: squad.ToolStatusError, ErrorReason: "超时"}) - q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c2"}) - q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2", - Status: squad.ToolStatusOK, Detail: "done"}) - if f := q.rec.unresolvedFailure(); f != nil { - t.Fatalf("retry should clear the failure: %+v", f) - } - if got := q.rec.finalReply("", "", "已完成。"); got != "已完成。" { - t.Fatalf("recovered run fallback = %q", got) + q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: long}) + got := toolResultsOf(q.parts()) + if len(got) != 1 || got[0].Summary != truncateRun(long, toolResultSummaryLen) { + t.Fatalf("truncated summary = %+v", got) } } @@ -365,9 +350,12 @@ func (p *entryProbe) resultPayloads() []core.ToolResultPayload { return append([]core.ToolResultPayload(nil), p.results...) } -// TestSquadEntryFailedToolReachesFinalParts:真实跑一次小队入口,同名并行工具 -// 必须按各自 CallID 落部件,失败必须显示为失败,且不被收尾文本改写成成功。 -func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) { +// TestSquadEntryToolFramesReachFinalParts:真实跑一次小队入口,工具帧必须落成部件。 +// +// 上游 squad.ProcessEvent 在 pin 前移后不再携带 CallID/Status:调用身份退到工具名, +// 结局恒为 ok(上游没给判定就不臆造),摘要取上游传来的工具结果文本——失败工具 +// 的失败原因也在这段文本里,仍会如实展示给用户。 +func TestSquadEntryToolFramesReachFinalParts(t *testing.T) { dir := t.TempDir() base := baseEntry(dir) rc := &entryProbe{entryFake: base} @@ -393,50 +381,52 @@ func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) { } parts := base.parts calls := toolCallsOf(parts) - ids := map[string]bool{} + search := 0 for _, c := range calls { if c.Name != "knowledge_search" { continue } - if c.ID == "knowledge_search" || ids[c.ID] { - t.Fatalf("tool call part lost its call id: %+v (all %+v)", c, calls) + search++ + if c.ID != "knowledge_search" { + t.Fatalf("tool call part lost its identity: %+v (all %+v)", c, calls) } - ids[c.ID] = true } - if len(ids) != 2 { - t.Fatalf("distinct call ids = %v", ids) + if search != 2 { + t.Fatalf("knowledge_search call parts = %d, want 2 (all %+v)", search, calls) } results := toolResultsOf(parts) - search := map[string]core.ToolResultPart{} + var searchResults []core.ToolResultPart for _, r := range results { - if ids[r.CallID] { - search[r.CallID] = r + if r.CallID == "knowledge_search" { + searchResults = append(searchResults, r) } } - if len(search) != 2 { + if len(searchResults) != 2 { t.Fatalf("tool results for knowledge_search = %+v", results) } - for id, r := range search { - if r.Status != squad.ToolStatusError { - t.Fatalf("%s status = %q, want error", id, r.Status) + for _, r := range searchResults { + if r.Status != toolStatusOK { + t.Fatalf("%s status = %q, want %q", r.CallID, r.Status, toolStatusOK) } if r.Summary != "索引不可用" { - t.Fatalf("%s summary = %q", id, r.Summary) + t.Fatalf("%s summary = %q", r.CallID, r.Summary) } } - // 发射载荷与落库部件一致:前端过程行不得说 ok。 - emitted := map[string]string{} + // 发射载荷与落库部件一致。 + emitted := 0 for _, pl := range rc.resultPayloads() { - if ids[pl.CallID] { - emitted[pl.CallID] = pl.Status + if pl.CallID != "knowledge_search" { + continue + } + emitted++ + if pl.Status != toolStatusOK || pl.Summary != "索引不可用" { + t.Fatalf("emitted payload = %+v", pl) } } - for id, r := range search { - if emitted[id] != r.Status { - t.Fatalf("emitted %s status = %q, part status = %q", id, emitted[id], r.Status) - } + if emitted != 2 { + t.Fatalf("emitted knowledge_search results = %d, want 2", emitted) } - // 收尾文本保留模型原话,失败部件不被覆盖。 + // 收尾文本保留模型原话。 if last := parts[len(parts)-1]; last.Type != core.PartText || last.Text != "写完了。" { t.Fatalf("final part = %+v", last) } diff --git a/internal/server/operation_query_test.go b/internal/server/operation_query_test.go index e51be2c..1ff1361 100644 --- a/internal/server/operation_query_test.go +++ b/internal/server/operation_query_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -41,6 +42,7 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) { } // 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。 + // agent-command main 契约禁止 unknown 携带 OperationID,句柄写在 Reason 里。 if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil { t.Fatal(err) @@ -48,10 +50,17 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) { resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{ ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID, }) - if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" { + const marker = " (operation " + reason := resp.Receipt.Reason + at := strings.Index(reason, marker) + operationID := "" + if resp.Receipt.State == agentcommand.StateUnknown && resp.Receipt.OperationID == "" && + at >= 0 && strings.HasSuffix(reason, ")") { + operationID = reason[at+len(marker) : len(reason)-1] + } + if operationID == "" { t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt) } - operationID := resp.Receipt.OperationID get := func(key, url string) *httptest.ResponseRecorder { t.Helper()