feat(shm): 输出通道 payload 走共享内存调用帧(§13.6)

§13.3 给 ToolInvokeParams 加了调用帧,但 OutputInvokeParams 没跟上——
payload 仍内联在 RPC JSON 里。核实后确认这个 lane 只做了半边:注入侧
(injectParams.TextRef + resolveText)可用,输出侧从内核到插件仍是内联。

改法照搬工具调用的 funccall 帧模型:

- OutputInvokeParams 加 Frame/ArgsLen(Args 仅留给直连 RPC 的测试)
- invokeOutput 序列化参数 → Alloc 帧 → 写帧 → 只传偏移描述符 → defer Free
- 帧尾不预留结果区:output 应答很小("ok"/status map),走 RPC 应答字段即可。
  但 OutputInvokeResult 支持插件把大结果写回帧(ResultRef),并识别
  sharedRefFlagExpand 单独归还扩容块——与 invokeTool 一致。
- 模板 output.invoke 用 frameInput 从帧读参数,无帧才回退内联 Args

为什么要做:output payload 在真机上可能含图片/文件描述等大字段,内联会
撑爆 stdin/stdout 管道。

验证:
- TestPlugin_OutputPayloadViaArena:9000 字节 payload 经帧完整送达(插件回报
  实收长度,不是只看返回值)+ 调用后 arena 归零
- TestE2E_RealTemplateOutputPayloadViaFrame:同上,但用真实 SDK 模板编译的
  插件——生产插件走的就是模板,模板不读帧则此改动等于没做
- TestPlugin_OutputChannelReportsRealFailure 仍绿:同步等真实结果、失败必须
  上报(§9.4)的语义未被破坏
- go test -race ./internal/plugin/... 全绿

plan.md §13.5 也如实标注:注入 side 内核→插件方向仍未接线(procCore.InjectText
直接传字符串,从不 Alloc/Put 构造 TextRef),不是仅打勾的项。
This commit is contained in:
JianFeeeee
2026-09-10 21:28:56 +08:00
parent b71a88a471
commit e2672c56d6
6 changed files with 244 additions and 22 deletions

View File

@ -62,6 +62,19 @@ func (p *e2ePlugin) Start(s *sdk.PluginSDK) error {
ctx.FinalText = ctx.FinalText + "|stage-touched"
return nil
})
// §13.5/13.6 输入/输出 lanetext 不再内联在 RPC 报文里,而是经共享内存
// TextRef 传递(大 payload 不爆 stdin/stdout 管道)。插件侧只调公开 API
// 内核侧负责 Alloc/Put/Free。
s.RegisterOutputChannel("e2e_out", 1, "测试输出通道", sdk.ChannelDef{},
func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
// 回报实际收到的长度:只有完整 payload 经共享帧送达才等于发送长度。
return map[string]interface{}{"status": "sent", "payload_len": len(payload)}, nil
})
s.RegisterInputChannel("e2e_in", sdk.ChannelDef{})
return nil
}
@ -273,3 +286,54 @@ func (p *readerPlugin) Stop() error { return nil }
t.Errorf("FinalText 改写被覆盖:实际 %q", sc.FinalText)
}
}
// §13.6:输出通道 payload 走共享内存调用帧。
//
// 用**真实 SDK 模板**编译的插件验证,而不是手写 testdata——因为生产插件
// (如 qq走的就是模板模板不读帧的话这个改动等于没做。
// 链路:内核 Alloc 帧 → 写 payload → RPC 只传偏移描述符 → 模板 frameInput
// 读出 → 交给插件的输出 handler。
func TestE2E_RealTemplateOutputPayloadViaFrame(t *testing.T) {
bin := buildPluginWithRealTemplate(t, e2ePluginSource)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
p := New("e2e", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
h, ok := core.outputs["e2e_out"]
core.mu.Unlock()
if !ok {
t.Fatal("插件应注册 e2e_out 输出通道")
}
// 9000 字节:超过任何内联预算,只有走帧才可能完整送达。
payload := strings.Repeat("输出", 3000)
res, err := h(map[string]interface{}{"payload": payload, "type": "text"})
if err != nil {
t.Fatalf("发送应成功: %v", err)
}
m, _ := res.(map[string]interface{})
var gotLen int
switch v := m["payload_len"].(type) {
case float64:
gotLen = int(v)
case int:
gotLen = v
}
if gotLen != len(payload) {
t.Fatalf("模板插件收到的 payload 长度 = %d期望 %d模板未从帧读参数", gotLen, len(payload))
}
if used, _ := host.Arena().Stats(); used != 0 {
t.Fatalf("调用结束后 arena 应归零,实际 used=%d", used)
}
}