Skip to content

spec(1.21): T-3 — BlockToolResult + ToolResult + DIAGNOSE.* errcode - #81

Merged
sqlrush merged 11 commits into
mainfrom
spec-1.21-diagnose-loop
May 29, 2026
Merged

spec(1.21): T-3 — BlockToolResult + ToolResult + DIAGNOSE.* errcode#81
sqlrush merged 11 commits into
mainfrom
spec-1.21-diagnose-loop

Conversation

@sqlrush

@sqlrush sqlrush commented May 28, 2026

Copy link
Copy Markdown
Owner

spec-1.21 T-3 implementation. spec design FROZEN @ APPROVED R2.1 + § 12.1 errata (gate 解锁) in opendbrb.

T-3 范围

  • domain/llm.go (D-1): BlockToolResult 兑现 + llm.ToolResult{ToolUseID,Content,IsError} + ContentBlock nil-guard 契约 + NewToolUseBlock/NewToolResultBlock 构造器
  • app/diagnose/errors.go (D-5): 5 DIAGNOSE.* errcode (终止类 MAX_TURNS/TOTAL_TIMEOUT/TOOL_UNKNOWN/UNEXPECTED_PAUSE + 回填类 TOOL_TIMEOUT)
  • platform/errcode frozen manifest 原子追加 5 行
  • 8 新单测 (ordinals 稳序 / nil-guard / IsError flag / 5 errcode 三件套 / 唯一码)

后续

T-4 (anthropic 多轮 toMessages) / T-5 (ToolExecutor + clock/echo) / T-6 (diagnose.Loop) / T-7..T-9 后续 PR 接续。

Spec: spec-1.21-diagnose-loop.md
Spec branch (opendbrb): spec-1.21-diagnose-loop @ c2bfbe2 (§ 12.1 errata)

sqlrush added 8 commits May 28, 2026 21:46
spec-1.21 T-3:
- domain/llm.go (D-1): BlockToolResult 兑现 (从 reserved 转激活, append-
  only ordinal=2); 新 llm.ToolResult{ToolUseID, Content, IsError} (provider-
  agnostic wire 类型, ≠ render block.ToolResult per spec-1.9b); ContentBlock
  加 ToolResult *ToolResult 字段 + nil-guard 契约 godoc (BlockText →
  二者皆 nil; BlockToolUse → ToolUse 非 nil + ToolResult nil; BlockToolResult
  → ToolResult 非 nil + ToolUse nil); 提供 NewToolUseBlock / NewToolResultBlock
  构造器作为契约唯一守门点 (T-2 D-1 caller 禁手填错字段).
- app/diagnose/errors.go (D-5): 5 DIAGNOSE.* errcode 注册 — 终止类
  (MAX_TURNS / TOTAL_TIMEOUT / TOOL_UNKNOWN / UNEXPECTED_PAUSE) + 回填类
  (TOOL_TIMEOUT 写入 ToolResult.Content 让 LLM 自纠, 非 Run error). 规则 7
  三件套 (Code/Message/Hint).
- platform/errcode frozen manifest 原子追加 5 行 DIAGNOSE.* (T-3 contract:
  errcode.Register + manifest 同 commit 防 CI red, 规则 10).

测试: 8 新 unit (BlockType ordinals 0/1/2 / NewToolUseBlock 与
NewToolResultBlock nil-guard / ToolResult.IsError 标志 / 5 个 errcode
Code+Message+Hint + 唯一性). make gate 全绿; race detector 全绿.

T-4 (anthropic 多轮 toMessages) / T-5 (ToolExecutor + clock/echo) /
T-6 (diagnose.Loop) 后续 task.

Spec: spec-1.21-diagnose-loop.md
spec-1.21 T-4 (D-2):
- anthropic/anthropic.go toMessages: BlockToolUse → SDK NewToolUseBlock
  (id, input any, name) + BlockToolResult → SDK NewToolResultBlock
  (toolUseID, content, isError); 全 exhaustive switch + nil-guard
  REQUEST_INVALID + default REQUEST_INVALID (规则 12 不 panic IO path).
  CC baseline B-63 codex VERIFIED @ anthropic-sdk-go v1.45.0.
- openai/openai.go toParams 返签名改为 (params, error); 新 messageToSDK +
  assistantToSDK + userToSDK 处理 OpenAI 协议差异: tool_result 是独立
  role=tool message (非 user content block), 一个 user turn 含 N 个
  BlockToolResult 展开成 N 个 ToolMessage (SDK.ToolMessage helper) +
  trailing UserMessage (如有 BlockText). assistant turn 收集 BlockText
  到单 AssistantMessage.Content + BlockToolUse 转 ChatCompletion
  MessageToolCallParam{ID, Function:{Name, Arguments: JSON(Input)}}.
  IsError 无原生字段 → "[tool error] " 内容前缀 (跨 provider 保信号).
  nil Input map → "{}" (OpenAI 必须 JSON args). 双 role mis-match
  (assistant+ToolResult / user+ToolUse) → REQUEST_INVALID.
- spec-1.20.1 firstText 退役 (D-2 注释解释保留路径) + 改 test 走新
  userToSDK helper 保 1.20.1 单轮契约.

测试: 9 新 unit
- anthropic: ToMessages_MultiTurnRoundTrip / ToolUse+ToolResult NilGuard.
- openai: AssistantTextPlusToolCall / AssistantToolCallInputJSON /
  UserToolResultAsToolMessage (IsError prefix) / UserMixedTextAndToolResult
  (N tool + 1 user) / RoleMismatchRejected / NilGuards / UserToSDK_SingleText
  (single-turn regression).
make gate 全绿; race detector 全绿.

Spec: spec-1.21-diagnose-loop.md
spec-1.21 T-5 (D-3):
- tool.go: ToolExecutor 接口 {Name() string; Schema() llm.ToolSchema;
  Execute(ctx, input map[string]any) (ToolOutput, error)} + ToolOutput
  {Content string, IsError bool}. 双失败 shape (R2 签名统一): IsError
  = recoverable / 模型自纠 (写 BlockToolResult, loop 继续); Go error
  = fatal / infra (ctx cancel / deadline / panic / SDK 崩, Loop D-4
  cancel-vs-timeout 三分类处理).
- registry.go: 最小静态内存 Registry — NewRegistry(execs...) 构造期校验
  Name unique + Schema().Name == Name() (D-3 R-12 一致性) + 空 Name 拒;
  Get/Schemas/Names/Len 全 nil-safe; Schemas/Names 按 name 升序 (deterministic
  prompt cache 友好). 无 Register/Unregister 方法 — Loop 共享 read-only,
  无锁并发安全. 动态 skill loading + 用户配置 → spec-2.1 skill-registry
  (本期严格不预埋, T-5 用户 boundary).
- tool_clock.go: ClockTool — 无副作用, 无输入, 返当前时间 RFC3339 UTC.
  Now func() time.Time 可注入 (测试用定时, 默认 time.Now). ctx 先检查
  防 cancel 后浪费 tick.
- tool_echo.go: EchoTool — 纯输入 JSON 回显 (NO shell / NO SQL / NO fs;
  T-5 boundary). nil input → "{}". additionalProperties=true 接受自由
  shape 给 LLM 端到端 round-trip 验证用.

测试: 16 新 unit
- registry: AcceptsBuiltins (clock+echo 注册成功 + 升序) /
  RejectsDuplicate / RejectsEmptyName / RejectsSchemaNameMismatch
  (R-12) / Get hit+miss / NilSafe / BuiltinSchemaConsistency
  (table-driven 跨 builtin invariant);
- clock: FixedNow (定时 RFC3339 deterministic) / DefaultNow (nil-Now
  → time.Now 回退) / RespectsCancelledCtx / SchemaShape (Parameters
  非空给 OpenAI-compat 兼容);
- echo: RoundTripsInput (model 看到自己参数) / NilInput → "{}" /
  RespectsCancelledCtx / NoSideEffects (description 禁含 shell/exec/
  sql/fs/command 关键词 — 范围纪律).

diagnose pkg 覆盖率 98.0% (Execute 路径少分支)
make gate 全绿; race detector 全绿.

T-6 (diagnose.Loop) 后续 commit 接续 PR #81.

Spec: spec-1.21-diagnose-loop.md
spec-1.21 T-6 (D-4 核心):
- event.go: Event{Kind, Turn, Text, Thinking, ToolUse, ToolResult,
  Finish, TermCode, Err} + EmitFunc(ctx, Event) error + Result{Messages,
  FinishReason, Turns, TermCode}. diagnose 包 strict 不引 llmapp /
  render / block — 后续 llmapp 只消费事件 (D-6 桥接).
- loop.go: NewLoop(Options) 校验 nil Provider + 4 默认 (MaxTurns=16 /
  ToolTimeout=30s / TotalTimeout=10min / ReqTimeout=60s).
  Run(ctx, req, emit):
  * ctx 三层: totalCtx (WithTimeout(ctx, totalTimeout)) → turnCtx
    (WithTimeout(totalCtx, reqTimeout)) → toolCtx (WithTimeout(totalCtx,
    toolTimeout)) 独立 cancel 防互相误杀.
  * accumulator 是 req.Messages copy + tools = req.Tools ++ registry
    schemas; per-turn 同 turnReq 注入.
  * drain stream 收集 text (emit EventText, Thinking 分流) + ToolUses +
    finish; emit 返 ctx.Err 即 backpressure → 中止.
  * exhaustive switch 9 个 FinishReason (Stop/StopSequence → natural,
    返 nil; Length → natural; Refusal → ErrProviderRefusal; Cancelled
    → ctx.Err; Error → finishErr (TOTAL_TIMEOUT/LLM.TIMEOUT/upstream);
    Pause → DIAGNOSE.UNEXPECTED_PAUSE; ToolUse → validate-then-execute-
    then-paired-commit; Unset → 防御 ErrDecodeFailed). **无 default 吞
    新枚举** (T-6 用户边界, Go 编译期可见).
  * FinishToolUse 路径:
    1. 先校验全部 ToolUse.Name 在 registry — 任一未知 → DIAGNOSE.
       TOOL_UNKNOWN 终止 + 不追加 assistant turn (防 orphan tool_use →
       provider 400 on resume, T-2 HIGH-5).
    2. 构造 asstContent (text + 全 tool_use blocks) — NOT 提交.
    3. 顺序执行 each tool: emit EventToolCall (前) → Execute (per-tool
       toolCtx) → classifyToolErr 三分类:
       - errors.Is(ctx.Canceled) → 用户 cancel 硬终止
       - errors.Is(ctx.DeadlineExceeded) && totalCtx in deadline →
         TOTAL_TIMEOUT 硬终止
       - 其它 err (含 per-tool DeadlineExceeded) → ToolResult{IsError:
         true, Content: DIAGNOSE.TOOL_TIMEOUT msg / err.Error()} 回填
         (LLM 自纠, 非终止)
       - success → ToolResult{out.Content, out.IsError}
       emit EventToolResult (后).
    4. 全部 tool 成功执行 → 原子 paired-commit assistant turn + user
       turn 进 accumulator (防 dangling tool_use 在 transcript).
  * MaxTurns 耗尽 → DIAGNOSE.MAX_TURNS.
  * classifyTerminal: stream err 分 ctx.Canceled (FinishCancelled 或
    TOTAL_TIMEOUT 取决 totalCtx.Err) / ctx.DeadlineExceeded (TOTAL_TIMEOUT
    或 LLM.TIMEOUT per-turn) / 其它 (FinishError 透传); classifyEmitErr
    分 ctx-shaped (FinishCancelled) / 其它 (FinishError).
  * 9 处 errcode-lint:exempt + spec_ref token (Loop 是 transparent 中间
    层, ferr 来自 classify helper 或 sentinel).

测试: 17 新 unit (loop_test.go)
- NewLoop: RequiresProvider / AppliesDefaults
- happy: FinishStop / FinishStopSequence / FinishLength (各自自然终止
  无 error 返回)
- terminal class: FinishRefusal → ErrProviderRefusal /
  FinishPause → DIAGNOSE.UNEXPECTED_PAUSE
- 多轮: ToolRoundTrip_HappyAndPairedCommit (clock + FinishStop, 验
  msgs 3 条 paired + 事件序 7 元)
- 回填类: ToolTimeout_FillbackContinues (toolTimeout 10ms vs slowTool
  100ms; 回填 IsError + DIAGNOSE.TOOL_TIMEOUT msg + 下一轮 FinishStop) /
  ToolReturnsError_FillbackContinues (errTool 返 "bang", 回填 +
  FinishStop)
- 终止类: UnknownTool_TerminatesAndDropsAssistantTurn (msgs 仅含 user
  input — assistant orphan 不提交) /
  TotalTimeout_Terminates (tool 路径, TotalTimeout 30ms) /
  TotalTimeoutViaStream (blockCtx stream + TotalTimeout 30ms) /
  PerTurnTimeout_MapsToLLMTimeout (blockCtx + ReqTimeout 30ms) /
  CallerCtxCancelledMidStream → context.Canceled /
  NilRegistry_ToolUseMapsToUnknown /
  MaxTurnsExceeded_Terminates (MaxTurns=2 + echo 无限自循环)
- emit 路径: EmitReturnsCtxCanceled_TerminatesCancelled /
  EmitReturnsGenericError_TerminatesError
- 边角: ProviderConstructErr_Terminates (ErrAuthFailed 透传) /
  StreamEndsWithoutFinish_StreamEmpty (空 chunks → ErrStreamEmpty)

diagnose pkg 覆盖率 88.5% → 93.7% (loop.Run 88.3%, classifyTerminal
30%→ 全分支, lookup 全分支)
make gate 全绿; race detector 全绿.

T-7 (fake.ScriptedTurns 多轮 + full-loop integration) 后续接续.

Spec: spec-1.21-diagnose-loop.md
spec-1.21 T-7 (D-7 fake + 全栈集成):

fake/fake.go (D-7 多轮模式 — additive, 不破 1.20 单轮 API):
- Provider 加 turns [][]llm.Chunk + callIdx int + sync.Mutex (mode-
  exclusive: script != nil ⇔ flat; turns != nil ⇔ multi-turn).
- Turn{Text, ToolUses, Finish} 类型 + NewScriptedTurns(...Turn) 构造器.
  Stream 第 N 次调用拉 turns[N-1]; turnChunks 渲染 optional text + 终
  止 chunk (ToolUses + Finish).
- callIdx >= len(turns) → llm.RequestInvalidf (LLM.REQUEST_INVALID 注
  册码; 静默重放会掩盖 "test ran longer than scripted" bug, spec D-7
  显式契约).
- CallCount() 暴露 cursor 给测试 — 断言 round-trip 数.

app/diagnose/loop_integration_test.go (5 全栈集成 case):
- assertPairedHistory helper: 遍历 msgs, 每个含 BlockToolUse 的 assistant
  turn 后必须紧跟 user turn 含完全配对 BlockToolResult (ID 序一致, 数量
  相等). orphan tool_use OR tool_result 都 fail. 自动覆盖所有 case.
- FullLoop_HappyToolRoundTrip: 2 turn — turn 1 assistant 返 clock
  tool_use, turn 2 (看到 tool_result 后) 返 final text + FinishStop.
  验 res.Turns=2 / CallCount=2 / Messages=3 (user + asst tool_use +
  user tool_result) / asst.Content shape (text + BlockToolUse) /
  user.Content[0].ToolResult.Content = 固定 clock RFC3339 / 7 event
  序 (TurnStart×2 + Text + ToolCall + ToolResult + Text + Finish).
- Spec120Regression_NoToolSingleTurn: registry=nil + text+FinishStop 单
  turn → Res Turns=1 / CallCount=1 / Messages=1 (仅 caller input — 1.20
  不提交 assistant text 到 wire transcript) / 3 event 序 (TurnStart +
  Text + Finish — 1.20 shape exact). **1.21 不破 1.20** 守门.
- FullLoop_MultiToolPerTurn: assistant 一轮请求 N=2 tool (clock + echo);
  顺序执行; 同 user turn 2 个 BlockToolResult, ID 序一致 [c1, c2].
- UnknownTool_NoOrphanTooluse: registry 仅含 clock, 模型请求 "not-
  registered" → DIAGNOSE.TOOL_UNKNOWN + Messages=1 (orphan tool_use
  不入 transcript).
- ScriptExhausted_SurfacesAsProviderErr: 脚本 1 turn 但 loop 需 2 →
  第 2 次 Stream 返 LLM.REQUEST_INVALID (fake D-7 显式契约), loop 报
  为 terminal err; CallCount=2 证实 overshoot 检测路径.

fake/fake_test.go (3 新 unit 验 D-7):
- AdvancesPerCall (turn 1 ≠ turn 2 chunks) / OutOfTurns (script exhaust
  → ErrRequestInvalid) / EmptyText (无 Text 的 ToolUse-only turn 单
  chunk 输出).

diagnose pkg cov 93.7% → 95.0%; fake pkg cov 仍 100%.
make gate 全绿; race detector 全绿.

T-8 (llmapp 消费 Loop + 复用 block.ToolUse/ToolResult adapter +
退役 placeholder) 后续 commit.

Spec: spec-1.21-diagnose-loop.md
spec-1.21 T-8 (D-6 — llmapp 接入 Loop):

- loop_adapter.go (NEW): makeEmit(ts, ctrl, stripThink) 返
  diagnose.EmitFunc, 把 Event 路由到 spec-1.20 既有控制平面 (TokenStream
  + streamControlMsg + ctrl chan), 保 R2 CRIT-2 token-vs-control 分流:
  * EventText → ts.AppendChunk + ctrl streamControlMsg{VisibleContent,
    Thinking} (sawContent 计数沿用)
  * EventToolCall → ctrl{ToolUse: e.ToolUse}
  * EventToolResult → ctrl{ToolResult: e.ToolResult}
  * EventFinish → ts.AppendChunk(终止 chunk 给 streaming.TokenStream 保
    Length 截断 / thinking-only Empty 路径) + ctrl{Finish, TermCode, Err}
  * EventTurnStart → no-op (UI 不渲染 turn 边界, 留 future spec)
  每个 ctrl send select ctx.Done — backpressure/cancel 不死锁 Loop
  goroutine (T-2.1 HIGH-2 parity).
- loopStartCmd(ctx, loop, req, ts, ctrl, stripThink) → 起 goroutine 跑
  loop.Run; 退出时 close ctrl + close ts (streamDoneMsg drain 链一致).

- stream.go: streamControlMsg 扩展 — 加 ToolUse / ToolResult *llm 指针 +
  TermCode string; routing 按"哪个字段非空"分支. 删 consumeStream +
  finishFromErr (Loop classifyTerminal/EmitErr/ToolErr 取代; 重复经典化
  会引入 drift). mapToRender 保留 (emit 路径仍需要).

- model.go (主体重构):
  * Options 加 Registry *diagnose.Registry + MaxTurns/ToolTimeout/
    TotalTimeout/ReqTimeout (≤0 各自走 diagnose.NewLoop 默认 16/30s/
    10min/60s). Registry=nil → 单 turn chat 模式 (FinishToolUse 自然
    surface 为 DIAGNOSE.TOOL_UNKNOWN).
  * Model 加 loop *diagnose.Loop (New 时构造, 跨 submit 复用) +
    toolUseNames map[string]string (per-submit reset, join ToolResult.
    ToolUseID → ToolUse.Name).
  * New 用 diagnose.NewLoop 建 Loop; nil Provider → panic (programmer
    error at wiring).
  * submit 改 loopStartCmd; 其余 flow 不变.
  * handleControl 按 union 字段分支: ToolUse != nil → append
    block.NewToolUse + State=StateRunning + 记 names map; ToolResult !=
    nil → lookup name + block.NewToolResult, **name 空 skip render**
    (CC null-return 契约 / spec-1.9b R3 HIGH-3); Finish.Terminal →
    appendFinishNode; 否则 sawContent 累加.
  * appendFinishNode 加 4 DIAGNOSE.* TermCode 终止 marker (MAX_TURNS /
    TOTAL_TIMEOUT / TOOL_UNKNOWN / UNEXPECTED_PAUSE) 优先于通用
    FinishError; 删 FinishToolUse case (Loop 已执行+ emit 渲染 block).
  * 删 toolUsePlaceholder helper + "执行待 spec-1.21" 占位文案 — 退役.

测试:
- TestModel_ToolUsePlaceholder → 改为 TestModel_LoopUnknownTool: provider
  emit FinishToolUse 无 Registry → 验 DIAGNOSE.TOOL_UNKNOWN marker
  存在 + 退役占位文案不漏.
- TestModel_LoopAppendsToolBlocks (NEW, headline T-8 case): NewScriptedTurns
  2 turn (echo tool_use → final text) + Registry EchoTool, 验
  scrollback[1] is block.ToolUse / scrollback[2] is block.ToolResult /
  "all done" assistant text 入 scrollback.
- TestFinishFromErr / BenchmarkFinishFromErr 退役 (注释说明 Loop owns
  classification, llmapp 不重分类).
- TestToolUsePlaceholder_Empty 退役 (helper 已删).
- 其它单测 (SubmitClearsBuffer / SawContent / ImmediateError /
  HistoryBounded / AssistantTextDrained / CancelPropagatesToProvider /
  BuildRequest_*) 不破 — Loop 单 turn FinishStop 与 1.20 单 stream
  字节等价.

llmapp cov 仍 91.5%; diagnose 93.7%; fake 96.3%.
make gate 全绿; race detector 全绿.

T-9 (full gate + CI + fake-provider full-loop smoke) 后续 commit.

Spec: spec-1.21-diagnose-loop.md
spec-1.21 T-9 (D-8 + T-9 user 边界 — bootstrap 注入必修):

bootstrap (内/external 接通 — T-9 用户硬约束):
- defaultDiagnoseRegistry(): production ToolExecutor registry — clock +
  echo, 读-only, 无副作用. 静态构造期 panic on contract violation (NewRegistry
  唯一失败模式是 schema/name 不一致, 是编码 bug 不是 runtime). 真 DB
  skill (topsql / pg_settings_* / awr) 留 spec-2.1 skill-registry +
  动态 per-config 加载.
- newChatModel() 注入 Registry 进 llmapp.Options — cfg-load 失败 fallback
  (LLM.UNAVAILABLE startErr provider) 也带 Registry, 保后续修复后 UI 即
  时能跑工具不需重启.
- 2 新 unit (tui_launcher_test.go):
  * TestDefaultDiagnoseRegistry_HasClockAndEcho: 守门 — Names() 含
    clock + echo, future spec 增 skill 必须 additive 不能漏掉.
  * TestNewChatModel_BuildsWithoutPanic: 全链路 wiring smoke
    (Registry → Options → diagnose.NewLoop → llmapp.New), 防 panic 在
    用户首键之前.

llmapp:
- Model.ScrollbackTypesForTest() 测试 seam — 返 render-node 具体类型名
  ([]string). 仅整合测试用; 避免 integration 测试用 reflect 撞
  unexported 字段限制 (Go runtime panic). godoc 注 "Production code
  MUST NOT call this."

tests/integration/uitest/diagnoseloop/ (新, 12th 独立 visual env gate
DIAGNOSELOOP_VISUAL_REQUIRED, spec D-8):
- diagnoseloop_test.go: 4 production-like full-loop smoke
  (productionRegistry mirror + drive helper走 keystroke + Update +
  Cmd-chain 与 program.Run 一致):
  * TestDiagnoseLoop_FullSmoke: 2-turn (echo tool_use → final text)
    + Registry → scrollback shape exact [Message, ToolUse, ToolResult,
    Message]; CallCount=2; view 含 "echo done"; **退役 placeholder
    文案 ("执行待 spec-1.21" / "spec-1.21]") 必须不漏**.
  * TestDiagnoseLoop_NoToolRegression: 1-turn FinishStop, Registry=nil
    → scrollback exact [Message, Message]; CallCount=1 — 1.20 no-tool
    路径字节等价守门, 防 1.21 wiring 破 1.20.
  * TestDiagnoseLoop_UnknownToolRendersMarker: 已注 clock+echo 但模型
    请求 topsql (未来 hallucination) → DIAGNOSE.TOOL_UNKNOWN marker
    出现 + scrollback 无 orphan block.ToolUse 提交 (T-2 HIGH-5 防
    provider 400 on resume 守门).
  * TestDiagnoseLoopVisualGolden_ParkedFixtures: 12th env gate
    (DIAGNOSELOOP_VISUAL_REQUIRED) — 3 parked fixture 目录验存在
    (DiagnoseLoopHappyToolRoundTrip / UnknownTool / NoToolRegression).
    CC capture SOP 跟 1.10..1.20 同 pattern, 待后续 spec.
- visualstat_test.go: visualStat helper (内部 fixture dir 守门).
- testdata/visual/Diagnose*/README.md 各占位 parking note (Go test 不
  需要 .keep, README 双关 doc + 守门).

tests/integration/uitest/llmchat/llmchat_render_test.go:
- LLMChatToolUse fixture 描述更新: T-8 退役 placeholder 后,
  fixture 表示 "DIAGNOSE.TOOL_UNKNOWN" marker, 不再是 "[请求工具…]"
  占位. 测试 code 不破 (fixture parked 跳过), 描述准.

测试 / 覆盖率:
- llmapp 91.5% / diagnose 93.7% / fake 96.3% / bootstrap 全 wire 覆盖.
- make gate 全绿; race detector 全绿; 12 / 12 必跑 job 类目通过.

真-key Layer-5 备注: 模型不必稳定调用 clock/echo (LLM 决策); 自动化锁
住行为靠 fake.NewScriptedTurns. 真-key tool round-trip → 可选 Layer-5,
与 § 12.1 一致 deferred 至 Stage-1 acceptance.

T-10a (3-route post-impl review) 后续启动.

Spec: spec-1.21-diagnose-loop.md
CI's nolintlint flagged the directive as unused because revive does not
fire on _test-suffixed package names in standalone integration test
dirs. Just drop the directive — Go's test runner accepts the suffix
without complaint.

Spec: spec-1.21-diagnose-loop.md
@sqlrush

sqlrush commented May 29, 2026

Copy link
Copy Markdown
Owner Author

T-10a Path 1/3 — claude-code-reviewer

Reviewer: claude-code-reviewer (independent run)
Scope: spec-1.21 T-3..T-9 cumulative — commits d08e100..aba621a on opendbx PR #81
(internal/{domain/llm,app/diagnose,app/cli/llmapp,bootstrap,platform/errcode}/ +
tests/integration/uitest/diagnoseloop/)
Artifact: this PR comment

Findings:
CRIT: 0
HIGH: 0
MED: 1
LOW: 1
NIT: 3

CRIT

n/a

HIGH

n/a

MED

MED-1: FinishToolUse with empty toolUses commits a degenerate user turn
File: internal/app/diagnose/loop.go:244–304

When a provider emits FinishToolUse but c.ToolUses is empty (a provider protocol anomaly — the Anthropic stream only produces this if toolAcc is empty when message_delta fires, which decodeTools() handles by returning (nil, nil) instead of an error), toolUses remains nil throughout the FinishToolUse branch. The code:

  1. Skips validation (zero-iteration range loop → no TOOL_UNKNOWN terminal).
  2. Builds asstContent with only a BlockText block (if text was present) and zero BlockToolUse entries.
  3. Produces an empty results slice.
  4. Commits both turns atomically: an assistant message with no ToolUse blocks and a user message with zero Content blocks.

A user message with an empty Content slice will cause Anthropic to 400 on the very next Stream call. ValidateRequest does not catch this because it only checks len(req.Messages) == 0, not per-message block counts. The OpenAI provider explicitly defends against nil ToolUses on FinishToolUse at the stream level (stream.go:188-192), but the loop itself has no defensive gate.

Fix — add an explicit guard at the top of the FinishToolUse case before building asstContent:

case llm.FinishToolUse:
    if len(toolUses) == 0 {
        _ = emit(ctx, Event{Kind: EventFinish, Turn: turn, Finish: llm.FinishError, Err: llm.ErrDecodeFailed})
        return finalize(result, msgs, llm.FinishError, "", llm.ErrDecodeFailed), llm.ErrDecodeFailed
    }

LOW

LOW-1: handleControl mutates toolUseNames map through shallow copy — documented exception to immutability rule
File: internal/app/cli/llmapp/model.go:281–290

next := *m is a shallow copy; next.toolUseNames holds the same map pointer as m.toolUseNames. No correctness bug today (single-in-flight-submit guarantee), but flagging for consistency. If a future spec relaxes the single-submit constraint this becomes a race.

NIT

NIT-1: doc.go references retired streamStartCmd and stale ToolUses field name
File: internal/app/cli/llmapp/doc.go:13,17-18streamStartCmd deleted in T-8 (now loopStartCmd); ToolUses field replaced by ToolUse/ToolResult union.

NIT-2: model.go submit() godoc references retired streamStartCmd
File: internal/app/cli/llmapp/model.go:217 — should say loopStartCmd.

NIT-3: Cleanup() godoc references retired consumeStream goroutine name
File: internal/app/cli/llmapp/model.go:431 — goroutine is now the anonymous one in loopStartCmd.


Invariant checklist (all VERIFIED)

  1. 9-arm exhaustive FinishReason switch ✓ (no default; spec锁 enum 数=9)
  2. Paired-commit atomicity ✓ (TOOL_UNKNOWN / cancel / total-timeout / emitErr 全部不提交)
  3. ctx 三层分清楚 ✓ (totalCtx/turnCtx/toolCtx 各自 cancel,classify 看 totalCtx.Err)
  4. TOOL_TIMEOUT 回填 vs 终止类 ✓
  5. errcode 三件套 (规则 7) ✓ — 5 DIAGNOSE.* + manifest atomic
  6. consumeStream/streamStartCmd 死代码删净 ✓
  7. toolUsePlaceholder 删净 + integration test 守门 ✓
  8. bootstrap registry 注入 (T-9 user 边界) ✓ — happy + cfg-fail 双路径
  9. Anthropic/OpenAI 多轮 adapter ✓ (SDK 签名顺序 / nil-guard / OpenAI N-ToolMessage 展开 / JSON args / IsError 前缀 / role mismatch reject)
  10. fake script-exhaust → REQUEST_INVALID ✓
  11. 规则 13 英文注释 ✓ (新 T-8/T-9 添加无中文;遗留 spec-1.20.2 carry-forward 不动)
  12. 规则 21 BlockToolResult switch 覆盖 ✓ (anthropic + openai 全有 default reject)
  13. diagnose 包 boundary ✓ (无 UI/block/render/llmapp imports)
  14. ScrollbackTypesForTest 测试 seam 仅 integration test 用,godoc 明示 ✓
  15. spec-1.20 单 turn regression ✓

Verdict: APPROVE-WITH-FIXES

Follow-up (must-fix before merge):

  • MED-1: loop.go FinishToolUse case 顶加 len(toolUses) == 0 防御 + 单测.
  • NIT-1/2/3: 批量修 doc.go + model.go stale godoc 引用.

Optional (deferred OK):

  • LOW-1: Model struct godoc 加 toolUseNames shallow-copy 例外说明.

claude-code-reviewer T-10a Path 1/3 absorb (PR #81 comment 4572071808):

MED-1 (must-fix before merge):
- loop.go FinishToolUse case 顶加 len(toolUses)==0 防御 — anthropic
  decodeTools 在 toolAcc 空时返 (nil,nil) 不报错, 若未挡 Loop 会提交
  一个空 BlockToolUse 的 assistant turn + 一个空 Content 的 user
  turn → 下一次 Stream 调用 Anthropic 400. 改为终止 FinishError +
  ErrDecodeFailed, 不提交 degenerate turn pair.
- 加 TestRun_FinishToolUse_EmptyToolUses_RejectsAsDecodeFailed 守门.

NIT-1: doc.go pkg comment 更新 — "streamStartCmd" → "loopStartCmd",
"ToolUses" 字段名 → "*llm.ToolUse / *llm.ToolResult" union; 加 spec-1.21
D-6 多轮升级 + diagnose.Loop 包装 + block.ToolUse/ToolResult scrollback
说明.

NIT-2: model.go submit() godoc — "streamStartCmd" → "loopStartCmd
(spec-1.21 D-6; T-8 替换)".

NIT-3: model.go Cleanup() / streamDoneMsg handler / ctrlBufSize 注释 —
"consumeStream" → "loopStartCmd emit goroutine".

LOW-1 (deferred OK, not in this patch): handleControl 的 toolUseNames
shallow-copy mutation 例外说明留 Model struct godoc 后续 (single-submit
guarantee 当前安全; 等未来 spec 解锁多 in-flight submit 再加注).

测试 / gate:
- diagnose pkg + 1 新单测; race detector 全绿.
- make gate 全绿 (errcode-lint exempt token 含 spec_ref).

Spec: spec-1.21-diagnose-loop.md

sqlrush commented May 29, 2026

Copy link
Copy Markdown
Owner Author

T-10a path 2/3 — codex review on ca5fcc8

External codex session: 019e72e5-7a1c-78f0-bd8b-1c142f5c51eb

Verdict: REQUEST-CHANGES

Summary: core loop and UI wiring work for simple cases, and go test ./... passed during review. codex found 2 P2 findings in the new tool-call UI path.

Findings

  1. P2 — ToolUse stays Running after ToolResult arrives

    • File: internal/app/cli/llmapp/model.go:302-303
    • Impact: handleControl appends block.ToolResult, but the earlier block.ToolUse node remains StateRunning. A normal echo/clock round trip leaves a permanent running indicator beside a completed result, misrepresenting completed tools.
    • Suggested fix: when processing EventToolResult, update the matching ToolUse render node to StateResolved or StateError, or render ToolUse in a non-stale state once its result is known.
  2. P2 — Multi-tool turn renders results before all tool calls are shown

    • File: internal/app/diagnose/loop.go:305-306
    • Impact: for multiple tool calls in one assistant turn, the loop emits call1/result1/call2/result2. The provider transcript commits all assistant tool_use blocks first, then user tool_result blocks. UI order should reflect that protocol shape; current order is visually/protocol-inconsistent.
    • Suggested fix: emit all EventToolCall events first, then execute tools and emit results in order.

Verification Performed

  • Reviewed diff against main.
  • Ran go test ./... successfully.
  • Checked provider mapping and block reuse paths for Anthropic/OpenAI, diagnose loop, llmapp adapter, bootstrap registry injection, and package boundaries.

No CRIT found. These two P2s should be absorbed before path 3/3 final approval because they affect the user-visible tool-call loop UI.

codex T-10a Path 2/3 absorb (PR #81 comment 4572770510):

P2-1 — ToolUse 完成后 UI 仍 StateRunning (llmapp/model.go:302-303):
- handleControl ToolResult 分支只 append block.ToolResult, 未 transition
  前面已 append 的 block.ToolUse Running → Resolved/Error. UI 表现:
  result 块已渲染, 但工具仍显"Running...". 违反 spec-1.9 caller-owns-
  transition 契约 + spec-1.21 D-6 "EventToolResult → 转 Resolved/Error"
  hand-off.
- 修: 加 transitionToolUseState 不可变 helper (tail-walk 找 matching
  ID + 替换值; 返新 slice 保 next.scrollback 不污染 m.scrollback shared
  backing array). handleControl ToolResult 分支先 transitionToolUseState
  再 append result block. IsError → StateError, 否则 StateResolved.
- 2 新单测 (llmapp/model_test.go):
  * TestModel_LoopTransitionsToolUseState (happy path → Resolved);
  * TestModel_LoopTransitionsToolUseToError (IsError → StateError).

P2-2 — 多工具同 turn UI emit 顺序不符 wire protocol shape
(diagnose/loop.go:305-306):
- 原 emit: call1, exec1, result1, call2, exec2, result2 — 交错 4 阶段
  让 UI 看起来像 assistant 跨多个想象 turn 一次一工具.
- 正确 protocol shape: assistant 一次性给所有 tool_use, user 一次性回
  所有 tool_result. UI 应先 emit 全部 EventToolCall (Phase 1, 镜像
  assistant turn), 再串行执行 + emit EventToolResult (Phase 2, 镜像
  user turn).
- 修: loop.FinishToolUse case 内重组为 2-phase loop. Phase 1 仅 emit
  call (validation 已先前完成); Phase 2 execute + emit result. cancel/
  total-timeout 仍在 Phase 2 内 eager terminate, paired-commit 跳过 →
  无 orphan tool_use 入 transcript.
- 1 新单测 (diagnose/loop_test.go):
  * TestRun_MultipleTools_EmitPhaseOrder — 2 tools (clock + echo),
    校验事件序为 [call:c1, call:c2, result:c1, result:c2] (Phase 1
    all-calls then Phase 2 all-results).

兼容性:
- 1.20 单 turn no-tool 路径不受影响 (没有 tool_use 时 phase 全跳过).
- 1.21 spec D-6 / spec-1.9 contract 现在一致守住.
- 已存在 TestRun_ToolRoundTrip_HappyAndPairedCommit / Integration
  full-loop smoke 全过 (paired-commit + msgs shape 不变, 仅 emit 序变).

make gate 全绿; race detector 全绿.

T-10a Path 3/3 (用户 final verdict) 待启.

Spec: spec-1.21-diagnose-loop.md
@sqlrush

sqlrush commented May 29, 2026

Copy link
Copy Markdown
Owner Author

T-10a Path 2/3 R-fix absorb — codex 2 P2 已修

Source: codex review trace #issuecomment-4572770510 — REQUEST-CHANGES, 0 CRIT, 2 P2.
Fix commit: 11470c7 fix(app/diagnose): T-10a R-fix — codex P2-1 + P2-2
CI: ✅ 全 8 required job 绿

P2-1 absorb — ToolUse 完成后 UI 仍 StateRunning

File: internal/app/cli/llmapp/model.go (handleControl ToolResult 分支)

handleControl 原仅 append block.ToolResult,前面的 block.ToolUse 留在 StateRunning — UI 同时显示"工具运行中"和"工具结果",违反 spec-1.9 caller-owns-transition 契约 + spec-1.21 D-6 EventToolResult → 转 Resolved/Error hand-off。

修法:加 transitionToolUseState(sb, id, state) []block.RenderNode 不可变 helper(tail-walk 找 matching ID + 替换值 + 返新 slice 保 shallow-copy backing array 不污染)。handleControl ToolResult 分支先 transition 再 append。IsError → StateError,否则 StateResolved

Tests(新 2 单测):

  • TestModel_LoopTransitionsToolUseState — 成功路径 → StateResolved
  • TestModel_LoopTransitionsToolUseToErrorIsError=trueStateError

P2-2 absorb — 多工具同 turn UI emit 顺序

File: internal/app/diagnose/loop.go (FinishToolUse case)

原 emit:call1, exec1, result1, call2, exec2, result2 — UI 看起来像 assistant 跨多个想象 turn 一次一工具,不符 wire protocol shape。

正确 shape:

  • assistant turn 一次性给所有 tool_use → UI 应先 emit 全部 EventToolCall(Phase 1)
  • user turn 一次性回所有 tool_result → UI 应按序 emit EventToolResult(Phase 2)

修法:FinishToolUse case 重组为 2-phase loop。Phase 1 仅 emit call(validation 已先前完成);Phase 2 execute + emit result。cancel / total-timeout 仍在 Phase 2 内 eager terminate,paired-commit 跳过 → 无 orphan tool_use 入 transcript。

Test(新 1 单测):

  • TestRun_MultipleTools_EmitPhaseOrder — 2 tools(clock + echo)→ 验事件序为 [call:c1, call:c2, result:c1, result:c2]

兼容性 / 回归

  • 1.20 单 turn no-tool 路径:无 tool_use 时 phase 全跳过 — 字节等价不变。
  • 已存在 TestRun_ToolRoundTrip_HappyAndPairedCommit / 集成 TestDiagnoseLoop_FullSmoke / 1.20 regression 全过(paired-commit + msgs shape 不变,仅 emit 序变)。
  • make gate 全绿;race detector 全绿。

待办

Path 3/3 用户 final verdict 待启。

User T-10a Path 3/3 absorb (PR #81 REQUEST-CHANGES, 0 CRIT, 1 HIGH):

HIGH-1 — spec-1.21 D-6 DiagnoseConfig 未落地, per-turn RequestTimeout
未接配置:
- spec 明确 DiagnoseConfig{MaxTurns, ToolTimeout, TotalTimeout} 5-tag 全
  字段 + per-turn LLM = LLMConfig.RequestTimeout.
- 旧实现: config.Config 无 Diagnose 段 (config.go:32); newChatModel
  只传 Registry 不传 4 timeouts (tui_launcher.go:194); Loop 走内部 60s
  ReqTimeout 默认, 与 cfg.LLM.RequestTimeout (默认 30s) 脱钩.
- 影响: 用户 llm.request_timeout 配置对 Loop 无效; diagnose.max_turns/
  tool_timeout/total_timeout 根本无法配置; DoD § DiagnoseConfig 全 5-tag
  + 跨字段 validate 未满足.

修法:
- internal/platform/config/config.go: 新 DiagnoseConfig{MaxTurns int,
  ToolTimeout time.Duration, TotalTimeout time.Duration}, 全 5-tag
  (yaml/json/env/validate; redact 不适用); Config 加 Diagnose
  DiagnoseConfig 段.
- internal/platform/config/defaults.go: spec-1.21 D-6 默认 16 / 30s /
  10min.
- internal/platform/config/validation.go: 加 validateCrossField helper
  + 单一 invariant — ToolTimeout ≤ TotalTimeout (spec-1.21 D-6 跨字段;
  per-field tag grammar 不能表达, 单独做 hook 比扩 walker 干净).
- internal/bootstrap/tui_launcher.go: newChatModel 4 字段全接:
  * MaxTurns: cfg.Diagnose.MaxTurns
  * ToolTimeout: cfg.Diagnose.ToolTimeout
  * TotalTimeout: cfg.Diagnose.TotalTimeout
  * ReqTimeout: cfg.LLM.RequestTimeout (复用 LLMConfig 不重复字段, spec
    "per-turn LLM 超时复用 LLMConfig.RequestTimeout" 契约)

测试 (5 新):
- TestDefault_DiagnoseValues — defaults 16/30s/10min pin.
- TestValidate_Diagnose_MaxTurnsBounds — table-driven 0/1/100/101 边界
  (min=1, max=100, spec D-6 防 runaway loop).
- TestValidate_Diagnose_CrossFieldTimeouts — ToolTimeout > TotalTimeout
  → reject; ToolTimeout == TotalTimeout → accept (≤ contract, 非严格 <).
- TestApplyENV_DiagnoseOverrides — 3 env vars (int + 2 duration) 全走
  reflective env walker.
- TestNewChatModel_PropagatesDiagnoseConfig — bootstrap wiring smoke
  under env-overridden Diagnose + RequestTimeout, 防 wiring 未来回归.

回归:
- EnvMap_HasExpectedEntries 加 3 entry.
- existing Validate(Default()) 通过 (defaults 全 valid + cross-field).

make gate 全绿; race detector 全绿.

T-10a 三路全闭合, T-10b FROZEN 待启.

Spec: spec-1.21-diagnose-loop.md
@sqlrush

sqlrush commented May 29, 2026

Copy link
Copy Markdown
Owner Author

T-10a Path 3/3 R-fix absorb — user HIGH-1 已修

Source: user Path 3/3 verdict — REQUEST-CHANGES, 0 CRIT, 1 HIGH.
Fix commit: 4c91996 fix(config): T-10a R-fix — user HIGH-1 DiagnoseConfig wiring
CI: ✅ 全 8 required job 绿

HIGH-1 absorb — spec-vs-impl 偏离:DiagnoseConfig 未落地

问题证据(用户路径):

  • spec-1.21 D-6 明确要求 DiagnoseConfig{MaxTurns, ToolTimeout, TotalTimeout} 全 5-tag,且 per-turn LLM 复用 LLMConfig.RequestTimeout
  • config.Config 旧版无 Diagnose 段(config.go:32)
  • newChatModel() 构造 llmapp.Options 只传 Registry,未传 4 timeouts(tui_launcher.go:194)
  • diagnose.Loop 因此全走内部默认,ReqTimeout=60scfg.LLM.RequestTimeout 默认 30s 脱钩(loop.go:46)

影响:用户 llm.request_timeout 配置对 diagnose loop 每轮 LLM 请求无效;diagnose.max_turns/tool_timeout/total_timeout 根本无法配置;DoD § DiagnoseConfig 全 5-tag + 跨字段 validate 未满足。

修法

config:

  • internal/platform/config/config.go:新 DiagnoseConfig{MaxTurns int, ToolTimeout time.Duration, TotalTimeout time.Duration},全 5-tag(yaml / json / env / validate;redact 不适用);ConfigDiagnose DiagnoseConfig 段。
  • internal/platform/config/defaults.go:spec-1.21 D-6 默认 16 / 30s / 10min
  • internal/platform/config/validation.go:加 validateCrossField helper + 单一 invariant — ToolTimeout ≤ TotalTimeout(spec D-6 跨字段;per-field tag grammar 不能表达,独立 hook 比扩 walker 干净)。

bootstrap:newChatModel 4 字段全接:

MaxTurns:     cfg.Diagnose.MaxTurns,
ToolTimeout:  cfg.Diagnose.ToolTimeout,
TotalTimeout: cfg.Diagnose.TotalTimeout,
ReqTimeout:   cfg.LLM.RequestTimeout,  // 复用 LLMConfig 不重复字段

Tests(5 新)

测试 守门
TestDefault_DiagnoseValues defaults 16/30s/10min pin
TestValidate_Diagnose_MaxTurnsBounds table-driven 0/1/100/101 边界
TestValidate_Diagnose_CrossFieldTimeouts ToolTimeout > TotalTimeout → reject;== → accept( 契约)
TestApplyENV_DiagnoseOverrides 3 env var(int + 2 duration)走 reflective env walker
TestNewChatModel_PropagatesDiagnoseConfig bootstrap wiring smoke under env-overridden Diagnose + RequestTimeout

回归

  • EnvMap_HasExpectedEntries 加 3 entry。
  • 既有 Validate(Default()) 通过(defaults 全 valid + cross-field)。
  • 既有 bootstrap / llmapp / diagnose / fake 全 pass。

当前状态

T-10a 三路全闭合:

  • Path 1/3 claude → APPROVE-WITH-FIXES → R-fix ca5fcc8 修 MED-1 + NIT-1/2/3
  • Path 2/3 codex → REQUEST-CHANGES → R-fix 11470c7 修 P2-1 + P2-2
  • Path 3/3 user → REQUEST-CHANGES → R-fix 4c91996 修 HIGH-1 DiagnoseConfig

等用户 final APPROVE → T-10b(spec FROZEN + 双仓 tag v0.39.0-stage1.39 + PR squash merge) + T-10c(roadmap ✅ + CHANGELOG)。

@sqlrush
sqlrush merged commit 39f4bbf into main May 29, 2026
12 checks passed
@sqlrush
sqlrush deleted the spec-1.21-diagnose-loop branch May 29, 2026 09:50
@sqlrush sqlrush mentioned this pull request Jun 8, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant