Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68104556d5 | ||
|
|
7776399fe7 | ||
|
|
67102901d6 | ||
|
|
8007e6028f | ||
|
|
605a6a1acd | ||
|
|
32ae91e8d9 | ||
|
|
96393b77f7 | ||
|
|
b1713173ef | ||
|
|
56700f0a26 | ||
|
|
a2e9f003bc | ||
|
|
2da3d76ce1 | ||
|
|
ea497ed149 | ||
|
|
e853260bc7 | ||
|
|
1f952a7ef7 | ||
|
|
aee1d9bcf2 | ||
|
|
503ae000e0 | ||
|
|
392bb68506 | ||
|
|
30e18af70e |
@@ -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: |
|
||||
|
||||
@@ -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",
|
||||
|
||||
+10
-10
@@ -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
|
||||
|
||||
@@ -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/<Repo>.git`
|
||||
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.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 执行与新项目准入(强制)
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: fe7987dc03...903d7e00c2
Vendored
+1
-1
Submodule deps/agent-document-renderer updated: 3f259990b5...40e5c671d1
Vendored
+1
-1
Submodule deps/agent-intent updated: 7f3ff6a187...28833bcfca
Vendored
+1
-1
Submodule deps/agent-native updated: be556f4641...8f19a4d78d
Vendored
+1
-1
Submodule deps/agent-squad updated: 95b387d722...fb9ccdc74f
Vendored
+1
-1
Submodule deps/agent-system-core updated: 0cf03446c4...614dcbff4b
Vendored
+1
-1
Submodule deps/development-guidelines updated: 2688fd2be6...3ed982138c
Vendored
+1
-1
Submodule deps/development-manager updated: 67784f43eb...24e8a5c8ba
Vendored
+1
-1
Submodule deps/error-code-system updated: a3ee67bba2...00c03035b5
Vendored
+1
-1
Submodule deps/logging-system updated: 49b5d8be60...7005493077
@@ -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)
|
||||
|
||||
+21
-27
@@ -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 ""
|
||||
|
||||
@@ -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 <id>)"),
|
||||
// 这里统一读出来供断言与按记录对账使用。
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
package docgen
|
||||
|
||||
const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
const rendererRevision = "40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
|
||||
const RendererIdentity = "agent-document-renderer@" + rendererRevision
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径)----
|
||||
|
||||
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
|
||||
func modeSpec(t *testing.T) json.RawMessage {
|
||||
@@ -239,7 +239,9 @@ func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
|
||||
// 生成路径与规则路径同款:库层对生成槽位跑全量权威校验(candidateFromGenerated
|
||||
// → validateProvidedSlots:enum/format/validator 失败进 Invalid,必填未给进 Missing),
|
||||
// 并按权威 spec 合成 risk/confirm/action;宿主只做投影,不重复裁决、不二次拦。
|
||||
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
@@ -254,27 +256,59 @@ 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 res.Complete() {
|
||||
t.Fatalf("非法枚举值不得算作完整:%+v", res)
|
||||
}
|
||||
if _, leaked := res.Slots["mode"]; leaked || res.Complete() {
|
||||
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
|
||||
if len(res.Missing) != 0 {
|
||||
// 必填且非法只进 Invalid,不重复进 Missing。
|
||||
t.Fatalf("非法槽位不应重复计入缺失:%+v", res.Missing)
|
||||
}
|
||||
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
|
||||
if len(res.Invalid) != 1 {
|
||||
t.Fatalf("生成路径非法槽位详情:%+v", res.Invalid)
|
||||
}
|
||||
d := res.Invalid[0]
|
||||
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
|
||||
t.Fatalf("生成路径非法原因/提问未透传:%+v", d)
|
||||
}
|
||||
if _, leaked := res.Slots["mode"]; leaked {
|
||||
t.Fatalf("非法值不得进入已校验槽位表:%+v", res.Slots)
|
||||
}
|
||||
if res.Slots["topic"] != "核能" {
|
||||
t.Fatalf("生成路径合法槽位丢失:%+v", res.Slots)
|
||||
}
|
||||
// 风险/确认/动作声明按 modeSpec 合成(生成路径与规则路径一致)。
|
||||
if res.Risk != "high" || !res.RequiresConfirm {
|
||||
t.Fatalf("生成路径风险元数据:%+v", res)
|
||||
}
|
||||
if res.Action == nil || res.Action.Kind != ActionCommand || res.Action.ID != "demo.mode" {
|
||||
t.Fatalf("生成路径动作声明:%+v", res.Action)
|
||||
}
|
||||
|
||||
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
|
||||
// 合法槽位:matched、完整、无缺陷,元数据同样齐备。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"mode": "摘要", "topic": "核能"},
|
||||
}}})
|
||||
clean := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" || len(clean.Invalid) != 0 {
|
||||
t.Fatalf("生成路径合法槽位:%+v", clean)
|
||||
}
|
||||
if clean.Risk != "high" || !clean.RequiresConfirm || clean.Action == nil {
|
||||
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。
|
||||
|
||||
@@ -218,6 +218,12 @@ func (s *Service) ApplyAnswer(key Key, revision int, answer Answer) (*Outcome, e
|
||||
if err != nil || cur == nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.applyAnswer(key, cur, answer)
|
||||
}
|
||||
|
||||
// applyAnswer 把一轮回答折叠进已读出的会话(读一次判一次:同一条命令里
|
||||
// 不重复读库,判定口径与读到的状态一致)。
|
||||
func (s *Service) applyAnswer(key Key, cur *Session, answer Answer) (*Outcome, error) {
|
||||
if cur.Expectation == nil || cur.Cancelled != nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -247,16 +253,33 @@ func (s *Service) ApplyAnswer(key Key, revision int, answer Answer) (*Outcome, e
|
||||
}
|
||||
|
||||
// ApplyCommand 消费库层会话命令:
|
||||
// - set_slot / change_slot 写槽位(动作声明可解析时按声明校验,非法值不采纳);
|
||||
// - set_slot / change_slot 写槽位(动作声明可解析时按声明校验,非法值不采纳;
|
||||
// 键只许落在当前预期认得的槽位上——别的动作的键一律忽略);
|
||||
// - cancel 清空挂起预期(保留已收集槽位,revision +1 让在途回答作废);
|
||||
// - 其它种类(start/clarify/chitchat/handoff…)一律忽略,如实返回 nil。
|
||||
func (s *Service) ApplyCommand(key Key, revision int, kind, name, value string) (*Outcome, error) {
|
||||
switch kind {
|
||||
case string(intentgate.CommandSetSlot), string(intentgate.CommandChangeSlot):
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return s.ApplyAnswer(key, revision, Answer{Slots: map[string]string{name: value}})
|
||||
cur, err := s.current(key, revision)
|
||||
if err != nil || cur == nil {
|
||||
return nil, err
|
||||
}
|
||||
if cur.Expectation == nil || cur.Cancelled != nil {
|
||||
// 没有待补齐的动作(或这一行只是已取消记录):如实忽略。
|
||||
return nil, nil
|
||||
}
|
||||
if !cur.ownsSlot(name) {
|
||||
// 显式落槽命令只许落在当前预期认得的槽位键上:别的动作的键绝不
|
||||
// 注入当前会话(如给「取消运行」的会话塞「清理对象」的键)。如
|
||||
// 实忽略(返回 nil=本轮没有任何会话语义),响应面继续按仍缺的
|
||||
// 槽位澄清,绝不假装消费过这条命令。
|
||||
return nil, nil
|
||||
}
|
||||
return s.applyAnswer(key, cur, Answer{Slots: map[string]string{name: value}})
|
||||
case string(intentgate.CommandCancel):
|
||||
cur, err := s.current(key, revision)
|
||||
if err != nil || cur == nil {
|
||||
@@ -362,13 +385,14 @@ func (s *Service) load(key Key) (*Session, error) {
|
||||
}
|
||||
|
||||
// live 报告会话是否仍在有效期内。没有挂起预期的行(已取消/已补齐)对宿主
|
||||
// 等于「没有会话」。
|
||||
// 等于「没有会话」;到期时间缺失或不可解析(行被写坏)同样 fail-closed:
|
||||
// 当作已过期,绝不把损坏的到期时间当成「永不过期」。
|
||||
func (s *Service) live(cur store.IntentSession) bool {
|
||||
if cur.Expectation == nil {
|
||||
return false
|
||||
}
|
||||
if cur.ExpiresAt.IsZero() {
|
||||
return true
|
||||
if !cur.ExpiresValid || cur.ExpiresAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return s.now().Before(cur.ExpiresAt)
|
||||
}
|
||||
@@ -446,6 +470,22 @@ func fromStore(key Key, cur store.IntentSession) *Session {
|
||||
return out
|
||||
}
|
||||
|
||||
// ownsSlot 报告这个槽位键是否属于当前预期认得的槽位:仍缺的(Missing)
|
||||
// 与已收集的(Slots)都是当前动作的键;别的动作的键一律不认(显式落槽
|
||||
// 命令不得把它们注入当前会话)。
|
||||
func (s *Session) ownsSlot(key string) bool {
|
||||
if s == nil || s.Expectation == nil {
|
||||
return false
|
||||
}
|
||||
for _, q := range s.Expectation.Missing {
|
||||
if q.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
_, collected := s.Slots[key]
|
||||
return collected
|
||||
}
|
||||
|
||||
// runIDOf 从槽位表里取运行号(固定键序,结果与 map 迭代序无关;无则空)。
|
||||
func runIDOf(slots map[string]string) string {
|
||||
for _, key := range runSlotKeys {
|
||||
|
||||
@@ -142,6 +142,60 @@ func TestSetSlotThenAnswerCompletes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 set_slot/change_slot 只许落在当前预期认得的槽位上:别的动作的键
|
||||
// 一律忽略(不落槽、不推进 revision);认得的键照旧生效。
|
||||
func TestSetSlotOnlyAcceptsKeysOfCurrentExpectation(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
// 当前预期是「取消运行」:它认得的键只有 run_id 与 reason。
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 别的动作(demo.purge)的键:忽略——绝不注入当前会话。
|
||||
out, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "target", "T-1")
|
||||
if err != nil || out != nil {
|
||||
t.Fatalf("不属于当前预期的键必须被忽略:%+v %v", out, err)
|
||||
}
|
||||
cur, err := svc.Pending(key)
|
||||
if err != nil || cur == nil || cur.Revision != suspended.Revision || cur.Slots["target"] != "" {
|
||||
t.Fatalf("被忽略的命令改动了会话:%+v %v", cur, err)
|
||||
}
|
||||
// 认得的键(仍缺的):照旧落槽。
|
||||
ok, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "run_id", "R-7")
|
||||
if err != nil || ok == nil || ok.Session == nil {
|
||||
t.Fatalf("认得的键必须照旧落槽:%+v %v", ok, err)
|
||||
}
|
||||
if ok.Session.Slots["run_id"] != "R-7" || ok.Session.Revision != suspended.Revision+1 {
|
||||
t.Fatalf("落槽结果:%+v", ok.Session)
|
||||
}
|
||||
// 已收集的键也认(否则「改一下编号」永远改不动已抽到的槽位)。
|
||||
changed, err := svc.ApplyCommand(key, ok.Session.Revision, string(intentgate.CommandChangeSlot), "run_id", "R-8")
|
||||
if err != nil || changed == nil || changed.Session == nil || changed.Session.Slots["run_id"] != "R-8" {
|
||||
t.Fatalf("change_slot 未改写已收集的键:%+v %v", changed, err)
|
||||
}
|
||||
// 补上最后一个缺键:照旧补齐(消费会话)。
|
||||
filled, err := svc.ApplyCommand(key, changed.Session.Revision, string(intentgate.CommandSetSlot), "reason", "误触发")
|
||||
if err != nil || filled == nil || !filled.Complete {
|
||||
t.Fatalf("补齐最后一个键应完成:%+v %v", filled, err)
|
||||
}
|
||||
// 反过来:另一个动作的预期认不得上一个动作的键。
|
||||
purge, err := svc.Suspend(key, Expectation{IntentID: "demo.purge",
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.purge"},
|
||||
Missing: []intentgate.SlotQuestion{{Key: "target", Question: "清理哪个对象?"}},
|
||||
Question: "清理哪个对象?"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := svc.ApplyCommand(key, purge.Revision, string(intentgate.CommandSetSlot), "run_id", "R-9"); err != nil || out != nil {
|
||||
t.Fatalf("上个动作的键必须被忽略:%+v %v", out, err)
|
||||
}
|
||||
after, err := svc.Pending(key)
|
||||
if err != nil || after == nil || after.Revision != purge.Revision || after.Slots["run_id"] != "" {
|
||||
t.Fatalf("被忽略的命令改动了会话:%+v %v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消清空挂起预期:确认取消、不假装成功,陈旧回答也作废。
|
||||
func TestCancelClearsPendingExpectation(t *testing.T) {
|
||||
svc := newService(t)
|
||||
@@ -221,6 +275,35 @@ func TestExpiredSessionNeverCompletesOldAction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 库里的到期时间被写坏(不可解析):fail-closed 当作已过期——会话对宿主
|
||||
// 不存在,行被惰性删除;绝不把损坏的到期时间当成「永不过期」。
|
||||
func TestCorruptedExpiryIsTreatedAsExpired(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 直接改坏库里的 expires_at(模拟损坏的行)。
|
||||
if _, err := svc.st.DB().Exec(`UPDATE intent_session SET expires_at = ? WHERE owner = ? AND conversation_id = ?`,
|
||||
"not-a-timestamp", key.Owner, key.Conversation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 损坏的到期时间=已过期:会话对宿主不存在。
|
||||
got, err := svc.Pending(key)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("损坏的到期时间必须当作已过期:%v %v", got, err)
|
||||
}
|
||||
// 陈旧回答不得借到旧会话的槽位。
|
||||
if out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"reason": "误触发"}}); err != nil || out != nil {
|
||||
t.Fatalf("过期回答不得补齐旧动作:%+v %v", out, err)
|
||||
}
|
||||
// 行已被惰性删除。
|
||||
if _, err := svc.st.GetIntentSession(key.Owner, key.Project, key.Conversation); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("过期行未被惰性删除:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个用户、两个项目在构造上互不可见。
|
||||
func TestSessionsAreIsolatedByOwnerAndProject(t *testing.T) {
|
||||
svc := newService(t)
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// 读不到隔离记录时更按 fail-closed 处理(不声称"可以安全重试")。
|
||||
// - 「没有记录」≠「没有发生」:side_effects 为空必须带 note,说明这是"未记录"
|
||||
// 而不是"未发生"。
|
||||
// - 有界读取也要说出来:命中读取上限(扫描/聚合上限)时报告显式带截断 note,
|
||||
// 不让"读了前 N 条"冒充"全部证据"。
|
||||
// - 归属边界:HITL 决策卡、危险命令确认卡、命令审计信封都按 owner + 会话归属,
|
||||
// 其中决策卡与确认卡表里根本没有 run 列,审计信封虽有 runId 字段但宿主总线
|
||||
// 当前不写。因此"归到这个 run"是调用方按签发时间窗做的有界推断,报告以
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
@@ -190,6 +193,10 @@ func Gather(src Source, run core.Run, now time.Time) Evidence {
|
||||
ev = ev.withNote("pending confirmations unreadable: " + err.Error())
|
||||
} else {
|
||||
ev.Confirms = confirms
|
||||
if len(confirms) >= maxConfirms {
|
||||
// 有界读到的行数正好等于上限 = 列表可能被截断;不说出来就会被读成"就这些"。
|
||||
ev = ev.withNote(fmt.Sprintf("pending confirmation read reached the %d-row cap; the list may be truncated", maxConfirms))
|
||||
}
|
||||
}
|
||||
|
||||
cp, ok, err := src.LastRunCheckpoint(conversation, string(run.ID))
|
||||
@@ -205,6 +212,11 @@ func Gather(src Source, run core.Run, now time.Time) Evidence {
|
||||
ev = ev.withNote("command audit unreadable: " + err.Error())
|
||||
} else {
|
||||
ev.Audits = audits
|
||||
if len(audits) >= maxAuditScan {
|
||||
// 审计信封按时间倒序取最近 maxAuditScan 条;命中上限说明更早的回执没被读,
|
||||
// 副作用证据可能不完整。
|
||||
ev = ev.withNote(fmt.Sprintf("command audit scan reached the %d-envelope cap (newest first); older receipts were not read, so side effect evidence may be incomplete", maxAuditScan))
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -215,13 +227,32 @@ func Build(ev Evidence) Report {
|
||||
if ev.Checkpoint != nil {
|
||||
rep.LastCheckpoint = &Checkpoint{ID: fmt.Sprintf("%d", ev.Checkpoint.Seq), At: ev.Checkpoint.TS, Type: ev.Checkpoint.Type}
|
||||
}
|
||||
rep.SideEffects = sideEffects(ev)
|
||||
effects, effectsTruncated := sideEffects(ev)
|
||||
rep.SideEffects = effects
|
||||
rep.Notes = append([]string(nil), ev.Notes...)
|
||||
if effectsTruncated {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf(
|
||||
"side_effects truncated at the %d-entry cap; recorded effects beyond the cap are not represented in this report",
|
||||
maxSideEffects))
|
||||
}
|
||||
|
||||
card := waitingCard(ev)
|
||||
decision := waitingDecision(ev)
|
||||
confirm := waitingConfirm(ev)
|
||||
|
||||
if isTerminalRun(ev.Run.Status) {
|
||||
// 终态 run 上仍未关闭的决策卡 / 人工决策 / 危险命令确认行是残留记录,不是
|
||||
// 「现在在等人」。终态优先于证据驱动的等待映射:按终态报结论,并把残留记录
|
||||
// 写进 note——否则值班会拿着一个已经结束、永远不会回跑的 run 去确认一张卡。
|
||||
// run.status 原样保留(不隐藏),这里只改责任判定与措辞。
|
||||
if stale := staleWaitRecords(card, decision, confirm); len(stale) > 0 {
|
||||
rep.Notes = append(rep.Notes, "run reached terminal status "+string(ev.Run.Status)+
|
||||
" while "+strings.Join(stale, ", ")+
|
||||
" stayed open; the run is not waiting on them, treat the leftover record(s) as stale instead of confirming")
|
||||
card, decision, confirm = nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case ev.HoldUnknown:
|
||||
// 隔离记录读不到:不猜没有 hold,直接 unknown,且不给"安全重试"。
|
||||
@@ -338,8 +369,9 @@ func runSummary(ev Evidence) RunSummary {
|
||||
}
|
||||
|
||||
// sideEffects 汇总已记录的外部副作用证据:命令回执对账记录(U7b 审计信封)加上
|
||||
// 恢复隔离行(U3b 的"结果未知"证据)。
|
||||
func sideEffects(ev Evidence) []SideEffect {
|
||||
// 恢复隔离行(U3b 的"结果未知"证据)。第二个返回值是截断标志:命中 maxSideEffects
|
||||
// 上限时为 true,调用方据此给报告加显式截断 note(有界读不冒充全量)。
|
||||
func sideEffects(ev Evidence) ([]SideEffect, bool) {
|
||||
out := []SideEffect{}
|
||||
if ev.Hold != nil {
|
||||
state := "held"
|
||||
@@ -354,15 +386,16 @@ func sideEffects(ev Evidence) []SideEffect {
|
||||
if !belongsToRun(ev, env) {
|
||||
continue
|
||||
}
|
||||
if len(out) >= maxSideEffects {
|
||||
// 上限已满且还有归属本次 run 的回执:后面的没进报告,如实标出截断。
|
||||
return out, true
|
||||
}
|
||||
out = append(out, SideEffect{
|
||||
Kind: EffectOperation, OperationID: env.OperationID, CommandID: env.CommandID,
|
||||
State: string(env.State), At: env.Time.UTC().Format(time.RFC3339),
|
||||
})
|
||||
if len(out) >= maxSideEffects {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, false
|
||||
}
|
||||
|
||||
// belongsToRun 是审计信封的归属边界:信封自带 runId 时以它为准;否则只在"同一
|
||||
@@ -385,6 +418,33 @@ func openHold(ev Evidence) bool {
|
||||
return ev.Hold != nil && ev.Hold.ReleasedAt == ""
|
||||
}
|
||||
|
||||
// isTerminalRun 判定 run 是否已到终态(failed / completed / cancelled:runner 不会再
|
||||
// 推进它)。终态优先于证据驱动的等待映射——终态 run 上残留的卡不再构成"在等人"。
|
||||
func isTerminalRun(status core.RunStatus) bool {
|
||||
switch status {
|
||||
case core.RunFailed, core.RunCompleted, core.RunCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// staleWaitRecords 列出终态 run 上仍未关闭的等待类记录,供 note 如实点名(不点名就等于
|
||||
// 把残留记录藏起来)。它不改变任何结论,只决定报告里怎么称呼残留证据。
|
||||
func staleWaitRecords(card *core.HITLCard, decision *store.HumanDecision, confirm *scopedConfirm) []string {
|
||||
out := []string{}
|
||||
if card != nil {
|
||||
out = append(out, "hitl card "+string(card.ID))
|
||||
}
|
||||
if decision != nil {
|
||||
out = append(out, "pending human decision "+decision.ID)
|
||||
}
|
||||
if confirm != nil {
|
||||
out = append(out, "pending command confirmation "+confirm.row.CardID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unknownOperations 统计结果未知的已记录 operation(四态语义里的 unknown:
|
||||
// 执行过但结果不确定)。它是"重试会不会复制副作用"的直接证据。
|
||||
func unknownOperations(effects []SideEffect) int {
|
||||
|
||||
@@ -3,6 +3,7 @@ package rundiag
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -107,6 +108,38 @@ func TestBuildResponsibilityStatesAndNextStep(t *testing.T) {
|
||||
state: StateReconciling, role: RoleOperationOwner, action: ActionNeedsManualReconciliation,
|
||||
waitHint: "still interrupted", since: afterRun.Add(time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "released hold reconciles even while a HITL card is still open",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunInterrupted),
|
||||
Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown external effect",
|
||||
CreatedAt: afterRun.Format(time.RFC3339), ReleasedAt: afterRun.Add(time.Minute).Format(time.RFC3339)},
|
||||
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve?"}},
|
||||
},
|
||||
state: StateReconciling, role: RoleOperationOwner, action: ActionNeedsManualReconciliation,
|
||||
waitHint: "still interrupted", since: afterRun.Add(time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "a pending decision outranks a pending dangerous command confirmation",
|
||||
ev: Evidence{
|
||||
Run: run(core.RunRunning),
|
||||
Decisions: []store.HumanDecision{{ID: "hd_1", CommandID: "rdm.confirm", Status: "pending",
|
||||
Question: "approve delivery?", IssuedAt: afterRun, ExpiresAt: afterRun.Add(time.Hour)}},
|
||||
Confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339),
|
||||
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.docs.instantiate", Owner: "alice", ConversationID: "cnv_1"})}},
|
||||
},
|
||||
state: StateWaitingHITL, role: RoleHuman, action: ActionConfirmCard, cardID: "hd_1",
|
||||
waitHint: "approve delivery?", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "a terminal failed run with a leftover card is failed, not a live wait",
|
||||
ev: Evidence{
|
||||
Run: ended(run(core.RunFailed), afterRun),
|
||||
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve?"}},
|
||||
},
|
||||
state: StateFailed, role: RoleRunner, action: ActionSafeRetry,
|
||||
waitHint: "run failed", since: afterRun.Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
name: "waiting on a HITL card",
|
||||
ev: Evidence{
|
||||
@@ -421,6 +454,115 @@ func TestBuildStatusDrivenWaitHasNoCardAndSaysSo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// MI1:终态 run 上的残留卡不再驱动「等人确认」。终态映射优先,run.status 原样可见,
|
||||
// 残留记录逐一点名——而不是把一个永远不会回跑的 run 的卡交给值班去确认。
|
||||
func TestBuildTerminalRunSuppressesStaleWaitEvidence(t *testing.T) {
|
||||
leftovers := func() Evidence {
|
||||
return Evidence{
|
||||
Run: ended(run(core.RunFailed), afterRun),
|
||||
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve?"}},
|
||||
Decisions: []store.HumanDecision{{ID: "hd_1", CommandID: "rdm.confirm", Status: "pending",
|
||||
Question: "approve delivery?", IssuedAt: afterRun, ExpiresAt: afterRun.Add(time.Hour)}},
|
||||
Confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339),
|
||||
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.docs.instantiate", Owner: "alice", ConversationID: "cnv_1"})}},
|
||||
}
|
||||
}
|
||||
|
||||
rep := Build(leftovers())
|
||||
if rep.Responsibility.State != StateFailed {
|
||||
t.Fatalf("a terminal run must not read as waiting on a card: %+v", rep.Responsibility)
|
||||
}
|
||||
if rep.NextStep.Action == ActionConfirmCard || rep.NextStep.CardID != "" {
|
||||
t.Fatalf("a terminal run must not advise confirming a leftover card: %+v", rep.NextStep)
|
||||
}
|
||||
if rep.Run.Status != string(core.RunFailed) {
|
||||
t.Fatalf("run.status must stay visible: %q", rep.Run.Status)
|
||||
}
|
||||
for _, want := range []string{"stale", "hitl card card_hitl", "pending human decision hd_1", "pending command confirmation card_cmd"} {
|
||||
if !containsNote(rep.Notes, want) {
|
||||
t.Fatalf("terminal run with leftovers must note %q: %v", want, rep.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
completed := leftovers()
|
||||
completed.Run = ended(run(core.RunCompleted), afterRun)
|
||||
rep = Build(completed)
|
||||
if rep.Responsibility.State != StateCompleted || rep.NextStep.Action != ActionNone || rep.NextStep.CardID != "" {
|
||||
t.Fatalf("completed run with leftovers: %+v", rep)
|
||||
}
|
||||
if !containsNote(rep.Notes, "stale") {
|
||||
t.Fatalf("completed run with leftovers must name them: %v", rep.Notes)
|
||||
}
|
||||
|
||||
cancelled := leftovers()
|
||||
cancelled.Run = ended(run(core.RunCancelled), afterRun)
|
||||
rep = Build(cancelled)
|
||||
if rep.Responsibility.State != StateUnknown || rep.NextStep.Action == ActionConfirmCard || rep.NextStep.CardID != "" {
|
||||
t.Fatalf("cancelled run with leftovers: %+v", rep)
|
||||
}
|
||||
if !containsNote(rep.Notes, "stale") {
|
||||
t.Fatalf("cancelled run with leftovers must name them: %v", rep.Notes)
|
||||
}
|
||||
|
||||
// 未终态的 run 不受影响:卡仍然驱动等待映射,且不得被说成残留。
|
||||
live := leftovers()
|
||||
live.Run = run(core.RunRunning)
|
||||
rep = Build(live)
|
||||
if rep.Responsibility.State != StateWaitingHITL || rep.NextStep.Action != ActionConfirmCard || rep.NextStep.CardID != "card_hitl" {
|
||||
t.Fatalf("a live run must keep its wait mapping: %+v", rep)
|
||||
}
|
||||
if containsNote(rep.Notes, "stale") {
|
||||
t.Fatalf("a live run must not be reported as stale: %v", rep.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// MI2:有界读取命中上限时必须显式说截断,不能让「读了前 N 条」冒充「全部证据」。
|
||||
func TestBuildReportsReadCapsInsteadOfSilentTruncation(t *testing.T) {
|
||||
// 审计信封按时间倒序取最近 maxAuditScan 条;命中上限即说明更早的回执没被读。
|
||||
rep := Build(Gather(fakeSource{audits: envelopes(maxAuditScan + 5)}, run(core.RunRunning), afterRun))
|
||||
if !containsNote(rep.Notes, "audit scan reached the") {
|
||||
t.Fatalf("a capped audit scan must be reported: %v", rep.Notes)
|
||||
}
|
||||
|
||||
// 副作用聚合上限:超限的回执不进报告,必须标出截断。
|
||||
rep = Build(Evidence{Run: ended(run(core.RunFailed), afterRun), Audits: envelopes(maxSideEffects + 10)})
|
||||
if len(rep.SideEffects) != maxSideEffects {
|
||||
t.Fatalf("side effects must stop at the read cap: %d", len(rep.SideEffects))
|
||||
}
|
||||
if !containsNote(rep.Notes, "side_effects truncated at the") {
|
||||
t.Fatalf("capped side effects must be reported as truncated: %v", rep.Notes)
|
||||
}
|
||||
|
||||
// 确认行上限同样要标出来。
|
||||
confirms := make([]store.PendingConfirm, maxConfirms)
|
||||
for i := range confirms {
|
||||
confirms[i] = store.PendingConfirm{CardID: fmt.Sprintf("card_%d", i), Owner: "alice",
|
||||
CreatedAt: afterRun.Format(time.RFC3339), Payload: "{}"}
|
||||
}
|
||||
rep = Build(Gather(fakeSource{confirms: confirms}, run(core.RunRunning), afterRun))
|
||||
if !containsNote(rep.Notes, "confirmation read reached the") {
|
||||
t.Fatalf("a capped confirmation read must be reported: %v", rep.Notes)
|
||||
}
|
||||
|
||||
// 未命中上限时不加噪音 note。
|
||||
clean := Build(Evidence{Run: run(core.RunRunning), Audits: envelopes(3)})
|
||||
if containsNote(clean.Notes, "truncat") {
|
||||
t.Fatalf("an uncapped read must not claim truncation: %v", clean.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// envelopes 造 n 条归属本次 run 的回执信封(RunID 显式带上,绕过时间窗推断)。
|
||||
func envelopes(n int) []agentcommand.AuditEnvelope {
|
||||
out := make([]agentcommand.AuditEnvelope, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, agentcommand.AuditEnvelope{
|
||||
RunID: "run_1", CommandID: "rdm.docs.instantiate", OperationID: fmt.Sprintf("op_%d", i),
|
||||
State: agentcommand.StateCommitted, Time: afterRun.Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mustRequest(t *testing.T, req cmdbus.Request) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(req)
|
||||
|
||||
@@ -54,7 +54,7 @@ type directRun struct {
|
||||
acting bool
|
||||
queued []agentllm.ToolCall
|
||||
inflight map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
|
||||
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
|
||||
toolMu sync.Mutex // 事件回调与工具 goroutine 之间互斥(inflight/finalParts/queued)
|
||||
turnBody string
|
||||
|
||||
evidence []toolEvidence
|
||||
@@ -763,6 +763,15 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
|
||||
st.acting = true
|
||||
}
|
||||
// queued 的判空与 takeQueued 不持 toolMu:它们与 message_end 里对 queued 的
|
||||
// 写入都在同一个事件回调(onEvent)里,而内核把宿主回调串行化——Run 入口的
|
||||
// serializeHostCallbacks 包装 Config.OnEvent(agent-native 循环),并行
|
||||
// executeParallel 的每个 goroutine 各发一个 tool_execution_start,但它们逐个
|
||||
// 进入 onEvent,不会并发;于是取用与写入天然互斥(配对结果不因此改变,只是
|
||||
// 不再依赖“恰好没人同时来”)。对比:inflight 槽位与 finalParts 还被工具
|
||||
// goroutine(directRun.writeSlot / appendFinalPart)写,那条路径不在回调串行化
|
||||
// 范围内,必须持 toolMu——takeInflight / writeSlot 的“调用方须持有 toolMu”
|
||||
// 靠那把锁兑现。
|
||||
if len(st.queued) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -1440,6 +1449,10 @@ func toolExecMode(eff core.EffectiveSettings) string {
|
||||
|
||||
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
|
||||
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
|
||||
//
|
||||
// queued 的并发前提:内核串行化宿主事件回调,takeQueued 与写 queued 的 message_end
|
||||
// 不会并发(见 onEvent 的 tool_execution_start 分支说明),因此调用点可以不持锁;
|
||||
// 本函数的其余调用方仍应持 toolMu(读改写非原子,脱离回调串行化即数据竞争)。
|
||||
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
|
||||
if callID != "" {
|
||||
for i, c := range st.queued {
|
||||
|
||||
@@ -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 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
|
||||
@@ -59,6 +51,13 @@ type toolFailure struct {
|
||||
// 因此流式文本、工具部件、用量与 pending 配对表全部在同一把锁下更新:
|
||||
// 既不丢条目,也不会把 A 调用的 start 与 B 调用的 end 交错写坏。
|
||||
// 事件发射与部件追加在同一临界区内,保持到达顺序与最终部件顺序一致。
|
||||
//
|
||||
// sink 契约(rc.Emit):同步、快、不得阻塞。onEvent 全程持 l.mu,发射在临界区内完成,
|
||||
// 所以任何慢 sink 都会把所有过程帧串成一条队:并行工具的 goroutine 一起堵在锁上,
|
||||
// 帧的到达顺序与最终部件顺序的不变式随之失去意义(且帧可能被重排)。既有 sink 只做
|
||||
// 鉴权、投影与投递,不在回调里做重活;新增 sink 必须守住这条契约。真要做重活就放到
|
||||
// 锁外(自建 goroutine 或交给 hub 排队),但那会放弃"发射与部件同序"这条不变式,
|
||||
// 需要一并权衡,而不是顺手挪出来。
|
||||
type squadEventLog struct {
|
||||
rc RunCtx
|
||||
|
||||
@@ -76,6 +75,8 @@ func newSquadEventLog(rc RunCtx) *squadEventLog {
|
||||
}
|
||||
|
||||
// onEvent 是小队过程帧回调(并发安全)。
|
||||
//
|
||||
// 锁内调 rc.Emit:sink 契约见 squadEventLog 的说明(同步、快、不得阻塞)。
|
||||
func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -108,7 +109,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 +160,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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -140,6 +140,14 @@ func (s *Server) handleIntentCancel(w http.ResponseWriter, r *http.Request, conv
|
||||
AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
|
||||
})
|
||||
result.Via, result.Accepted = "bus", resp.Receipt.State != agentcommand.StateRejected
|
||||
if !result.Accepted {
|
||||
// 拒绝原因如实透出(与本地投递路径对称):不受理就要说清为什么,
|
||||
// 绝不只报一个「没成功」的空理由。
|
||||
result.Reason = strings.TrimSpace(resp.Receipt.Reason)
|
||||
if result.Reason == "" {
|
||||
result.Reason = "命令被拒绝"
|
||||
}
|
||||
}
|
||||
awaitingConfirm = resp.Card != nil
|
||||
out["command"], out["receipt"] = intentCancelCommandID, resp.Receipt
|
||||
if resp.Card != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -398,6 +399,60 @@ func TestIntentCancelResolvesRunIDFromCommandValue(t *testing.T) {
|
||||
waitRunStatus(t, h, runID, string(core.RunCancelled))
|
||||
}
|
||||
|
||||
// 命令总线拒绝派发(处理器声明「确定未执行」):不受理,并如实给出拒绝
|
||||
// 原因(与本地投递路径对称:绝不只报一个空理由)。
|
||||
func TestIntentCancelRejectedDispatchReportsReason(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: intentCancelCommandID,
|
||||
Title: "取消运行", ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id"],
|
||||
"properties":{"run_id":{"type":"string"},"text":{"type":"string"}}}`)},
|
||||
"", func(_ context.Context, req cmdbus.Request) (any, error) {
|
||||
return nil, cmdbus.NotExecuted(errors.New("取消被策略拒绝"))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := &scriptedGate{}
|
||||
srv.Gate = gate
|
||||
h := srv.Handler()
|
||||
projectID, convID := setupConversation(t, h)
|
||||
runID := liveRun(t, h, convID)
|
||||
gate.script("取消刚才的任务", taskCancelIntent(map[string]string{"run_id": runID}))
|
||||
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "取消刚才的任务"})
|
||||
if code != 200 {
|
||||
t.Fatalf("取消轮:%d %v", code, out)
|
||||
}
|
||||
cancel, _ := out["cancel"].(map[string]any)
|
||||
if cancel == nil {
|
||||
t.Fatalf("取消回执缺失:%v", out)
|
||||
}
|
||||
if cancel["accepted"] != false || cancel["via"] != "bus" || cancel["runId"] != runID {
|
||||
t.Fatalf("被拒绝的派发不得写成已受理:%v", cancel)
|
||||
}
|
||||
reason, _ := cancel["reason"].(string)
|
||||
if !strings.Contains(reason, "取消被策略拒绝") {
|
||||
t.Fatalf("拒绝原因必须如实透出:%v", cancel)
|
||||
}
|
||||
receipt, _ := out["receipt"].(map[string]any)
|
||||
if receipt == nil || receipt["state"] != string(agentcommand.StateRejected) {
|
||||
t.Fatalf("拒绝回执:%v", out)
|
||||
}
|
||||
// 没受理就不写会话记录,也不谎报已取消。
|
||||
if _, noted := out["intent_session"]; noted {
|
||||
t.Fatalf("被拒绝的取消不得写会话记录:%v", out)
|
||||
}
|
||||
if sess := pendingIntent(t, st, projectID, convID); sess != nil {
|
||||
t.Fatalf("被拒绝的取消改动了会话:%+v", sess)
|
||||
}
|
||||
// 运行没被取消(拒绝即无副作用)。
|
||||
if code, run := doJSON(t, h, "GET", "/api/runs/"+runID, nil); code == 200 {
|
||||
if r, _ := run["run"].(map[string]any); r["status"] == string(core.RunCancelled) {
|
||||
t.Fatalf("被拒绝的取消却取消了运行:%v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息原文进 params:取消命令与其它命令入口同款(恒带 text)。
|
||||
func TestIntentCancelPassesOriginalText(t *testing.T) {
|
||||
srv, _, gate, log := cancelServer(t)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,8 +8,9 @@ package store
|
||||
//
|
||||
// 键是 (owner, project_id, conversation_id):两个用户、两个项目的会话在
|
||||
// 构造上互不可见,读写一律带 owner 过滤。行是运行态(默认 30 分钟后
|
||||
// 过期、读侧惰性删除),不进 storage-migrate 的 copyOrder(同
|
||||
// admission_leases:进程/会话瞬态,迁移没有意义)。
|
||||
// 过期、读侧惰性删除);但它随库迁移:v52 起 intent_session 已登记在
|
||||
// storage-migrate 的 copyOrder 里(夹具即文档:新表漏登记会被那条用例
|
||||
// 挡住),源库没有这张表时由 sqliteHasTable 跳过(旧库无此表)。
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -46,8 +47,12 @@ type IntentSession struct {
|
||||
Slots map[string]string
|
||||
Revision int
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// ExpiresValid 报告到期时间是否可用:库里的 expires_at 为空或不可解析
|
||||
// 时为假(此时 ExpiresAt 不可信)。宿主据此 fail-closed 把该行当成
|
||||
// 已过期,绝不把损坏的到期时间当成「永不过期」。
|
||||
ExpiresValid bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetIntentSession 按 owner+project+conversation 读回意图会话。
|
||||
@@ -159,7 +164,16 @@ func scanIntentSession(owner, projectID, conversationID string, row *sql.Row) (I
|
||||
out.Slots = slots
|
||||
}
|
||||
}
|
||||
out.ExpiresAt, _ = parseTime(expiresAt)
|
||||
// 到期时间:可解析才算有效。空值(没有 TTL)或解析失败(行被写坏)都
|
||||
// 如实标成不可用——宿主据此 fail-closed 当作已过期,绝不 fail-open 地
|
||||
// 当成「永不过期」。
|
||||
expires, err := parseTime(strings.TrimSpace(expiresAt))
|
||||
if err != nil {
|
||||
out.ExpiresValid = false
|
||||
} else {
|
||||
out.ExpiresValid = true
|
||||
}
|
||||
out.ExpiresAt = expires
|
||||
out.CreatedAt, _ = parseTime(createdAt)
|
||||
out.UpdatedAt, _ = parseTime(updatedAt)
|
||||
return out, nil
|
||||
|
||||
@@ -120,6 +120,40 @@ func TestIntentSessionIsolatesOwnersAndProjects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 到期时间:可解析才算有效。为空(没有 TTL)或被写坏都如实标成「不可用」,
|
||||
// 宿主据此 fail-closed 当作已过期——绝不当成「永不过期」。
|
||||
func TestIntentSessionExpiryIsReportedUnusableWhenCorrupted(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", cancelExpectation(),
|
||||
map[string]string{"run_id": "R-1"}, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
|
||||
if err != nil || !got.ExpiresValid || got.ExpiresAt.IsZero() {
|
||||
t.Fatalf("正常写入的到期时间应可用:%+v %v", got, err)
|
||||
}
|
||||
// 直接改坏库里的 expires_at(模拟损坏的行)。
|
||||
if _, err := st.DB().Exec(st.q(`UPDATE intent_session SET expires_at = ? WHERE owner = ? AND conversation_id = ?`),
|
||||
"not-a-timestamp", "alice", "cnv_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if broken.ExpiresValid || !broken.ExpiresAt.IsZero() {
|
||||
t.Fatalf("损坏的到期时间必须如实标成不可用:%+v", broken)
|
||||
}
|
||||
// 没有 TTL(ttl<=0)的写入同样没有可用到期时间。
|
||||
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_2", cancelExpectation(), nil, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
none, err := st.GetIntentSession("alice", "prj_1", "cnv_2")
|
||||
if err != nil || none.ExpiresValid {
|
||||
t.Fatalf("没有到期时间的行不得被当成有效:%+v %v", none, err)
|
||||
}
|
||||
}
|
||||
|
||||
// intentSessionTableExists 双档目录探测(SQLite sqlite_master / PG information_schema)。
|
||||
func intentSessionTableExists(t *testing.T, st *Store) bool {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user