feat(extension): Go SDK, generated DTOs, example plugin, and host conformance

Stage 9 of the unified extension kernel / Extension Protocol v1 plan.

Problem: Extension Protocol v1 had a host implementation but no supported
way for third parties to write sidecars, no proof the contract works from
both ends, and no generator-produced SDK artifacts.

Fix:
- sdk/go: a new nested module (github.com/esengine/DeepSeek-Reasonix/sdk/go,
  Go 1.23, standard library only, zero requires). Serve(ctx, Handler,
  Options) drives the full lifecycle: initialize barrier with
  manifest-subset enforcement, interceptor routing with wildcard fallback
  and Continue/Block/Replace/Allow/Deny helpers, observation hooks, a
  streaming Provider pump (1-based contiguous seqs, exactly one stream/end,
  no chunks after cancel), structured HostUI publish/request helpers
  (structured-only by construction), transparent content-ref rehydration
  with paging and SHA-256 verification, panic isolation, bounded queues,
  and bounded shutdown. 44 unit tests, race-clean, against a scriptable
  fake host.
- The protocol generator now emits a fourth artifact,
  sdk/go/types_generated.go (wire DTOs, enums, method names, frozen limits
  and error specs), replacing the handwritten mirror; handwritten
  validators/helpers moved to types_ext.go and the public API is unchanged
  (the 44 tests compile and pass unmodified). A new exported
  protocol.EnumValues() keeps enum sets single-sourced; the three
  pre-existing artifacts regenerate byte-identical.
- sdk/go/examples/fullsidecar: the reference extension — input.receive
  rewrite, tool.before block and argument rewrite, system_prompt.build
  strategy, a fake streaming provider (text, tool call, usage, cancel
  handling), UI status/card publish plus an action-driven form request,
  and clean shutdown.
- internal/extension/conformance: 17 hermetic host<->SDK round-trip tests
  (root module, so the CI three-OS matrix covers them) building the
  example and driving it over the real wire: handshake accept and
  capability_not_declared, intercept replace/block, strategy replacement,
  provider stream/cancel/usage with seq discipline, >64 KiB content-ref
  rehydration, UI action/form round-trips, intercept timeout, mid-intercept
  crash fail-fast, unknown method, oversized frame, and bounded shutdown
  with exit 0.

Verification: cd sdk/go && go test ./... and -race pass with a zero-require
go.mod; go test ./internal/extension/... passes (conformance also -race);
both protocol generators -check green; boot golden baselines unregenerated;
root go build ./... clean; no new dependencies anywhere.
This commit is contained in:
SivanCola committed 2026-08-04 02:42:06 +08:00
1 parent ae8a7fc0f7
commit 0b51315538
19 files changed
+6462 -2

No files matched your search

@@ -0,0 +1,689 @@
// Package conformance runs the Host↔SDK bidirectional conformance suite: the
// SDK's reference example (sdk/go/examples/fullsidecar) is built once per
// test run and driven against the real host sidecar client
// (internal/extension/sidecar) over its stdin/stdout, plus a raw-frame driver
// for the transport-level cases (unknown method, oversized frame, bounded
// shutdown exit status). The suite is hermetic: temp dirs, no network, no
// real providers.
package conformance
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/extension/protocol"
"reasonix/internal/extension/sidecar"
"reasonix/internal/pluginpkg"
"reasonix/internal/rpcwire"
)
// examplePath is the built fullsidecar binary, shared by every test.
var examplePath string
// TestMain builds the SDK example once for the whole run. The suite skips
// cleanly when no go toolchain is available (minimal test environments); a
// present toolchain that cannot build the example is a real failure.
func TestMain(m *testing.M) {
if _, err := exec.LookPath("go"); err != nil {
fmt.Fprintln(os.Stderr, "conformance: go toolchain unavailable; skipping suite")
os.Exit(0)
}
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
fmt.Fprintln(os.Stderr, "conformance: cannot locate source root")
os.Exit(1)
}
sdkDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "sdk", "go")
dir, err := os.MkdirTemp("", "fullsidecar-conformance-")
if err != nil {
fmt.Fprintln(os.Stderr, "conformance: MkdirTemp:", err)
os.Exit(1)
}
defer os.RemoveAll(dir)
binary := filepath.Join(dir, "fullsidecar")
if runtime.GOOS == "windows" {
binary += ".exe"
}
build := exec.Command("go", "build", "-C", sdkDir, "-o", binary, "./examples/fullsidecar")
if out, err := build.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "conformance: build example: %v\n%s", err, out)
os.Exit(1)
}
examplePath = binary
os.Exit(m.Run())
}
// ---------------------------------------------------------------------------
// Host client fixture
// ---------------------------------------------------------------------------
const (
testPluginID = "conformance-ext"
testProvider = "plugin/conformance-ext/fake/echo"
)
// startExample launches the example under the real host sidecar client with a
// manifest that declares everything the example contributes. mutate tunes the
// runtime spec (env, under-declared manifests); opts tunes ClientOptions.
func startExample(t *testing.T, mutate func(rt *pluginpkg.RuntimeSpec), opts func(*sidecar.ClientOptions)) *sidecar.Client {
t.Helper()
rt := &pluginpkg.RuntimeSpec{
Command: examplePath,
Intercepts: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"},
Replaces: []string{"system_prompt"},
Capabilities: []string{"providers", "ui"},
}
if mutate != nil {
mutate(rt)
}
root := t.TempDir()
pkg := pluginpkg.Package{
Root: root,
ManifestKind: "reasonix",
Manifest: pluginpkg.Manifest{Name: testPluginID, Version: "1.0.0", Runtime: rt},
}
installed := pluginpkg.InstalledPlugin{Name: testPluginID, Version: "1.0.0", Enabled: true, Root: root}
clientOpts := sidecar.ClientOptions{
Package: pkg,
Installed: installed,
Session: protocol.SessionContext{SessionID: "sess-conf", WorkspaceRoot: "/ws", Generation: 1},
}
if opts != nil {
opts(&clientOpts)
}
client, err := sidecar.StartClient(context.Background(), clientOpts)
if err != nil {
t.Fatalf("StartClient: %v", err)
}
t.Cleanup(func() { _ = client.Close() })
return client
}
// intercept is a small shortcut for the common blocking-intercept call.
func intercept(t *testing.T, client *sidecar.Client, event protocol.InterceptEvent, payload string) protocol.InterceptResult {
t.Helper()
result, err := client.Intercept(context.Background(), event, json.RawMessage(payload), 10*time.Second)
if err != nil {
t.Fatalf("Intercept(%s): %v", event, err)
}
return result
}
// decodeReplacement strict-decodes an intercept replacement into out.
func decodeReplacement(t *testing.T, result protocol.InterceptResult, out any) {
t.Helper()
if result.Decision != protocol.DecisionReplace {
t.Fatalf("decision = %q (reason %q), want replace", result.Decision, result.Reason)
}
if len(result.Replacement) == 0 {
t.Fatal("replace decision carries no replacement")
}
decoder := json.NewDecoder(bytes.NewReader(result.Replacement))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
t.Fatalf("replacement does not decode: %v", err)
}
}
// ---------------------------------------------------------------------------
// Stub UI handler and stream router
// ---------------------------------------------------------------------------
// stubUI records host/ui/publish calls and answers host/ui/request through a
// programmable function (default: the user cancelled).
type stubUI struct {
mu sync.Mutex
published []protocol.UIPublishParams
requestFn func(p protocol.UIRequestParams) (protocol.UIRequestResult, error)
}
func (s *stubUI) Publish(_ context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.published = append(s.published, p)
return protocol.UIPublishResult{Accepted: true}, nil
}
func (s *stubUI) Request(_ context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
s.mu.Lock()
fn := s.requestFn
s.mu.Unlock()
if fn != nil {
return fn(p)
}
return protocol.UIRequestResult{Cancelled: true}, nil
}
// publishedOfKind returns the recorded publishes of one surface kind.
func (s *stubUI) publishedOfKind(kind protocol.UISurfaceKind) []protocol.UIPublishParams {
s.mu.Lock()
defer s.mu.Unlock()
var out []protocol.UIPublishParams
for _, p := range s.published {
if p.Kind == kind {
out = append(out, p)
}
}
return out
}
func (s *stubUI) publishedCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.published)
}
// stubStreams records routed provider stream notifications.
type stubStreams struct {
mu sync.Mutex
chunks []protocol.StreamChunkParams
ends []protocol.StreamEndParams
}
func (s *stubStreams) RouteStreamChunk(p protocol.StreamChunkParams) {
s.mu.Lock()
defer s.mu.Unlock()
s.chunks = append(s.chunks, p)
}
func (s *stubStreams) RouteStreamEnd(p protocol.StreamEndParams) {
s.mu.Lock()
defer s.mu.Unlock()
s.ends = append(s.ends, p)
}
func (s *stubStreams) snapshot() (chunks []protocol.StreamChunkParams, ends []protocol.StreamEndParams) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]protocol.StreamChunkParams(nil), s.chunks...), append([]protocol.StreamEndParams(nil), s.ends...)
}
// waitFor polls cond until it holds or the deadline expires.
func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// protocolReason extracts the frozen protocol error reason from err, whether
// it travels as a local *protocol.ProtocolError or as a wire-shaped
// *rpcwire.RPCError.
func protocolReason(t *testing.T, err error) protocol.ErrorReason {
t.Helper()
var protocolErr *protocol.ProtocolError
if errors.As(err, &protocolErr) {
return protocolErr.Reason
}
var rpcErr *rpcwire.RPCError
if errors.As(err, &rpcErr) {
var data protocol.ProtocolErrorData
raw, _ := json.Marshal(rpcErr.Data)
if json.Unmarshal(raw, &data) == nil && data.Reason != "" {
return data.Reason
}
}
t.Fatalf("error %v carries no protocol reason", err)
return ""
}
// ---------------------------------------------------------------------------
// Tests: initialize handshake
// ---------------------------------------------------------------------------
// TestHandshakeAccepted proves the host accepts the example's full
// declaration: subscriptions, the system_prompt strategy slot, the namespaced
// provider, and the demo UI action.
func TestHandshakeAccepted(t *testing.T) {
client := startExample(t, nil, nil)
h := client.Handshake()
if h.Name != testPluginID || h.Version != "1.0.0" {
t.Fatalf("identity = %q/%q", h.Name, h.Version)
}
wantSubs := map[string]bool{"input.receive": true, "tool.before": true, "system_prompt.build": true, "session.start": true}
if len(h.Subscriptions) != len(wantSubs) {
t.Fatalf("subscriptions = %v", h.Subscriptions)
}
for _, sub := range h.Subscriptions {
if !wantSubs[sub] {
t.Fatalf("unexpected subscription %q in %v", sub, h.Subscriptions)
}
}
if len(h.Replaces) != 1 || h.Replaces[0] != "system_prompt" {
t.Fatalf("replaces = %v", h.Replaces)
}
if len(h.Providers) != 1 || h.Providers[0].Ref != testProvider {
t.Fatalf("providers = %+v", h.Providers)
}
if len(h.UIActions) != 1 || h.UIActions[0].ActionID != "demo" {
t.Fatalf("uiActions = %+v", h.UIActions)
}
}
// TestHandshakeUnderDeclaredRejected proves the manifest contract: an
// extension activating a capability its manifest did not declare is refused
// with capability_not_declared.
func TestHandshakeUnderDeclaredRejected(t *testing.T) {
rt := &pluginpkg.RuntimeSpec{
Command: examplePath,
Intercepts: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"},
Replaces: []string{"system_prompt"},
Capabilities: []string{"ui"}, // no "providers": the example still declares one
}
root := t.TempDir()
pkg := pluginpkg.Package{
Root: root,
ManifestKind: "reasonix",
Manifest: pluginpkg.Manifest{Name: testPluginID, Version: "1.0.0", Runtime: rt},
}
installed := pluginpkg.InstalledPlugin{Name: testPluginID, Version: "1.0.0", Enabled: true, Root: root}
_, err := sidecar.StartClient(context.Background(), sidecar.ClientOptions{
Package: pkg,
Installed: installed,
Session: protocol.SessionContext{SessionID: "sess-conf", WorkspaceRoot: "/ws", Generation: 1},
})
if err == nil {
t.Fatal("StartClient succeeded with an under-declared manifest")
}
if reason := protocolReason(t, err); reason != protocol.ErrCapabilityNotDeclared {
t.Fatalf("reason = %q, want %q (err %v)", reason, protocol.ErrCapabilityNotDeclared, err)
}
}
// ---------------------------------------------------------------------------
// Tests: intercepts and strategy
// ---------------------------------------------------------------------------
// TestInputReceiveRewrite drives the "/fs " trigger: the example replaces the
// input; ordinary input continues untouched.
func TestInputReceiveRewrite(t *testing.T) {
client := startExample(t, nil, nil)
result := intercept(t, client, protocol.EventInputReceive, `{"text":"/fs hello world"}`)
var replaced struct {
Text string `json:"text"`
}
decodeReplacement(t, result, &replaced)
if replaced.Text != "hello world [rewritten by fullsidecar]" {
t.Fatalf("rewritten text = %q", replaced.Text)
}
result = intercept(t, client, protocol.EventInputReceive, `{"text":"plain input"}`)
if result.Decision != protocol.DecisionContinue {
t.Fatalf("decision for plain input = %q, want continue", result.Decision)
}
}
// TestToolBeforeBlockAndRewrite covers the tool interception: the denied tool
// is blocked, the rewritten tool's arguments gain the sandbox flag, and
// unrelated tools continue.
func TestToolBeforeBlockAndRewrite(t *testing.T) {
client := startExample(t, nil, nil)
blocked := intercept(t, client, protocol.EventToolBefore, `{"name":"dangerous_exec","arguments":"{}"}`)
if blocked.Decision != protocol.DecisionBlock {
t.Fatalf("decision = %q, want block", blocked.Decision)
}
if !strings.Contains(blocked.Reason, "dangerous_exec") {
t.Fatalf("block reason = %q", blocked.Reason)
}
rewritten := intercept(t, client, protocol.EventToolBefore, `{"name":"read","arguments":"{\"path\":\"/etc/hosts\"}"}`)
var replacement struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
decodeReplacement(t, rewritten, &replacement)
if replacement.Name != "read" {
t.Fatalf("replacement name = %q", replacement.Name)
}
var args map[string]any
if err := json.Unmarshal([]byte(replacement.Arguments), &args); err != nil {
t.Fatalf("rewritten arguments are not a JSON object: %v", err)
}
if args["sandbox"] != true || args["path"] != "/etc/hosts" {
t.Fatalf("rewritten arguments = %v", args)
}
passthrough := intercept(t, client, protocol.EventToolBefore, `{"name":"write","arguments":"{}"}`)
if passthrough.Decision != protocol.DecisionContinue {
t.Fatalf("decision for unrelated tool = %q, want continue", passthrough.Decision)
}
}
// TestSystemPromptStrategy proves the strategy-slot replacement lands: the
// example owns system_prompt.build and wraps the base prompt.
func TestSystemPromptStrategy(t *testing.T) {
client := startExample(t, nil, nil)
result := intercept(t, client, protocol.EventSystemPromptBuild, `{"prompt":"BASE PROMPT","workspaceRoot":"/ws"}`)
var replacement struct {
Prompt string `json:"prompt"`
WorkspaceRoot string `json:"workspaceRoot"`
}
decodeReplacement(t, result, &replacement)
if !strings.Contains(replacement.Prompt, "fullsidecar demo strategy") || !strings.Contains(replacement.Prompt, "BASE PROMPT") {
t.Fatalf("replacement prompt = %q", replacement.Prompt)
}
if replacement.WorkspaceRoot != "/ws" {
t.Fatalf("workspaceRoot = %q", replacement.WorkspaceRoot)
}
}
// ---------------------------------------------------------------------------
// Tests: provider broker
// ---------------------------------------------------------------------------
// TestProviderCatalog fetches the extension's provider catalog through the
// host client.
func TestProviderCatalog(t *testing.T) {
client := startExample(t, nil, nil)
providers, err := client.ProviderCatalog(context.Background())
if err != nil {
t.Fatalf("ProviderCatalog: %v", err)
}
if len(providers) != 1 {
t.Fatalf("catalog = %+v", providers)
}
desc := providers[0]
if desc.Ref != testProvider || desc.Model != "echo" || !desc.Tools || !desc.Reasoning {
t.Fatalf("descriptor = %+v", desc)
}
}
// TestProviderStream opens one stream and asserts the scripted completion
// arrives in order with contiguous seqs, a tool call, usage, and a clean end.
func TestProviderStream(t *testing.T) {
streams := &stubStreams{}
client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.Streams = streams })
opened, err := client.ProviderStreamOpen(context.Background(), protocol.StreamOpenParams{
StreamID: "s-full",
ProviderRef: testProvider,
Request: protocol.ProviderRequest{Messages: []protocol.ProviderMessage{}, Tools: []protocol.ProviderToolSchema{}},
})
if err != nil {
t.Fatalf("ProviderStreamOpen: %v", err)
}
if !opened.Accepted {
t.Fatal("stream open was not accepted")
}
waitFor(t, "stream end", 10*time.Second, func() bool {
_, ends := streams.snapshot()
return len(ends) == 1
})
chunks, ends := streams.snapshot()
if len(chunks) != 5 {
t.Fatalf("received %d chunks, want 5: %+v", len(chunks), chunks)
}
for i, chunk := range chunks {
if chunk.StreamID != "s-full" || chunk.Seq != int64(i+1) {
t.Fatalf("chunk %d = stream %q seq %d, want s-full/%d", i, chunk.StreamID, chunk.Seq, i+1)
}
}
if chunks[0].Chunk.Type != protocol.ChunkText || chunks[0].Chunk.Text != "fake-hello " {
t.Fatalf("chunk 1 = %+v", chunks[0].Chunk)
}
if chunks[1].Chunk.Type != protocol.ChunkText || chunks[1].Chunk.Text != "fake-world" {
t.Fatalf("chunk 2 = %+v", chunks[1].Chunk)
}
call := chunks[2].Chunk
if call.Type != protocol.ChunkToolCall || call.ToolCall == nil || call.ToolCall.Name != "lookup" || call.ToolCall.ID != "call-1" {
t.Fatalf("tool call chunk = %+v", call)
}
usage := chunks[3].Chunk
if usage.Type != protocol.ChunkUsage || usage.Usage == nil || usage.Usage.TotalTokens != 12 || usage.Usage.PromptTokens != 5 {
t.Fatalf("usage chunk = %+v", usage)
}
if chunks[4].Chunk.Type != protocol.ChunkDone {
t.Fatalf("final chunk type = %q, want done", chunks[4].Chunk.Type)
}
end := ends[0]
if end.StreamID != "s-full" || end.LastSeq != 5 || end.Error != "" || end.Interrupted {
t.Fatalf("stream end = %+v", end)
}
}
// TestProviderStreamCancel cancels mid-stream: the cancel is honored, the
// stream ends interrupted at the last delivered seq, and no chunk travels
// after the cancel.
func TestProviderStreamCancel(t *testing.T) {
streams := &stubStreams{}
client := startExample(t, func(rt *pluginpkg.RuntimeSpec) {
rt.Env = map[string]string{"FULLSIDECAR_STREAM_INTERVAL_MS": "150"}
}, func(o *sidecar.ClientOptions) { o.Streams = streams })
if _, err := client.ProviderStreamOpen(context.Background(), protocol.StreamOpenParams{
StreamID: "s-cancel",
ProviderRef: testProvider,
Request: protocol.ProviderRequest{Messages: []protocol.ProviderMessage{}, Tools: []protocol.ProviderToolSchema{}},
}); err != nil {
t.Fatalf("ProviderStreamOpen: %v", err)
}
waitFor(t, "first chunk", 10*time.Second, func() bool {
chunks, _ := streams.snapshot()
return len(chunks) >= 1
})
client.ProviderStreamCancel("s-cancel")
waitFor(t, "stream end", 10*time.Second, func() bool {
_, ends := streams.snapshot()
return len(ends) == 1
})
chunks, ends := streams.snapshot()
end := ends[0]
if !end.Interrupted {
t.Fatalf("stream end = %+v, want interrupted", end)
}
if end.LastSeq != 1 {
t.Fatalf("end.lastSeq = %d, want 1", end.LastSeq)
}
for _, chunk := range chunks {
if chunk.Seq > end.LastSeq {
t.Fatalf("chunk seq %d traveled after the cancel (end %+v)", chunk.Seq, end)
}
}
}
// ---------------------------------------------------------------------------
// Tests: content refs
// ---------------------------------------------------------------------------
// TestContentRefRehydration sends an intercept payload above the 64 KiB
// externalization threshold: the host moves it into a content ref, and the
// SDK pages it back transparently — the extension must see (and rewrite) the
// full payload.
func TestContentRefRehydration(t *testing.T) {
client := startExample(t, nil, nil)
big := strings.Repeat("x", 100<<10)
payload, err := json.Marshal(map[string]string{"text": "/fs " + big})
if err != nil {
t.Fatal(err)
}
if len(payload) <= protocol.ExternalizeFieldBytes {
t.Fatalf("payload is %d bytes, want above the %d threshold", len(payload), protocol.ExternalizeFieldBytes)
}
result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 15*time.Second)
if err != nil {
t.Fatalf("Intercept: %v", err)
}
var replaced struct {
Text string `json:"text"`
}
decodeReplacement(t, result, &replaced)
if replaced.Text != big+" [rewritten by fullsidecar]" {
t.Fatalf("rehydrated text is %d bytes, want %d (full payload reassembled)", len(replaced.Text), len(big)+len(" [rewritten by fullsidecar]"))
}
}
// ---------------------------------------------------------------------------
// Tests: UI
// ---------------------------------------------------------------------------
// TestSessionStartPublishes drives one session.start observation: the example
// publishes its status line and demo card through host/ui/publish.
func TestSessionStartPublishes(t *testing.T) {
ui := &stubUI{}
client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.UI = ui })
if err := client.NotifyEvent(protocol.EventSessionStart, json.RawMessage(`{"sessionPath":"/s/1","phase":"start"}`)); err != nil {
t.Fatalf("NotifyEvent: %v", err)
}
waitFor(t, "status and card publish", 10*time.Second, func() bool {
return ui.publishedCount() >= 2
})
statuses := ui.publishedOfKind(protocol.UISurfaceStatus)
if len(statuses) != 1 {
t.Fatalf("status publishes = %+v", statuses)
}
var status protocol.UIStatusPayload
if err := json.Unmarshal(statuses[0].Payload, &status); err != nil {
t.Fatalf("status payload: %v", err)
}
if statuses[0].SurfaceID != "fullsidecar-status" || status.Label != "fullsidecar online" {
t.Fatalf("status surface = %q %+v", statuses[0].SurfaceID, status)
}
cards := ui.publishedOfKind(protocol.UISurfaceCard)
if len(cards) != 1 {
t.Fatalf("card publishes = %+v", cards)
}
var card protocol.UICardPayload
if err := json.Unmarshal(cards[0].Payload, &card); err != nil {
t.Fatalf("card payload: %v", err)
}
if cards[0].SurfaceID != "fullsidecar-card" || len(card.Actions) != 1 || card.Actions[0].ActionID != "demo" {
t.Fatalf("card surface = %q %+v", cards[0].SurfaceID, card)
}
}
// TestUIActionRoundTrip invokes the demo action: the example issues a
// blocking form request (answered by the stub UI handler) and publishes the
// greeting notification built from the answers.
func TestUIActionRoundTrip(t *testing.T) {
ui := &stubUI{}
var requested protocol.UIRequestParams
ui.requestFn = func(p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
requested = p
return protocol.UIRequestResult{Values: map[string]any{"name": "Ada", "loud": true}}, nil
}
client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.UI = ui })
result, err := client.UIAction(context.Background(), protocol.UIActionParams{
ActionID: "demo", SessionID: "sess-conf", Generation: 1,
})
if err != nil {
t.Fatalf("UIAction: %v", err)
}
if !result.Accepted {
t.Fatalf("action rejected: %+v", result)
}
if requested.SurfaceID != "fullsidecar-demo-form" || requested.SessionID != "sess-conf" || requested.Kind != protocol.UIRequestInput {
t.Fatalf("ui request = %+v", requested)
}
notifications := ui.publishedOfKind(protocol.UISurfaceNotification)
if len(notifications) != 1 {
t.Fatalf("notification publishes = %+v", notifications)
}
var notice protocol.UINotificationPayload
if err := json.Unmarshal(notifications[0].Payload, &notice); err != nil {
t.Fatalf("notification payload: %v", err)
}
if notice.Title != "HELLO, ADA!" {
t.Fatalf("greeting = %q", notice.Title)
}
}
// TestUISubmitRoundTrip delivers a form submission; the example acknowledges
// it with a status update.
func TestUISubmitRoundTrip(t *testing.T) {
ui := &stubUI{}
client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.UI = ui })
result, err := client.UISubmit(context.Background(), protocol.UISubmitParams{
SurfaceID: "fullsidecar-demo-form", SessionID: "sess-conf", Generation: 1,
Values: map[string]any{"name": "Ada"},
})
if err != nil {
t.Fatalf("UISubmit: %v", err)
}
if !result.Accepted {
t.Fatalf("submit rejected: %+v", result)
}
waitFor(t, "submit status publish", 10*time.Second, func() bool {
return len(ui.publishedOfKind(protocol.UISurfaceStatus)) == 1
})
statuses := ui.publishedOfKind(protocol.UISurfaceStatus)
var status protocol.UIStatusPayload
if err := json.Unmarshal(statuses[0].Payload, &status); err != nil {
t.Fatalf("status payload: %v", err)
}
if !strings.Contains(status.Label, "fullsidecar-demo-form") {
t.Fatalf("submit status label = %q", status.Label)
}
}
// ---------------------------------------------------------------------------
// Tests: timeout and crash
// ---------------------------------------------------------------------------
// TestInterceptTimeout stalls the example past the intercept budget; the host
// must surface the frozen intercept_timeout reason.
func TestInterceptTimeout(t *testing.T) {
client := startExample(t, func(rt *pluginpkg.RuntimeSpec) {
rt.Env = map[string]string{"FULLSIDECAR_STALL_ON_INPUT": "stall-me"}
}, nil)
started := time.Now()
_, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"stall-me"}`), 500*time.Millisecond)
if err == nil {
t.Fatal("Intercept succeeded against a stalling extension")
}
if reason := protocolReason(t, err); reason != protocol.ErrInterceptTimeout {
t.Fatalf("reason = %q, want %q (err %v)", reason, protocol.ErrInterceptTimeout, err)
}
if elapsed := time.Since(started); elapsed > 5*time.Second {
t.Fatalf("timeout surfaced after %s, not bounded by the 500ms budget", elapsed)
}
}
// TestCrashMidIntercept kills the extension process while an intercept is in
// flight: the pending call errors and every later call fails fast with the
// crashed-sidecar reason.
func TestCrashMidIntercept(t *testing.T) {
client := startExample(t, func(rt *pluginpkg.RuntimeSpec) {
rt.Env = map[string]string{"FULLSIDECAR_CRASH_ON_INPUT": "boom"}
}, nil)
_, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"boom"}`), 10*time.Second)
if err == nil {
t.Fatal("Intercept succeeded though the extension exited mid-intercept")
}
waitFor(t, "crash detection", 10*time.Second, client.Crashed)
started := time.Now()
_, err = client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"ok"}`), 10*time.Second)
if err == nil {
t.Fatal("Intercept on a crashed sidecar succeeded")
}
if reason := protocolReason(t, err); reason != protocol.ErrProviderInterrupted {
t.Fatalf("reason = %q, want %q (err %v)", reason, protocol.ErrProviderInterrupted, err)
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("call on a crashed sidecar took %s, not fail-fast", elapsed)
}
}
+222
View File
@@ -0,0 +1,222 @@
package conformance
import (
"bufio"
"bytes"
"encoding/json"
"io"
"os/exec"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/extension/protocol"
"reasonix/internal/rpcwire"
)
// rawSidecar drives the example binary directly over hand-written JSON-RPC
// frames, for the transport-level conformance cases the typed host client
// cannot produce (unregistered methods, oversized frames, exit statuses).
type rawSidecar struct {
t *testing.T
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
stderr *bytes.Buffer
nextID int64
waitOnce sync.Once
waitErr error
}
type rawFrame struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
} `json:"error"`
}
func startRawSidecar(t *testing.T) *rawSidecar {
t.Helper()
cmd := exec.Command(examplePath)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatalf("StdinPipe: %v", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("StdoutPipe: %v", err)
}
stderr := &bytes.Buffer{}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start example: %v", err)
}
r := &rawSidecar{t: t, cmd: cmd, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr}
t.Cleanup(func() {
if r.cmd.Process != nil {
_ = r.cmd.Process.Kill()
}
_ = r.wait()
})
return r
}
// wait reaps the process exactly once.
func (r *rawSidecar) wait() error {
r.waitOnce.Do(func() { r.waitErr = r.cmd.Wait() })
return r.waitErr
}
// waitWithin reaps the process inside the budget.
func (r *rawSidecar) waitWithin(what string, budget time.Duration) error {
done := make(chan error, 1)
go func() { done <- r.wait() }()
select {
case err := <-done:
return err
case <-time.After(budget):
r.t.Fatalf("process did not exit within %s (%s)", budget, what)
return nil
}
}
// send marshals and writes one frame.
func (r *rawSidecar) send(v any) {
r.t.Helper()
raw, err := json.Marshal(v)
if err != nil {
r.t.Fatalf("marshal frame: %v", err)
}
if _, err := r.stdin.Write(append(raw, '\n')); err != nil {
r.t.Fatalf("write frame: %v", err)
}
}
// readFrame reads one NDJSON frame.
func (r *rawSidecar) readFrame() (rawFrame, error) {
line, err := r.stdout.ReadBytes('\n')
if err != nil {
return rawFrame{}, err
}
var frame rawFrame
if err := json.Unmarshal(line, &frame); err != nil {
return rawFrame{}, err
}
return frame, nil
}
// call writes one request and returns its response, failing on any
// interleaved host-bound traffic (none is expected in these scenarios).
func (r *rawSidecar) call(method string, params any) rawFrame {
r.t.Helper()
r.nextID++
id := r.nextID
r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
for {
frame, err := r.readFrame()
if err != nil {
r.t.Fatalf("read answer for %s: %v (stderr: %s)", method, err, strings.TrimSpace(r.stderr.String()))
}
if frame.Method != "" {
r.t.Fatalf("extension sent an unexpected host-bound request %q", frame.Method)
}
var gotID int64
if err := json.Unmarshal(frame.ID, &gotID); err == nil && gotID == id {
return frame
}
}
}
// handshake runs the initialize exchange and opens the barrier.
func (r *rawSidecar) handshake() {
r.t.Helper()
frame := r.call("extension/initialize", protocol.InitializeParams{
ProtocolVersion: protocol.ProtocolVersion,
ProtocolID: protocol.ProtocolID,
Session: protocol.SessionContext{SessionID: "raw-sess", WorkspaceRoot: "/ws", Generation: 1},
Capabilities: protocol.HostCapabilities{ContentRefs: true, UIHost: protocol.UIHostHeadless, ProtocolVersion: protocol.ProtocolVersion},
})
if frame.Error != nil {
r.t.Fatalf("initialize answered with an error: %+v", frame.Error)
}
var result protocol.InitializeResult
if err := json.Unmarshal(frame.Result, &result); err != nil {
r.t.Fatalf("decode initialize result: %v", err)
}
r.send(map[string]any{"jsonrpc": "2.0", "method": "extension/initialized", "params": map[string]any{}})
}
// TestUnknownMethod sends a request for an unregistered method past the
// handshake: the SDK must answer with the JSON-RPC method-not-found code and
// the frozen unknown_method reason.
func TestUnknownMethod(t *testing.T) {
r := startRawSidecar(t)
r.handshake()
frame := r.call("extension/bogus", map[string]any{})
if frame.Error == nil {
t.Fatalf("unknown method answered with result %s", string(frame.Result))
}
if frame.Error.Code != rpcwire.ErrMethodNotFound {
t.Fatalf("error code = %d, want %d", frame.Error.Code, rpcwire.ErrMethodNotFound)
}
var data protocol.ProtocolErrorData
if err := json.Unmarshal(frame.Error.Data, &data); err != nil {
t.Fatalf("error data does not decode: %v", err)
}
if data.Reason != protocol.ErrUnknownMethod {
t.Fatalf("error reason = %q, want %q", data.Reason, protocol.ErrUnknownMethod)
}
}
// TestOversizedFrame sends one NDJSON line beyond the frozen 8 MiB frame
// budget: the SDK must fail the connection and exit non-zero.
func TestOversizedFrame(t *testing.T) {
r := startRawSidecar(t)
line := strings.Repeat("a", protocol.FrameBytes+1024)
go func() {
// The write may fail with EPIPE once the SDK drops the connection;
// either way the connection error is what is being asserted.
_, _ = io.WriteString(r.stdin, line+"\n")
}()
if err := r.waitWithin("oversized frame", 15*time.Second); err == nil {
t.Fatal("process exited 0 after an oversized frame")
}
if !strings.Contains(r.stderr.String(), "frame") {
t.Fatalf("stderr does not mention the frame violation: %q", strings.TrimSpace(r.stderr.String()))
}
}
// TestBoundedShutdownExitZero runs the orderly shutdown: the example answers
// accepted:true and the process exits 0 inside the budget.
func TestBoundedShutdownExitZero(t *testing.T) {
r := startRawSidecar(t)
r.handshake()
frame := r.call("extension/shutdown", protocol.ShutdownParams{TimeoutMillis: 5000})
if frame.Error != nil {
t.Fatalf("shutdown answered with an error: %+v", frame.Error)
}
var result protocol.ShutdownResult
if err := json.Unmarshal(frame.Result, &result); err != nil {
t.Fatalf("decode shutdown result: %v", err)
}
if !result.Accepted {
t.Fatalf("shutdown not accepted: %+v", result)
}
// The real host closes the sidecar's stdin right after the shutdown
// request (proc.close); the SDK then sees EOF, Serve returns nil, and
// the process exits 0.
if err := r.stdin.Close(); err != nil {
t.Fatalf("close stdin: %v", err)
}
if err := r.waitWithin("shutdown", 10*time.Second); err != nil {
t.Fatalf("process exit = %v, want 0 (stderr: %s)", err, strings.TrimSpace(r.stderr.String()))
}
}
+12
View File
@@ -53,6 +53,18 @@ func init() {
enumTypes[reflect.TypeOf(ErrorReason(""))] = reasons
}
// EnumValues returns the frozen wire values of every string enum DTO type,
// keyed by the Go type name (e.g. "InterceptEvent" → the 17 hook points). It
// is the exported form of enumTypes for code generators: the strict decoder,
// the JSON Schema, and the SDK DTO mirror all draw from this one table.
func EnumValues() map[string][]string {
out := make(map[string][]string, len(enumTypes))
for typ, allowed := range enumTypes {
out[typ.Name()] = append([]string(nil), allowed...)
}
return out
}
func interceptEventValues() []string {
return InterceptEvents()
}
@@ -20,6 +20,9 @@ const (
SchemaArtifactPath = "internal/extension/protocol/schema.generated.json"
HashArtifactPath = "internal/extension/protocol/schema_hash.generated.go"
MarkdownArtifactPath = "docs/EXTENSION_PROTOCOL.generated.md"
// SDKTypesArtifactPath is the Go DTO mirror compiled into the stdlib-only
// extension SDK module.
SDKTypesArtifactPath = "sdk/go/types_generated.go"
)
// Artifact is one deterministic generated file, relative to the repository
@@ -62,11 +65,16 @@ func Generate() ([]Artifact, error) {
if err != nil {
return nil, fmt.Errorf("generate markdown: %w", err)
}
sdkTypes, err := generateSDKTypesGo()
if err != nil {
return nil, fmt.Errorf("generate sdk types: %w", err)
}
return []Artifact{
{Path: SchemaArtifactPath, Data: append([]byte(nil), canonical...)},
{Path: HashArtifactPath, Data: hashSource},
{Path: MarkdownArtifactPath, Data: markdown},
{Path: SDKTypesArtifactPath, Data: sdkTypes},
}, nil
}
@@ -16,8 +16,8 @@ func TestGeneratedArtifactsAreDeterministicAndCommitted(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(first) != 3 || len(second) != len(first) {
t.Fatalf("generated %d/%d artifacts, want 3", len(first), len(second))
if len(first) != 4 || len(second) != len(first) {
t.Fatalf("generated %d/%d artifacts, want 4", len(first), len(second))
}
temporaryRoot := t.TempDir()
+419
View File
@@ -0,0 +1,419 @@
package protocolgen
import (
"encoding/json"
"fmt"
"go/format"
"reflect"
"strconv"
"strings"
"reasonix/internal/extension/protocol"
)
// The SDK types artifact (SDKTypesArtifactPath in generate.go) is produced
// from the same frozen registry and reflection walk as the JSON Schema:
// every wire DTO and enum reachable from the registry, plus the payload
// documents addressed indirectly through json.RawMessage fields and the
// structured error envelope.
// sdkEnumPrefixes maps every reachable string enum type to the constant-name
// prefix its values carry in the SDK's public API. ProviderErrorCode has
// none: its values already read as Go names ("provider_failed" →
// ProviderFailed).
var sdkEnumPrefixes = map[string]string{
"InterceptEvent": "Event",
"InterceptDecision": "Decision",
"UIHostKind": "UIHost",
"UISurfaceKind": "UISurface",
"UIRequestKind": "UIRequest",
"UIFieldKind": "UIField",
"UISeverity": "UISeverity",
"ProviderRole": "ProviderRole",
"ProviderChunkType": "Chunk",
"ProviderErrorCode": "",
"ContentEncoding": "Content",
"ErrorReason": "Err",
}
// sdkEnumConstantExceptions pins constant names that mechanical mangling
// would render differently from the SDK's established public API.
var sdkEnumConstantExceptions = map[string]map[string]string{
"ProviderChunkType": {"tool_call_args_delta": "ChunkToolCallDelta"},
}
// sdkInitialisms upper-cases identifier parts that read as acronyms.
var sdkInitialisms = map[string]string{
"ui": "UI", "tui": "TUI", "acp": "ACP", "utf8": "UTF8",
}
var rawMessageType = reflect.TypeOf(json.RawMessage{})
// sdkTypeWalk is the deterministic first-visit record of every named wire
// type reachable from the frozen registry (plus the extra roots below).
type sdkTypeWalk struct {
enums map[string][]string // frozen enum value sets, by type name
order []reflect.Type // named types in discovery order
kinds map[reflect.Type]string
seen map[reflect.Type]bool
}
// walkSDKTypes reflection-walks the frozen registry exactly like the schema
// builder does — registry params and non-notification results — and adds the
// roots no registry DTO references by name: the host UI payload documents
// (carried inside json.RawMessage payload fields) and the structured error
// envelope ProtocolErrorData.
func walkSDKTypes() (*sdkTypeWalk, error) {
w := &sdkTypeWalk{
enums: protocol.EnumValues(),
kinds: map[reflect.Type]string{},
seen: map[reflect.Type]bool{},
}
var roots []reflect.Type
for _, spec := range protocol.Registry() {
roots = append(roots, spec.ParamsType)
if !spec.Notification() {
roots = append(roots, spec.ResultType)
}
}
roots = append(roots,
reflect.TypeOf(protocol.UIStatusPayload{}),
reflect.TypeOf(protocol.UICardPayload{}),
reflect.TypeOf(protocol.UIFormPayload{}),
reflect.TypeOf(protocol.UINotificationPayload{}),
reflect.TypeOf(protocol.ProtocolErrorData{}),
)
for _, root := range roots {
if err := w.visit(root); err != nil {
return nil, err
}
}
return w, nil
}
func (w *sdkTypeWalk) visit(typ reflect.Type) error {
for typ.Kind() == reflect.Pointer {
typ = typ.Elem()
}
if typ == rawMessageType {
return nil
}
switch typ.Kind() {
case reflect.Struct:
if typ.Name() == "" {
return fmt.Errorf("anonymous struct %v is not a named wire DTO", typ)
}
if w.seen[typ] {
return nil
}
w.seen[typ] = true
w.order = append(w.order, typ)
w.kinds[typ] = "struct"
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.PkgPath != "" {
continue
}
if field.Anonymous {
return fmt.Errorf("embedded field %v is not supported in wire DTOs", field.Type)
}
if err := w.visit(field.Type); err != nil {
return err
}
}
return nil
case reflect.String:
if typ.PkgPath() == "" {
return nil // predeclared string
}
if _, ok := w.enums[typ.Name()]; !ok {
return fmt.Errorf("named string type %s (%v) is not a frozen enum", typ.Name(), typ)
}
if w.seen[typ] {
return nil
}
w.seen[typ] = true
w.order = append(w.order, typ)
w.kinds[typ] = "enum"
return nil
case reflect.Slice, reflect.Array:
return w.visit(typ.Elem())
case reflect.Map:
if err := w.visit(typ.Key()); err != nil {
return err
}
return w.visit(typ.Elem())
}
// Predeclared scalars and unconstrained interfaces carry no named types.
return nil
}
// generateSDKTypesGo renders the SDK's DTO mirror from the frozen walk.
func generateSDKTypesGo() ([]byte, error) {
walk, err := walkSDKTypes()
if err != nil {
return nil, err
}
var out strings.Builder
out.WriteString("// Code generated by cmd/extension-protocol-gen; DO NOT EDIT.\n")
out.WriteString("\n")
out.WriteString("// Package extension: Extension Protocol v1 wire DTOs, enums, method\n")
out.WriteString("// names, frozen limits, and the frozen error table, mirrored from the\n")
out.WriteString("// host's internal/extension/protocol package. Behavior (validators,\n")
out.WriteString("// error constructors, helpers) lives in the handwritten files.\n")
out.WriteString("package extension\n")
out.WriteString("\n")
out.WriteString("import \"encoding/json\"\n\n")
emitSDKIdentity(&out)
emitSDKLimits(&out)
if err := emitSDKMethods(&out); err != nil {
return nil, err
}
emitSDKErrorTable(&out)
for _, typ := range walk.order {
var err error
switch walk.kinds[typ] {
case "enum":
err = emitSDKEnum(&out, walk, typ)
case "struct":
err = emitSDKStruct(&out, typ)
}
if err != nil {
return nil, err
}
}
if err := guardSDKSurfacePayloads(walk); err != nil {
return nil, err
}
formatted, err := format.Source([]byte(out.String()))
if err != nil {
return nil, fmt.Errorf("format sdk types source: %w", err)
}
return formatted, nil
}
func emitSDKIdentity(out *strings.Builder) {
out.WriteString("// ProtocolID is the immutable identity string peers exchange during the\n")
out.WriteString("// initialize handshake.\n")
fmt.Fprintf(out, "const ProtocolID = %q\n\n", protocol.ProtocolID)
out.WriteString("// ProtocolMajor is the frozen major version of this protocol build.\n")
fmt.Fprintf(out, "const ProtocolMajor = %d\n\n", protocol.ProtocolMajor)
out.WriteString("// ProtocolVersion is the wire string form of ProtocolMajor carried in the\n")
out.WriteString("// initialize handshake.\n")
fmt.Fprintf(out, "const ProtocolVersion = %q\n\n", protocol.ProtocolVersion)
}
func emitSDKLimits(out *strings.Builder) {
limits := protocol.FrozenLimits()
out.WriteString("// Frozen wire limits. These constants are part of the protocol contract.\n")
out.WriteString("const (\n")
out.WriteString("\t// FrameBytes caps one JSON-RPC frame on the extension transport.\n")
fmt.Fprintf(out, "\tFrameBytes = %d\n", limits.FrameBytes)
out.WriteString("\t// ExternalizeFieldBytes is the threshold above which an externalizable\n")
out.WriteString("\t// payload must move into a content ref instead of traveling inline.\n")
fmt.Fprintf(out, "\tExternalizeFieldBytes = %d\n", limits.ExternalizeFieldBytes)
out.WriteString("\t// ContentRefChunkBytes caps one host/content/read chunk.\n")
fmt.Fprintf(out, "\tContentRefChunkBytes = %d\n", limits.ContentRefChunkBytes)
out.WriteString("\t// ContentRefObjectBytes caps one externalized object.\n")
fmt.Fprintf(out, "\tContentRefObjectBytes = %d\n", limits.ContentRefObjectBytes)
out.WriteString(")\n\n")
}
func emitSDKMethods(out *strings.Builder) error {
out.WriteString("// Method names, frozen for Extension Protocol v1.\n")
out.WriteString("const (\n")
seen := map[string]bool{}
for _, spec := range protocol.Registry() {
name := sdkMethodConstantName(string(spec.Name))
if seen[name] {
return fmt.Errorf("method constant name collision: %s", name)
}
seen[name] = true
fmt.Fprintf(out, "\t%s = %q\n", name, string(spec.Name))
}
out.WriteString(")\n\n")
return nil
}
func emitSDKErrorTable(out *strings.Builder) {
out.WriteString("// DomainErrorCode is the JSON-RPC code every extension domain error uses on\n")
out.WriteString("// the wire. The structured ProtocolErrorData reason distinguishes them.\n")
fmt.Fprintf(out, "const DomainErrorCode = %d\n\n", protocol.DomainErrorCode)
out.WriteString("// errorSpec is one frozen error table entry: the JSON-RPC code, the\n")
out.WriteString("// generic wire message, and whether the call may be retried.\n")
out.WriteString("type errorSpec struct {\n\tCode int\n\tMessage string\n\tRetryable bool\n}\n\n")
out.WriteString("// frozenErrorSpecs mirrors the host's frozen error table. Adding an entry\n")
out.WriteString("// is a conscious protocol change.\n")
out.WriteString("var frozenErrorSpecs = map[ErrorReason]errorSpec{\n")
for _, contract := range protocol.ErrorContracts() {
fmt.Fprintf(out, "\t%s: {%s, %q, %t},\n",
sdkEnumConstantName("ErrorReason", string(contract.Reason)),
sdkErrorCodeName(contract.JSONRPCCode), contract.Message, contract.Retryable)
}
out.WriteString("}\n\n")
}
// sdkErrorCodeName renders a frozen JSON-RPC code with the SDK's symbolic
// constant where one exists (the standard codes live in wire.go).
func sdkErrorCodeName(code int) string {
switch code {
case -32600:
return "CodeInvalidRequest"
case -32601:
return "CodeMethodNotFound"
case -32602:
return "CodeInvalidParams"
case -32603:
return "CodeInternal"
case protocol.DomainErrorCode:
return "DomainErrorCode"
default:
return strconv.Itoa(code)
}
}
func emitSDKEnum(out *strings.Builder, walk *sdkTypeWalk, typ reflect.Type) error {
name := typ.Name()
if _, ok := sdkEnumPrefixes[name]; !ok {
return fmt.Errorf("enum %s has no constant prefix registered", name)
}
fmt.Fprintf(out, "// %s is a generated Extension Protocol v1 string enum.\n", name)
fmt.Fprintf(out, "type %s string\n\n", name)
out.WriteString("const (\n")
seen := map[string]bool{}
for _, value := range walk.enums[name] {
constant := sdkEnumConstantName(name, value)
if seen[constant] {
return fmt.Errorf("enum constant name collision: %s", constant)
}
seen[constant] = true
fmt.Fprintf(out, "\t%s %s = %q\n", constant, name, value)
}
out.WriteString(")\n\n")
return nil
}
func emitSDKStruct(out *strings.Builder, typ reflect.Type) error {
fmt.Fprintf(out, "// %s is a generated Extension Protocol v1 wire DTO.\n", typ.Name())
fields := 0
var body strings.Builder
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.PkgPath != "" {
continue
}
rendered, err := renderSDKType(field.Type)
if err != nil {
return fmt.Errorf("%s field %s: %w", typ.Name(), field.Name, err)
}
fmt.Fprintf(&body, "\t%s %s `%s`\n", field.Name, rendered, string(field.Tag))
fields++
}
if fields == 0 {
fmt.Fprintf(out, "type %s struct{}\n\n", typ.Name())
return nil
}
fmt.Fprintf(out, "type %s struct {\n%s}\n\n", typ.Name(), body.String())
return nil
}
// renderSDKType renders a field type as Go source. Named types resolve to
// their bare name — every named type reachable from the walk is either one of
// the mirrored DTOs/enums or encoding/json's RawMessage.
func renderSDKType(typ reflect.Type) (string, error) {
if typ == rawMessageType {
return "json.RawMessage", nil
}
switch typ.Kind() {
case reflect.Pointer:
elem, err := renderSDKType(typ.Elem())
return "*" + elem, err
case reflect.Slice:
elem, err := renderSDKType(typ.Elem())
return "[]" + elem, err
case reflect.Map:
key, err := renderSDKType(typ.Key())
if err != nil {
return "", err
}
elem, err := renderSDKType(typ.Elem())
return "map[" + key + "]" + elem, err
case reflect.Interface:
if typ.NumMethod() == 0 {
return "any", nil
}
case reflect.Struct:
if typ.Name() != "" {
return typ.Name(), nil
}
case reflect.String:
if typ.Name() != "" {
return typ.Name(), nil
}
return "string", nil
case reflect.Bool:
return "bool", nil
case reflect.Int:
return "int", nil
case reflect.Int64:
return "int64", nil
case reflect.Uint64:
return "uint64", nil
case reflect.Float64:
return "float64", nil
}
return "", fmt.Errorf("unsupported wire type %v", typ)
}
// guardSDKSurfacePayloads pins the surface-kind → payload-DTO convention: a
// new UISurfaceKind without a matching UI<Kind>Payload type in the generated
// set fails generation loudly instead of silently shipping an SDK that
// cannot build the new surface.
func guardSDKSurfacePayloads(walk *sdkTypeWalk) error {
emitted := map[string]bool{}
for _, typ := range walk.order {
emitted[typ.Name()] = true
}
for _, kind := range walk.enums["UISurfaceKind"] {
want := "UI" + sdkIdentifierFromValue(kind) + "Payload"
if !emitted[want] {
return fmt.Errorf("surface kind %q has no payload DTO %s in the generated set", kind, want)
}
}
return nil
}
// sdkMethodConstantName mangles a wire method name into its Go constant
// name: "extension/ui/action" → MethodExtensionUIAction.
func sdkMethodConstantName(method string) string {
return "Method" + sdkIdentifierFromValue(method)
}
// sdkEnumConstantName mangles an enum value into its Go constant name with
// the type's registered prefix; exceptions pin the established public API.
func sdkEnumConstantName(typeName, value string) string {
if exceptions, ok := sdkEnumConstantExceptions[typeName]; ok {
if name, ok := exceptions[value]; ok {
return name
}
}
return sdkEnumPrefixes[typeName] + sdkIdentifierFromValue(value)
}
// sdkIdentifierFromValue turns a wire value into Go identifier parts:
// "agent.before_start" → "AgentBeforeStart".
func sdkIdentifierFromValue(value string) string {
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == '_' || r == '.' || r == '-' || r == '/'
})
var out strings.Builder
for _, part := range parts {
if initialism, ok := sdkInitialisms[part]; ok {
out.WriteString(initialism)
continue
}
out.WriteString(strings.ToUpper(part[:1]) + part[1:])
}
return out.String()
}
+102
View File
@@ -0,0 +1,102 @@
# Reasonix Extension SDK for Go
Write [Reasonix](https://github.com/esengine/DeepSeek-Reasonix) extensions in
Go. An extension is a small sidecar process speaking **Extension Protocol
v1** (`reasonix.extension.v1`) over stdio: Reasonix launches it, hands it the
initialize handshake, and then drives intercepts, event observation,
extension-hosted provider streams, and structured UI surfaces.
The module is **standard library only** — zero dependencies.
## Install
```sh
go get github.com/esengine/DeepSeek-Reasonix/sdk/go
```
Requires Go 1.23+.
## Minimal example
```go
package main
import (
"context"
"encoding/json"
"os"
extension "github.com/esengine/DeepSeek-Reasonix/sdk/go"
)
type ext struct{}
func (ext) Initialize(_ context.Context, p extension.InitializeParams) (*extension.InitializeResult, error) {
return &extension.InitializeResult{
Name: "my-ext",
Version: "0.1.0",
Subscriptions: []string{"tool.before"},
}, nil
}
func main() {
err := extension.Serve(context.Background(), ext{}, extension.Options{
Interceptors: map[string]extension.InterceptorFunc{
"tool.before": func(_ context.Context, event string, payload json.RawMessage) (*extension.InterceptResult, error) {
return extension.Continue(), nil // or Block / Replace / Allow / Deny
},
},
})
if err != nil {
os.Exit(1)
}
// Serve returned nil: the host asked for shutdown. Exit 0.
}
```
Everything else is optional and declared through `Options`: an `Observer`
for fire-and-forget events, a `Provider` for extension-hosted model
providers, `UI` callbacks plus the `HostUI` client for structured surfaces
(status, cards, forms, notifications and blocking prompts — never HTML/JS),
`ReadContentRef`/`ResolveExternalized` for large externalized payloads, and a
`Shutdown` hook.
## Runnable example
[`examples/fullsidecar`](examples/fullsidecar/main.go) is the reference
extension: input rewriting (try the `/fs ` trigger), tool interception
(block + argument rewrite), system-prompt strategy replacement, a fake
streaming provider (text chunks, a tool call, usage), structured UI (status +
card on session start, a form prompt behind the `demo` action), and a clean
bounded shutdown — all in one small stdlib-only program.
```sh
go build -o /tmp/fullsidecar ./examples/fullsidecar
```
The binary speaks the protocol on stdin/stdout, so install it as a plugin
package runtime (or point the host-side conformance suite at it) rather than
running it interactively. It is driven end-to-end against the real host by
`internal/extension/conformance` in the Reasonix repository.
## Generated wire types
`types_generated.go` is produced from the host's frozen protocol registry by
`go run ./cmd/extension-protocol-gen -root .` (repository root). Edit nothing
in that file; the handwritten half of the type layer (validators, error
constructors, enum helpers) lives in `types_ext.go`.
## Protocol reference
- Method/DTO contract: [`docs/EXTENSION_PROTOCOL.generated.md`](../../docs/EXTENSION_PROTOCOL.generated.md)
- Canonical JSON schema: [`internal/extension/protocol/schema.generated.json`](../../internal/extension/protocol/schema.generated.json)
- Transport: strict JSON-RPC 2.0 over NDJSON (one object per line), integer
request ids, object params, 8 MiB frames.
## Stability
Extension Protocol v1's compatibility promise applies: within major version
1, only optional fields, new enum values, and new methods are added; existing
required fields, method names, directions, limits, error reasons, and
semantics never change. This SDK tracks that contract — upgrading within v1
never breaks a compiled extension.
+319
View File
@@ -0,0 +1,319 @@
package extension
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
)
// contentStore scripts the host's content store for host/content/read.
type contentStore struct {
mu sync.Mutex
objects map[string][]byte
requests []ContentReadParams
tamper bool // flip one byte in the first served chunk
}
func newContentStore() *contentStore {
return &contentStore{objects: make(map[string][]byte)}
}
func (s *contentStore) put(data string) (ref string, descriptor ExternalizedField) {
sum := sha256.Sum256([]byte(data))
ref = "content_test_" + hex.EncodeToString(sum[:4])
s.mu.Lock()
s.objects[ref] = []byte(data)
s.mu.Unlock()
return ref, ExternalizedField{
JSONPointer: "/payload", ContentRef: ref,
TotalBytes: int64(len(data)), SHA256: hex.EncodeToString(sum[:]),
}
}
// handler pages like the real host: at most ContentRefChunkBytes per answer,
// NextOffset null at the end, content_ref_expired for unknown refs.
func (s *contentStore) handler(params json.RawMessage) (any, *hostError) {
var p ContentReadParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, &hostError{Code: CodeInvalidParams, Message: "bad params"}
}
s.mu.Lock()
s.requests = append(s.requests, p)
data, ok := s.objects[p.ContentRef]
tamper := s.tamper
s.mu.Unlock()
if !ok {
return nil, &hostError{
Code: DomainErrorCode,
Message: "The referenced content has expired.",
Data: ProtocolErrorData{Reason: ErrContentRefExpired, Retryable: true},
}
}
if p.Offset < 0 || p.Offset > int64(len(data)) {
return nil, &hostError{
Code: DomainErrorCode,
Message: "The referenced content has expired.",
Data: ProtocolErrorData{Reason: ErrContentRefExpired, Retryable: true},
}
}
end := p.Offset + ContentRefChunkBytes
if end > int64(len(data)) {
end = int64(len(data))
}
chunk := append([]byte(nil), data[p.Offset:end]...)
if tamper && p.Offset == 0 && len(chunk) > 0 {
chunk[0] ^= 0xFF
}
var next *int64
if end < int64(len(data)) {
value := end
next = &value
}
sum := sha256.Sum256(data)
return ContentReadResult{
ContentRef: p.ContentRef, Offset: p.Offset,
DataBase64: base64.StdEncoding.EncodeToString(chunk),
NextOffset: next, TotalBytes: int64(len(data)),
SHA256: hex.EncodeToString(sum[:]), Encoding: ContentUTF8,
}, nil
}
func (s *contentStore) requestedOffsets() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
var out []int64
for _, r := range s.requests {
out = append(out, r.Offset)
}
return out
}
// TestReadContentRefMultiChunk reads a payload spanning several chunks and
// verifies paging offsets and SHA-256.
func TestReadContentRefMultiChunk(t *testing.T) {
store := newContentStore()
big := strings.Repeat("abcdefghij", ContentRefChunkBytes/4) // exactly 2.5 chunks → 3 pages
ref, _ := store.put(big)
var data []byte
var readErr error
hook := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
data, readErr = ReadContentRef(ctx, ref)
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: hook})
host.onRequest(MethodHostContentRead, store.handler)
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if readErr != nil {
t.Fatalf("ReadContentRef: %v", readErr)
}
if string(data) != big {
t.Fatalf("reassembled %d bytes, want %d identical bytes", len(data), len(big))
}
offsets := store.requestedOffsets()
if len(offsets) != 3 {
t.Fatalf("read offsets = %v, want 3 pages", offsets)
}
for i, offset := range offsets {
if offset != int64(i)*ContentRefChunkBytes {
t.Fatalf("offset %d = %d, want %d", i, offset, int64(i)*ContentRefChunkBytes)
}
}
}
// TestReadContentRefTamper detects a SHA-256 mismatch.
func TestReadContentRefTamper(t *testing.T) {
store := newContentStore()
store.tamper = true
ref, _ := store.put(strings.Repeat("x", ContentRefChunkBytes+10))
var readErr error
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
_, readErr = ReadContentRef(ctx, ref)
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostContentRead, store.handler)
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
var protocolErr *ProtocolError
if !errors.As(readErr, &protocolErr) || !strings.Contains(protocolErr.Message, "SHA-256") {
t.Fatalf("readErr = %v, want SHA-256 mismatch protocol error", readErr)
}
}
// TestReadContentRefExpired maps the wire reason to a *ProtocolError.
func TestReadContentRefExpired(t *testing.T) {
store := newContentStore()
var readErr error
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
_, readErr = ReadContentRef(ctx, "content_gone")
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostContentRead, store.handler)
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
var protocolErr *ProtocolError
if !errors.As(readErr, &protocolErr) {
t.Fatalf("readErr = %v, want *ProtocolError", readErr)
}
if protocolErr.Reason != ErrContentRefExpired {
t.Fatalf("reason = %q, want content_ref_expired", protocolErr.Reason)
}
}
// TestInterceptExternalizedPayload runs the full transparent rehydration:
// the host sends payload:null plus the externalized envelope, and the
// interceptor receives the reassembled bytes.
func TestInterceptExternalizedPayload(t *testing.T) {
store := newContentStore()
big := `{"text":"` + strings.Repeat("lorem ", ContentRefChunkBytes/3) + `"}`
_, descriptor := store.put(big)
var got json.RawMessage
interceptors := map[string]InterceptorFunc{
"input.receive": func(_ context.Context, _ string, payload json.RawMessage) (*InterceptResult, error) {
got = payload
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostContentRead, store.handler)
host.handshake(t)
resp := host.request(MethodExtensionIntercept, map[string]any{
"event": "input.receive", "seq": 1, "payload": nil, "timeoutMillis": 0,
"externalized": []ExternalizedField{descriptor},
})
if resp.Err != nil {
t.Fatalf("intercept failed: %+v", resp.Err)
}
if string(got) != big {
t.Fatalf("payload = %d bytes, want rehydrated %d bytes", len(got), len(big))
}
}
// TestInterceptExternalizedViolation rejects an inline payload alongside an
// envelope.
func TestInterceptExternalizedViolation(t *testing.T) {
interceptors := map[string]InterceptorFunc{
"*": func(context.Context, string, json.RawMessage) (*InterceptResult, error) { return Continue(), nil },
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
resp := host.request(MethodExtensionIntercept, map[string]any{
"event": "input.receive", "seq": 1, "payload": json.RawMessage(`{"x":1}`), "timeoutMillis": 0,
"externalized": []ExternalizedField{{
JSONPointer: "/payload", ContentRef: "content_fake",
TotalBytes: 7, SHA256: strings.Repeat("0", 64),
}},
})
if resp.Err == nil {
t.Fatal("expected a protocol error for inline payload plus envelope")
}
data, _ := resp.Err.Data.(ProtocolErrorData)
if data.Reason != ErrProtocolError {
t.Fatalf("reason = %q, want protocol_error", data.Reason)
}
}
// TestResolveExternalizedHelper covers the exported helper directly,
// including the pointer check.
func TestResolveExternalizedHelper(t *testing.T) {
store := newContentStore()
_, descriptor := store.put(`{"hello":"world"}`)
var resolved json.RawMessage
var resolveErr error
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
resolved, resolveErr = ResolveExternalized(ctx, nil, []ExternalizedField{descriptor}, "/payload")
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostContentRead, store.handler)
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if resolveErr != nil {
t.Fatalf("ResolveExternalized: %v", resolveErr)
}
if string(resolved) != `{"hello":"world"}` {
t.Fatalf("resolved = %s", resolved)
}
// A wrong pointer must fail without any content read.
before := len(store.requestedOffsets())
var wrongErr error
interceptors2 := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
_, wrongErr = ResolveExternalized(ctx, nil, []ExternalizedField{descriptor}, "/replacement")
return Continue(), nil
},
}
host2, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors2})
host2.onRequest(MethodHostContentRead, store.handler)
host2.handshake(t)
host2.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
var protocolErr *ProtocolError
if !errors.As(wrongErr, &protocolErr) || protocolErr.Reason != ErrProtocolError {
t.Fatalf("wrongErr = %v, want protocol_error", wrongErr)
}
if got := len(store.requestedOffsets()); got != before {
t.Fatalf("content reads happened despite the pointer violation: %d → %d", before, got)
}
}
// TestResolveExternalizedNoConnection requires an SDK callback context.
func TestResolveExternalizedNoConnection(t *testing.T) {
if _, err := ResolveExternalized(context.Background(), nil, nil, "/payload"); !errors.Is(err, ErrNoConnection) {
t.Fatalf("err = %v, want ErrNoConnection", err)
}
if _, err := ReadContentRef(context.Background(), "content_x"); !errors.Is(err, ErrNoConnection) {
t.Fatalf("err = %v, want ErrNoConnection", err)
}
}
// TestEventExternalizedPayload rehydrates event payloads too.
func TestEventExternalizedPayload(t *testing.T) {
store := newContentStore()
big := fmt.Sprintf(`{"blob":"%s"}`, strings.Repeat("z", ContentRefChunkBytes+100))
_, descriptor := store.put(big)
seen := make(chan json.RawMessage, 1)
opts := Options{Observer: func(_ context.Context, _ string, payload json.RawMessage) { seen <- payload }}
host, _ := startFakeHost(t, basicHandler(), opts)
host.onRequest(MethodHostContentRead, store.handler)
host.handshake(t)
host.notify(MethodExtensionEvent, map[string]any{
"event": "session.end", "payload": nil, "externalized": []ExternalizedField{descriptor},
})
select {
case payload := <-seen:
if string(payload) != big {
t.Fatalf("payload = %d bytes, want %d", len(payload), len(big))
}
case <-time.After(5 * time.Second):
t.Fatal("observer not called for the externalized event")
}
}
+357
View File
@@ -0,0 +1,357 @@
// Command fullsidecar is the reference Reasonix extension sidecar: one small
// program that exercises every Extension Protocol v1 contribution kind —
// input rewriting, tool interception, system-prompt strategy replacement, an
// extension-hosted streaming provider, structured UI surfaces and prompts,
// and a clean bounded shutdown. It is the example third parties copy.
//
// Behavior map:
//
// input "/fs <text>" → input.receive replaces the input with
// "<text> [rewritten by fullsidecar]"
// tool "dangerous_exec" → tool.before blocks it with a policy reason
// tool "read" → tool.before rewrites the arguments (sandbox)
// system_prompt.build → the strategy slot owner wraps the prompt
// session.start → publishes a status line and a card
// action "demo" → asks a form prompt, greets via notification
// provider plugin/<id>/fake/echo → streams a fixed completion: two text
// chunks, one tool call, usage, done
//
// Environment:
//
// REASONIX_PLUGIN_NAME plugin ID, set by the host at launch (provider
// refs must live in the plugin/<id>/ namespace);
// defaults to "fullsidecar" when run standalone
// FULLSIDECAR_STREAM_INTERVAL_MS
// pacing between provider chunks (default 15)
//
// The two hooks below exist for the host↔SDK conformance suite
// (internal/extension/conformance); they are inert unless set:
//
// FULLSIDECAR_CRASH_ON_INPUT exit(3) without answering when an
// input.receive text matches exactly
// FULLSIDECAR_STALL_ON_INPUT hold an input.receive answer until the
// intercept context ends when the text matches
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
extension "github.com/esengine/DeepSeek-Reasonix/sdk/go"
)
const (
rewritePrefix = "/fs "
rewriteSuffix = " [rewritten by fullsidecar]"
deniedTool = "dangerous_exec"
rewrittenTool = "read"
fakeModel = "echo"
defaultPluginID = "fullsidecar"
)
// plugin is the extension handler. The session context arrives with the
// handshake and is read by later callbacks, so it travels through an atomic.
type plugin struct {
id string
log *log.Logger
ui extension.HostUI
session atomic.Pointer[extension.SessionContext]
}
func main() {
logger := log.New(os.Stderr, "fullsidecar: ", log.LstdFlags)
id := strings.TrimSpace(os.Getenv("REASONIX_PLUGIN_NAME"))
if id == "" {
id = defaultPluginID
}
p := &plugin{id: id, log: logger}
provider := &fakeProvider{id: id, interval: streamInterval(), log: logger}
err := extension.Serve(context.Background(), p, extension.Options{
Name: id,
Version: "1.0.0",
Interceptors: map[string]extension.InterceptorFunc{
"input.receive": func(ctx context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) {
return p.interceptInput(ctx, payload)
},
"tool.before": func(ctx context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) {
return p.interceptTool(ctx, payload)
},
"system_prompt.build": func(ctx context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) {
return p.interceptSystemPrompt(ctx, payload)
},
},
Observer: p.observe,
Provider: provider,
UI: extension.UIHandler{
Action: p.action,
Submit: p.submit,
},
Shutdown: func(context.Context) { logger.Print("shutdown requested; exiting") },
Logger: logger,
})
if err != nil {
logger.Printf("serve: %v", err)
os.Exit(1)
}
// Serve returned nil: the host asked for shutdown. Exit 0 so the host
// reaps the process as an orderly stop.
}
// streamInterval reads FULLSIDECAR_STREAM_INTERVAL_MS with a 15ms default.
func streamInterval() time.Duration {
if raw := strings.TrimSpace(os.Getenv("FULLSIDECAR_STREAM_INTERVAL_MS")); raw != "" {
if ms, err := strconv.Atoi(raw); err == nil && ms > 0 {
return time.Duration(ms) * time.Millisecond
}
}
return 15 * time.Millisecond
}
// Initialize declares everything this extension contributes. The host rejects
// anything the installed manifest did not declare first.
func (p *plugin) Initialize(_ context.Context, params extension.InitializeParams) (*extension.InitializeResult, error) {
session := params.Session
p.session.Store(&session)
p.log.Printf("initialized for session %s (workspace %s)", session.SessionID, session.WorkspaceRoot)
return &extension.InitializeResult{
Subscriptions: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"},
Replaces: []string{"system_prompt"},
Providers: []extension.ProviderDescriptor{fakeDescriptor(p.id)},
UIActions: []extension.UIActionDecl{{ActionID: "demo", Label: "Run the fullsidecar demo"}},
}, nil
}
// ---------------------------------------------------------------------------
// Interceptors
// ---------------------------------------------------------------------------
// interceptInput rewrites any input that starts with the "/fs " trigger.
func (p *plugin) interceptInput(ctx context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
var in struct {
Text string `json:"text"`
}
if err := json.Unmarshal(payload, &in); err != nil {
return extension.Continue(), nil
}
// Conformance hooks (see the package comment); inert when unset.
if crash := os.Getenv("FULLSIDECAR_CRASH_ON_INPUT"); crash != "" && in.Text == crash {
p.log.Printf("crash hook triggered by input %q", in.Text)
os.Exit(3)
}
if stall := os.Getenv("FULLSIDECAR_STALL_ON_INPUT"); stall != "" && in.Text == stall {
<-ctx.Done()
return nil, ctx.Err()
}
if !strings.HasPrefix(in.Text, rewritePrefix) {
return extension.Continue(), nil
}
rewritten := strings.TrimPrefix(in.Text, rewritePrefix) + rewriteSuffix
p.log.Printf("input.receive: rewrote %q → %q", in.Text, rewritten)
return extension.Replace(map[string]string{"text": rewritten})
}
// interceptTool blocks the denied tool outright and rewrites the arguments of
// the rewritten tool; every other tool continues untouched.
func (p *plugin) interceptTool(_ context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
var call struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
if err := json.Unmarshal(payload, &call); err != nil {
return extension.Continue(), nil
}
switch call.Name {
case deniedTool:
return extension.Block("fullsidecar: tool " + deniedTool + " is denied by the demo policy"), nil
case rewrittenTool:
args := map[string]any{}
if strings.TrimSpace(call.Arguments) != "" {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
return extension.Continue(), nil
}
}
args["sandbox"] = true
encoded, err := json.Marshal(args)
if err != nil {
return extension.Continue(), nil
}
return extension.Replace(map[string]string{"name": call.Name, "arguments": string(encoded)})
default:
return extension.Continue(), nil
}
}
// interceptSystemPrompt owns the system_prompt strategy slot: it wraps the
// base prompt instead of letting the default assembler render it.
func (p *plugin) interceptSystemPrompt(_ context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
var in struct {
Prompt string `json:"prompt"`
WorkspaceRoot string `json:"workspaceRoot"`
}
if err := json.Unmarshal(payload, &in); err != nil {
return nil, err
}
owned := "You are Reasonix running under the fullsidecar demo strategy.\n\n" +
"Workspace: " + in.WorkspaceRoot + "\n\nBase prompt:\n" + in.Prompt
return extension.Replace(map[string]string{"prompt": owned, "workspaceRoot": in.WorkspaceRoot})
}
// ---------------------------------------------------------------------------
// Observation and UI
// ---------------------------------------------------------------------------
// observe publishes the extension's status line and demo card when the
// session starts.
func (p *plugin) observe(ctx context.Context, event string, _ json.RawMessage) {
if event != "session.start" {
return
}
session := p.session.Load()
if session == nil {
return
}
if err := p.ui.PublishStatus(ctx, session.SessionID, session.Generation, "fullsidecar-status", extension.UIStatusPayload{
Label: "fullsidecar online",
Detail: "intercepts, provider, and UI are live",
Severity: extension.UISeverityInfo,
}); err != nil {
p.log.Printf("publish status: %v", err)
}
if err := p.ui.PublishCard(ctx, session.SessionID, session.Generation, "fullsidecar-card", extension.UICardPayload{
Title: "fullsidecar",
Markdown: "Reference extension: try the **demo** action or the `/fs ` input trigger.",
Fields: []extension.UIKeyValue{{Key: "plugin", Value: p.id}, {Key: "provider", Value: fakeRef(p.id)}},
Actions: []extension.UIActionRef{{ActionID: "demo", Label: "Run demo"}},
}); err != nil {
p.log.Printf("publish card: %v", err)
}
}
// action runs the declared "demo" action: a blocking form prompt, then a
// notification built from the answers. A dismissed prompt is not a failure.
func (p *plugin) action(ctx context.Context, actionID string, _ map[string]string) error {
if actionID != "demo" {
return fmt.Errorf("fullsidecar: unknown action %q", actionID)
}
session := p.session.Load()
if session == nil {
return errors.New("fullsidecar: no session yet")
}
values, err := p.ui.RequestForm(ctx, session.SessionID, session.Generation, "fullsidecar-demo-form", extension.UIFormPayload{
Title: "fullsidecar demo",
Message: "Whom should the demo greet?",
Fields: []extension.UIFormField{
{Key: "name", Label: "Your name", Kind: extension.UIFieldInput, Required: true},
{Key: "loud", Label: "Shout the greeting", Kind: extension.UIFieldConfirm},
},
})
if errors.Is(err, extension.ErrUICancelled) {
return nil
}
if err != nil {
return err
}
name, _ := values["name"].(string)
if strings.TrimSpace(name) == "" {
name = "world"
}
greeting := "Hello, " + name + "!"
if loud, _ := values["loud"].(bool); loud {
greeting = strings.ToUpper(greeting)
}
return p.ui.PublishNotification(ctx, session.SessionID, session.Generation, "fullsidecar-greeting", extension.UINotificationPayload{
Title: greeting,
Severity: extension.UISeverityInfo,
})
}
// submit acknowledges published-form submissions with a status update.
func (p *plugin) submit(ctx context.Context, surfaceID string, values map[string]any) error {
p.log.Printf("form %q submitted: %v", surfaceID, values)
session := p.session.Load()
if session == nil {
return nil
}
return p.ui.PublishStatus(ctx, session.SessionID, session.Generation, "fullsidecar-status", extension.UIStatusPayload{
Label: "fullsidecar: form " + surfaceID + " submitted",
Severity: extension.UISeverityInfo,
})
}
// ---------------------------------------------------------------------------
// Fake streaming provider
// ---------------------------------------------------------------------------
func fakeRef(pluginID string) string { return "plugin/" + pluginID + "/fake/" + fakeModel }
func fakeDescriptor(pluginID string) extension.ProviderDescriptor {
return extension.ProviderDescriptor{
Ref: fakeRef(pluginID),
DisplayName: "fullsidecar fake",
Model: fakeModel,
ContextWindow: 64000,
Tools: true,
Reasoning: true,
Efforts: []string{"low", "high"},
DefaultEffort: "low",
}
}
// fakeProvider streams a fixed scripted completion: two text chunks, one tool
// call, final usage, done. Chunks are paced so hosts can exercise mid-stream
// cancel; a cancelled context stops production immediately, and the SDK ends
// the stream interrupted.
type fakeProvider struct {
id string
interval time.Duration
log *log.Logger
}
func (p *fakeProvider) Catalog(context.Context) ([]extension.ProviderDescriptor, error) {
return []extension.ProviderDescriptor{fakeDescriptor(p.id)}, nil
}
func (p *fakeProvider) Stream(ctx context.Context, req extension.StreamRequest) (<-chan extension.StreamChunk, error) {
if req.ProviderRef != fakeRef(p.id) {
return nil, fmt.Errorf("fullsidecar: unknown provider ref %q", req.ProviderRef)
}
p.log.Printf("stream %s opened for %s (model %s)", req.StreamID, req.ProviderRef, req.Model)
chunks := make(chan extension.StreamChunk)
go func() {
defer close(chunks)
script := []extension.StreamChunk{
extension.TextChunk("fake-hello "),
extension.TextChunk("fake-world"),
{Type: extension.ChunkToolCall, ToolCall: &extension.ProviderToolCall{
ID: "call-1", Name: "lookup", Arguments: `{"query":"reasonix"}`,
}},
extension.UsageChunk(extension.ProviderUsage{
PromptTokens: 5, CompletionTokens: 7, TotalTokens: 12,
CacheHitTokens: 2, CacheMissTokens: 3, ReasoningTokens: 4,
FinishReason: "stop",
}),
extension.DoneChunk(),
}
for _, chunk := range script {
select {
case <-ctx.Done():
return
case <-time.After(p.interval):
}
select {
case <-ctx.Done():
return
case chunks <- chunk:
}
}
}()
return chunks, nil
}
+427
View File
@@ -0,0 +1,427 @@
package extension
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"log"
"sync"
"testing"
"time"
)
// fakeHost is a scriptable in-memory Reasonix host speaking raw JSON-RPC
// over two io.Pipes: it writes Host → Extension frames into the SDK's stdin
// pipe and reads the SDK's stdout pipe, answering Extension → Host requests
// with scripted handlers.
type fakeHost struct {
t *testing.T
toSDK *io.PipeWriter // host writes, SDK reads
fromSDK *io.PipeReader // SDK writes, host reads
writeMu sync.Mutex
nextID int64
pendingMu sync.Mutex
pending map[int64]chan hostResponse
strayMu sync.Mutex
strays []strayResponse
handlersMu sync.Mutex
handlers map[string]func(params json.RawMessage) (any, *hostError)
requestLog map[string][]json.RawMessage
notesMu sync.Mutex
notifications []hostNotification
readerDone chan struct{}
}
type hostError struct {
Code int
Message string
Data any
}
type hostResponse struct {
Result json.RawMessage
Err *hostError
}
// strayResponse is an SDK response with no matching pending host request —
// typically a -32600 rejection of a malformed raw frame.
type strayResponse struct {
ID json.RawMessage
Error *hostError
}
type hostNotification struct {
Method string
Params json.RawMessage
}
type hostFrame struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *hostErrorFrame `json:"error"`
}
type hostErrorFrame struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// serveWaiter caches Serve's result so both the test and its cleanup can
// observe it exactly once.
type serveWaiter struct {
ch chan error
mu sync.Mutex
err error
received bool
}
// wait blocks up to timeout for Serve's result; later calls return the
// cached value. ok is false on timeout.
func (w *serveWaiter) wait(timeout time.Duration) (err error, ok bool) {
w.mu.Lock()
if w.received {
w.mu.Unlock()
return w.err, true
}
w.mu.Unlock()
select {
case err := <-w.ch:
w.mu.Lock()
w.err = err
w.received = true
w.mu.Unlock()
return err, true
case <-time.After(timeout):
return nil, false
}
}
// startFakeHost launches Serve against a fake host and returns both. The
// host's read loop answers SDK requests until cleanup.
func startFakeHost(t *testing.T, h Handler, opts Options) (*fakeHost, *serveWaiter) {
t.Helper()
sdkStdinR, sdkStdinW := io.Pipe()
sdkStdoutR, sdkStdoutW := io.Pipe()
opts.Stdin = sdkStdinR
opts.Stdout = sdkStdoutW
if opts.Logger == nil {
opts.Logger = log.New(io.Discard, "", 0)
}
waiter := &serveWaiter{ch: make(chan error, 1)}
go func() { waiter.ch <- Serve(context.Background(), h, opts) }()
host := &fakeHost{
t: t,
toSDK: sdkStdinW,
fromSDK: sdkStdoutR,
pending: make(map[int64]chan hostResponse),
handlers: make(map[string]func(json.RawMessage) (any, *hostError)),
requestLog: make(map[string][]json.RawMessage),
readerDone: make(chan struct{}),
}
go host.readLoop()
t.Cleanup(func() {
_ = host.toSDK.Close()
_ = host.fromSDK.Close()
<-host.readerDone
if _, ok := waiter.wait(5 * time.Second); !ok {
t.Errorf("Serve did not return after the transport closed")
}
})
return host, waiter
}
// readLoop consumes every frame the SDK writes: responses resolve pending
// host requests, requests are routed to scripted handlers, notifications are
// recorded.
func (h *fakeHost) readLoop() {
defer close(h.readerDone)
scanner := bufio.NewScanner(h.fromSDK)
scanner.Buffer(make([]byte, 0, 64<<10), FrameBytes*2)
for scanner.Scan() {
line := scanner.Bytes()
var frame hostFrame
if err := json.Unmarshal(line, &frame); err != nil {
h.t.Errorf("fake host: undecodable SDK frame %q: %v", line, err)
continue
}
switch {
case frame.Method != "" && len(frame.ID) > 0:
h.serveSDKRequest(frame)
case frame.Method != "":
h.notesMu.Lock()
h.notifications = append(h.notifications, hostNotification{Method: frame.Method, Params: frame.Params})
h.notesMu.Unlock()
case len(frame.ID) > 0:
resp := hostResponse{Result: frame.Result}
if frame.Error != nil {
resp.Err = &hostError{Code: frame.Error.Code, Message: frame.Error.Message}
if len(frame.Error.Data) > 0 {
var data ProtocolErrorData
if err := json.Unmarshal(frame.Error.Data, &data); err == nil {
resp.Err.Data = data
}
}
}
var id int64
if err := json.Unmarshal(frame.ID, &id); err != nil {
h.recordStray(frame.ID, resp.Err)
continue
}
h.pendingMu.Lock()
ch := h.pending[id]
delete(h.pending, id)
h.pendingMu.Unlock()
if ch == nil {
h.recordStray(frame.ID, resp.Err)
continue
}
ch <- resp
}
}
}
func (h *fakeHost) recordStray(id json.RawMessage, herr *hostError) {
h.strayMu.Lock()
defer h.strayMu.Unlock()
h.strays = append(h.strays, strayResponse{ID: append(json.RawMessage(nil), id...), Error: herr})
}
// nextStray waits for one stray error response and returns it.
func (h *fakeHost) nextStray() strayResponse {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
h.strayMu.Lock()
if len(h.strays) > 0 {
stray := h.strays[0]
h.strays = h.strays[1:]
h.strayMu.Unlock()
return stray
}
h.strayMu.Unlock()
time.Sleep(5 * time.Millisecond)
}
h.t.Fatalf("fake host: no stray response within 5s")
return strayResponse{}
}
func (h *fakeHost) serveSDKRequest(frame hostFrame) {
h.handlersMu.Lock()
handler := h.handlers[frame.Method]
h.requestLog[frame.Method] = append(h.requestLog[frame.Method], append(json.RawMessage(nil), frame.Params...))
h.handlersMu.Unlock()
var result any
var herr *hostError
if handler == nil {
herr = &hostError{Code: CodeMethodNotFound, Message: "method not found: " + frame.Method}
} else {
result, herr = handler(frame.Params)
}
var out []byte
if herr != nil {
errorFrame := map[string]any{"code": herr.Code, "message": herr.Message}
if herr.Data != nil {
errorFrame["data"] = herr.Data
}
out, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(frame.ID), "error": errorFrame})
} else {
raw, _ := json.Marshal(result)
out, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(frame.ID), "result": json.RawMessage(raw)})
}
h.writeLine(out)
}
// onRequest installs the scripted answerer for one Extension → Host method.
func (h *fakeHost) onRequest(method string, handler func(params json.RawMessage) (any, *hostError)) {
h.handlersMu.Lock()
defer h.handlersMu.Unlock()
h.handlers[method] = handler
}
// lastRawParams returns the raw params of the most recent Extension → Host
// request for method.
func (h *fakeHost) lastRawParams(t *testing.T, method string) json.RawMessage {
t.Helper()
h.handlersMu.Lock()
defer h.handlersMu.Unlock()
log := h.requestLog[method]
if len(log) == 0 {
t.Fatalf("fake host: no %s request recorded", method)
}
return log[len(log)-1]
}
// request sends one Host → Extension request and waits for its response.
func (h *fakeHost) request(method string, params any) hostResponse {
_, ch := h.startRequest(method, params)
select {
case resp := <-ch:
return resp
case <-time.After(10 * time.Second):
h.t.Fatalf("fake host: no response to %s", method)
return hostResponse{}
}
}
// startRequest sends one Host → Extension request without waiting; the
// response arrives on the returned channel.
func (h *fakeHost) startRequest(method string, params any) (int64, chan hostResponse) {
h.pendingMu.Lock()
h.nextID++
id := h.nextID
ch := make(chan hostResponse, 1)
h.pending[id] = ch
h.pendingMu.Unlock()
raw, _ := json.Marshal(params)
frame, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": id, "method": method, "params": json.RawMessage(raw),
})
h.writeLine(frame)
return id, ch
}
// notify sends one Host → Extension notification.
func (h *fakeHost) notify(method string, params any) {
raw, _ := json.Marshal(params)
frame, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": json.RawMessage(raw)})
h.writeLine(frame)
}
// writeRaw sends one unvalidated frame, for strict-frame violation tests.
func (h *fakeHost) writeRaw(frame []byte) { h.writeLine(frame) }
func (h *fakeHost) writeLine(frame []byte) {
h.writeMu.Lock()
defer h.writeMu.Unlock()
if _, err := h.toSDK.Write(append(frame, '\n')); err != nil {
h.t.Errorf("fake host: write to SDK: %v", err)
}
}
// handshake runs the standard initialize + initialized sequence and returns
// the decoded initialize result.
func (h *fakeHost) handshake(t *testing.T) InitializeResult {
t.Helper()
resp := h.request(MethodExtensionInitialize, InitializeParams{
ProtocolVersion: ProtocolVersion,
ProtocolID: ProtocolID,
Manifest: ManifestExpectation{Intercepts: InterceptEvents(), Capabilities: []string{"providers", "ui"}},
Session: SessionContext{SessionID: "sess-1", WorkspaceRoot: "/repo", Generation: 7},
Capabilities: HostCapabilities{ContentRefs: true, UIHost: UIHostHeadless, ProtocolVersion: ProtocolVersion},
})
if resp.Err != nil {
t.Fatalf("initialize failed: %+v", resp.Err)
}
var result InitializeResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
t.Fatalf("decode initialize result: %v", err)
}
h.notify(MethodExtensionInitialized, InitializedParams{})
return result
}
// nextNotification waits for one SDK notification with the given method.
func (h *fakeHost) nextNotification(method string) hostNotification {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
h.notesMu.Lock()
for i, note := range h.notifications {
if note.Method == method {
h.notifications = append(h.notifications[:i], h.notifications[i+1:]...)
h.notesMu.Unlock()
return note
}
}
h.notesMu.Unlock()
time.Sleep(5 * time.Millisecond)
}
h.t.Fatalf("fake host: no %s notification within 5s", method)
return hostNotification{}
}
// notificationsSnapshot returns all recorded SDK notifications.
func (h *fakeHost) notificationsSnapshot() []hostNotification {
h.notesMu.Lock()
defer h.notesMu.Unlock()
return append([]hostNotification(nil), h.notifications...)
}
// streamNotifications returns all stream/chunk and stream/end notifications
// recorded so far, in arrival order, decoded.
func (h *fakeHost) streamNotifications() (chunks []StreamChunkParams, ends []StreamEndParams) {
for _, note := range h.notificationsSnapshot() {
switch note.Method {
case MethodExtensionProviderStreamChunk:
var p StreamChunkParams
if err := json.Unmarshal(note.Params, &p); err == nil {
chunks = append(chunks, p)
}
case MethodExtensionProviderStreamEnd:
var p StreamEndParams
if err := json.Unmarshal(note.Params, &p); err == nil {
ends = append(ends, p)
}
}
}
return chunks, ends
}
// waitStreamEnd polls until one stream/end notification arrives and returns
// it decoded. It does not consume anything.
func (h *fakeHost) waitStreamEnd() StreamEndParams {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
_, ends := h.streamNotifications()
if len(ends) > 0 {
return ends[len(ends)-1]
}
time.Sleep(5 * time.Millisecond)
}
h.t.Fatalf("fake host: no stream/end within 5s")
return StreamEndParams{}
}
// testHandler is a Handler returning a fixed declaration.
type testHandler struct {
result *InitializeResult
err error
seen *InitializeParams
}
func (h *testHandler) Initialize(_ context.Context, p InitializeParams) (*InitializeResult, error) {
if h.seen != nil {
*h.seen = p
}
if h.err != nil {
return nil, h.err
}
return h.result, nil
}
// HandlerFunc adapts a function to the Handler interface.
type HandlerFunc func(ctx context.Context, p InitializeParams) (*InitializeResult, error)
// Initialize implements Handler.
func (f HandlerFunc) Initialize(ctx context.Context, p InitializeParams) (*InitializeResult, error) {
return f(ctx, p)
}
func basicHandler() *testHandler {
return &testHandler{result: &InitializeResult{
Name: "test-ext", Version: "0.1.0",
Subscriptions: []string{"tool.before"},
}}
}
var errTest = errors.New("test error")
+3
View File
@@ -0,0 +1,3 @@
module github.com/esengine/DeepSeek-Reasonix/sdk/go
go 1.23
+305
View File
@@ -0,0 +1,305 @@
package extension
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
)
// scriptProvider is a test Provider with scripted catalog and streams.
type scriptProvider struct {
catalog []ProviderDescriptor
catalogErr error
streamErr error
// makeChannel builds the chunk channel for one Stream call; the test owns
// the channel lifecycle.
makeChannel func(req StreamRequest) <-chan StreamChunk
mu sync.Mutex
requests []StreamRequest
}
func (p *scriptProvider) Catalog(context.Context) ([]ProviderDescriptor, error) {
if p.catalogErr != nil {
return nil, p.catalogErr
}
return p.catalog, nil
}
func (p *scriptProvider) Stream(_ context.Context, req StreamRequest) (<-chan StreamChunk, error) {
p.mu.Lock()
p.requests = append(p.requests, req)
p.mu.Unlock()
if p.streamErr != nil {
return nil, p.streamErr
}
return p.makeChannel(req), nil
}
func providerHandler() *testHandler {
return &testHandler{result: &InitializeResult{
Name: "provider-ext", Version: "1.0.0",
Providers: []ProviderDescriptor{{Ref: "plugin/provider-ext/echo", DisplayName: "Echo", Model: "echo-1"}},
}}
}
func openStreamRequest(streamID string) StreamOpenParams {
return StreamOpenParams{
StreamID: streamID,
ProviderRef: "plugin/provider-ext/echo",
Model: "echo-1",
Request: ProviderRequest{
Messages: []ProviderMessage{{Role: ProviderRoleUser, Content: "hi"}},
Tools: []ProviderToolSchema{},
},
SeqBase: 1,
}
}
// TestProviderCatalog serves extension/provider/catalog.
func TestProviderCatalog(t *testing.T) {
provider := &scriptProvider{catalog: []ProviderDescriptor{
{Ref: "plugin/provider-ext/echo", DisplayName: "Echo", Model: "echo-1", ContextWindow: 8192, Tools: true},
}}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
resp := host.request(MethodExtensionProviderCatalog, ProviderCatalogParams{})
if resp.Err != nil {
t.Fatalf("catalog failed: %+v", resp.Err)
}
var result ProviderCatalogResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
t.Fatalf("decode catalog: %v", err)
}
if len(result.Providers) != 1 || result.Providers[0].Ref != "plugin/provider-ext/echo" || !result.Providers[0].Tools {
t.Fatalf("catalog = %+v", result.Providers)
}
}
// TestProviderCatalogNil ensures the array shape survives an empty catalog:
// the wire requires "providers":[], never null.
func TestProviderCatalogNil(t *testing.T) {
provider := &scriptProvider{}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
resp := host.request(MethodExtensionProviderCatalog, ProviderCatalogParams{})
var raw struct {
Providers json.RawMessage `json:"providers"`
}
if err := json.Unmarshal(resp.Result, &raw); err != nil {
t.Fatalf("decode: %v", err)
}
if string(raw.Providers) != "[]" {
t.Fatalf("providers = %s, want []", raw.Providers)
}
}
// TestProviderCatalogWithoutProvider answers unknown_method when no Provider
// is configured.
func TestProviderCatalogWithoutProvider(t *testing.T) {
host, _ := startFakeHost(t, basicHandler(), Options{})
host.handshake(t)
resp := host.request(MethodExtensionProviderCatalog, ProviderCatalogParams{})
if resp.Err == nil || resp.Err.Code != CodeMethodNotFound {
t.Fatalf("expected unknown_method, got %+v", resp.Err)
}
}
// TestProviderStreamPump verifies contiguous 1-based seqs and the terminal
// stream/end lastSeq.
func TestProviderStreamPump(t *testing.T) {
chunks := make(chan StreamChunk, 4)
chunks <- TextChunk("Hello")
chunks <- ReasoningChunk("thinking", "sig-1")
chunks <- UsageChunk(ProviderUsage{PromptTokens: 3, CompletionTokens: 2, TotalTokens: 5, FinishReason: "stop"})
close(chunks)
provider := &scriptProvider{makeChannel: func(StreamRequest) <-chan StreamChunk { return chunks }}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
resp := host.request(MethodExtensionProviderStreamOpen, openStreamRequest("stream-1"))
if resp.Err != nil {
t.Fatalf("stream open failed: %+v", resp.Err)
}
var opened StreamOpenResult
if err := json.Unmarshal(resp.Result, &opened); err != nil || !opened.Accepted {
t.Fatalf("open result = %+v", opened)
}
endParams := host.waitStreamEnd()
sentChunks, ends := host.streamNotifications()
if len(ends) != 1 {
t.Fatalf("stream/end count = %d, want exactly 1", len(ends))
}
if endParams.StreamID != "stream-1" || endParams.LastSeq != 3 || endParams.Error != "" || endParams.Interrupted {
t.Fatalf("end = %+v, want lastSeq 3 clean", endParams)
}
for i, chunk := range sentChunks {
if chunk.Seq != int64(i+1) {
t.Fatalf("chunk %d seq = %d, want contiguous 1-based", i, chunk.Seq)
}
if chunk.StreamID != "stream-1" {
t.Fatalf("chunk %d streamId = %q", i, chunk.StreamID)
}
}
if sentChunks[0].Chunk.Type != ChunkText || sentChunks[0].Chunk.Text != "Hello" {
t.Fatalf("chunk 0 = %+v", sentChunks[0].Chunk)
}
if sentChunks[1].Chunk.Type != ChunkReasoning || sentChunks[1].Chunk.Signature != "sig-1" {
t.Fatalf("chunk 1 = %+v", sentChunks[1].Chunk)
}
if sentChunks[2].Chunk.Usage == nil || sentChunks[2].Chunk.Usage.TotalTokens != 5 {
t.Fatalf("chunk 2 = %+v", sentChunks[2].Chunk)
}
}
// TestProviderStreamCancel asserts a processed cancel stops chunk production:
// no chunk may be sent after the cancel response, and the stream ends
// interrupted.
func TestProviderStreamCancel(t *testing.T) {
chunks := make(chan StreamChunk) // unbuffered: every send is visible
provider := &scriptProvider{makeChannel: func(StreamRequest) <-chan StreamChunk { return chunks }}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
resp := host.request(MethodExtensionProviderStreamOpen, openStreamRequest("stream-c"))
var opened StreamOpenResult
if err := json.Unmarshal(resp.Result, &opened); err != nil || !opened.Accepted {
t.Fatalf("open result = %+v", opened)
}
// Feed one chunk, wait for it on the wire.
go func() { chunks <- TextChunk("one") }()
first := host.nextNotification(MethodExtensionProviderStreamChunk)
// Cancel; the response means the SDK processed it.
resp = host.request(MethodExtensionProviderStreamCancel, StreamCancelParams{StreamID: "stream-c"})
var cancelled StreamCancelResult
if err := json.Unmarshal(resp.Result, &cancelled); err != nil || !cancelled.Cancelled {
t.Fatalf("cancel result = %+v respErr=%+v", cancelled, resp.Err)
}
// Keep producing: none of these may reach the wire.
go func() {
for i := 0; i < 5; i++ {
chunks <- TextChunk("late")
}
}()
endParams := host.waitStreamEnd()
if !endParams.Interrupted || endParams.LastSeq != 1 {
t.Fatalf("end = %+v, want interrupted lastSeq 1", endParams)
}
sentChunks, _ := host.streamNotifications()
for _, chunk := range sentChunks {
if chunk.Seq > 1 {
t.Fatalf("chunk seq %d sent after the cancel was processed", chunk.Seq)
}
}
var firstParams StreamChunkParams
if err := json.Unmarshal(first.Params, &firstParams); err != nil || firstParams.Seq != 1 {
t.Fatalf("first chunk = %+v", firstParams)
}
}
// TestProviderStreamErrorChunk maps a provider error chunk to stream/end's
// error field without forwarding the chunk.
func TestProviderStreamErrorChunk(t *testing.T) {
chunks := make(chan StreamChunk, 2)
chunks <- TextChunk("partial")
chunks <- ErrorChunk("provider upstream unavailable")
close(chunks)
provider := &scriptProvider{makeChannel: func(StreamRequest) <-chan StreamChunk { return chunks }}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
host.request(MethodExtensionProviderStreamOpen, openStreamRequest("stream-e"))
endParams := host.waitStreamEnd()
if endParams.Error != "provider upstream unavailable" {
t.Fatalf("end.error = %q", endParams.Error)
}
if endParams.LastSeq != 1 || endParams.Interrupted {
t.Fatalf("end = %+v, want lastSeq 1 not interrupted", endParams)
}
sentChunks, _ := host.streamNotifications()
if len(sentChunks) != 1 || sentChunks[0].Chunk.Type != ChunkText {
t.Fatalf("chunks = %+v, want only the text chunk forwarded", sentChunks)
}
}
// TestProviderStreamOpenError answers provider_failed when Stream refuses to
// open.
func TestProviderStreamOpenError(t *testing.T) {
provider := &scriptProvider{streamErr: errors.New("quota exhausted")}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
resp := host.request(MethodExtensionProviderStreamOpen, openStreamRequest("stream-f"))
if resp.Err == nil {
t.Fatal("expected provider_failed")
}
data, _ := resp.Err.Data.(ProtocolErrorData)
if data.Reason != ErrProviderFailed {
t.Fatalf("reason = %q, want provider_failed", data.Reason)
}
}
// TestProviderStreamOpenInvalidEnvelope rejects malformed opens before they
// reach the Provider.
func TestProviderStreamOpenInvalidEnvelope(t *testing.T) {
var calls atomic.Int64
provider := &scriptProvider{makeChannel: func(StreamRequest) <-chan StreamChunk {
calls.Add(1)
return make(chan StreamChunk)
}}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
frames := []string{
`{"providerRef":"x","request":{"messages":[],"tools":[]},"seqBase":1,"streamId":""}`,
`{"providerRef":"x","request":{"messages":null,"tools":[]},"seqBase":1,"streamId":"s"}`,
`{"providerRef":"x","request":{"messages":[],"tools":[{"name":"t","parameters":[1]}]},"seqBase":1,"streamId":"s"}`,
`{"providerRef":"x","request":{"messages":[],"tools":[]},"seqBase":-1,"streamId":"s"}`,
}
for _, params := range frames {
resp := host.request(MethodExtensionProviderStreamOpen, json.RawMessage(params))
if resp.Err == nil || resp.Err.Code != CodeInvalidParams {
t.Fatalf("params %s: expected invalid_params, got %+v", params, resp.Err)
}
}
if calls.Load() != 0 {
t.Fatalf("Stream called %d times on invalid envelopes", calls.Load())
}
}
// TestStreamRequestPassedThrough checks the helper-level StreamRequest maps
// the wire params faithfully.
func TestStreamRequestPassedThrough(t *testing.T) {
chunks := make(chan StreamChunk)
close(chunks)
provider := &scriptProvider{makeChannel: func(StreamRequest) <-chan StreamChunk { return chunks }}
host, _ := startFakeHost(t, providerHandler(), Options{Provider: provider})
host.handshake(t)
open := openStreamRequest("stream-req")
open.Effort = "high"
open.Request.MaxTokens = 128
temp := 0.5
open.Request.Temperature = &temp
host.request(MethodExtensionProviderStreamOpen, open)
host.waitStreamEnd()
provider.mu.Lock()
defer provider.mu.Unlock()
if len(provider.requests) != 1 {
t.Fatalf("Stream calls = %d", len(provider.requests))
}
req := provider.requests[0]
if req.StreamID != "stream-req" || req.ProviderRef != "plugin/provider-ext/echo" || req.Model != "echo-1" || req.Effort != "high" {
t.Fatalf("request = %+v", req)
}
if req.Request.MaxTokens != 128 || req.Request.Temperature == nil || *req.Request.Temperature != 0.5 {
t.Fatalf("provider request = %+v", req.Request)
}
if len(req.Request.Messages) != 1 || req.Request.Messages[0].Content != "hi" {
t.Fatalf("messages = %+v", req.Request.Messages)
}
}
+1178
View File
File diff suppressed because it is too large. Load diff
+564
View File
@@ -0,0 +1,564 @@
package extension
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestHandshakeHappyPath verifies the initialize exchange echoes the
// sidecar's declaration and the barrier opens on extension/initialized.
func TestHandshakeHappyPath(t *testing.T) {
handler := basicHandler()
handler.result.Subscriptions = []string{"tool.before", "session.start"}
handler.result.Replaces = []string{"model"}
handler.result.UIActions = []UIActionDecl{{ActionID: "open", Label: "Open"}}
var seen InitializeParams
handler.seen = &seen
host, _ := startFakeHost(t, handler, Options{})
result := host.handshake(t)
if result.ProtocolVersion != ProtocolVersion {
t.Fatalf("protocolVersion = %q, want %q", result.ProtocolVersion, ProtocolVersion)
}
if result.Name != "test-ext" || result.Version != "0.1.0" {
t.Fatalf("identity = %q/%q, want test-ext/0.1.0", result.Name, result.Version)
}
if len(result.Subscriptions) != 2 || result.Subscriptions[0] != "tool.before" {
t.Fatalf("subscriptions = %v", result.Subscriptions)
}
if len(result.UIActions) != 1 || result.UIActions[0].ActionID != "open" {
t.Fatalf("uiActions = %v", result.UIActions)
}
if seen.Session.SessionID != "sess-1" || seen.Session.Generation != 7 || seen.Session.WorkspaceRoot != "/repo" {
t.Fatalf("session context = %+v", seen.Session)
}
if !seen.Capabilities.ContentRefs || seen.Capabilities.UIHost != UIHostHeadless {
t.Fatalf("host capabilities = %+v", seen.Capabilities)
}
if seen.Manifest.Capabilities == nil {
t.Fatalf("manifest expectation = %+v", seen.Manifest)
}
}
// TestHostRequestBeforeInitializeRejected sends a non-initialize request
// first: it must be answered with the frozen protocol_error.
func TestHostRequestBeforeInitializeRejected(t *testing.T) {
host, _ := startFakeHost(t, basicHandler(), Options{})
resp := host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if resp.Err == nil {
t.Fatal("expected a protocol error for a request before initialize")
}
if resp.Err.Code != CodeInvalidRequest {
t.Fatalf("code = %d, want %d", resp.Err.Code, CodeInvalidRequest)
}
data, ok := resp.Err.Data.(ProtocolErrorData)
if !ok || data.Reason != ErrProtocolError {
t.Fatalf("error data = %+v, want reason protocol_error", resp.Err.Data)
}
}
// TestHostRequestBeforeInitializedRejected covers the barrier window: the
// handshake answer is out but extension/initialized has not arrived, so
// intercepts are still refused.
func TestHostRequestBeforeInitializedRejected(t *testing.T) {
host, _ := startFakeHost(t, basicHandler(), Options{})
resp := host.request(MethodExtensionInitialize, InitializeParams{
ProtocolVersion: ProtocolVersion, ProtocolID: ProtocolID,
Session: SessionContext{SessionID: "s", WorkspaceRoot: "/r"},
Capabilities: HostCapabilities{ProtocolVersion: ProtocolVersion},
})
if resp.Err != nil {
t.Fatalf("initialize failed: %+v", resp.Err)
}
resp = host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if resp.Err == nil || resp.Err.Code != CodeInvalidRequest {
t.Fatalf("expected protocol_error before initialized, got %+v", resp.Err)
}
// Notifications before the barrier are dropped silently (no response is
// possible); the connection must survive and the barrier must still open.
host.notify(MethodExtensionEvent, EventParams{Event: EventSessionStart, Payload: json.RawMessage(`{}`)})
host.notify(MethodExtensionInitialized, InitializedParams{})
resp = host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if resp.Err != nil {
t.Fatalf("intercept after initialized failed: %+v", resp.Err)
}
}
// TestOutboundCallBeforeInitializedFails checks the sidecar side of the
// barrier: Extension → Host calls from the Initialize handler return
// ErrNotReady.
func TestOutboundCallBeforeInitializedFails(t *testing.T) {
handler := &testHandler{}
var readyErr error
handlerWithProbe := HandlerFunc(func(ctx context.Context, p InitializeParams) (*InitializeResult, error) {
ui := HostUI{}
readyErr = ui.PublishNotification(ctx, p.Session.SessionID, p.Session.Generation, "probe",
UINotificationPayload{Title: "hi"})
return &InitializeResult{Name: "probe", Version: "1"}, nil
})
handler.result = &InitializeResult{Name: "x", Version: "1"}
host, _ := startFakeHost(t, handlerWithProbe, Options{})
host.handshake(t)
if !errors.Is(readyErr, ErrNotReady) {
t.Fatalf("outbound call before initialized = %v, want ErrNotReady", readyErr)
}
}
// TestInitializeVersionMismatch answers unsupported_version and ends Serve.
func TestInitializeVersionMismatch(t *testing.T) {
host, serveDone := startFakeHost(t, basicHandler(), Options{})
resp := host.request(MethodExtensionInitialize, InitializeParams{
ProtocolVersion: "2", ProtocolID: ProtocolID,
Session: SessionContext{SessionID: "s", WorkspaceRoot: "/r"},
Capabilities: HostCapabilities{ProtocolVersion: ProtocolVersion},
})
if resp.Err == nil {
t.Fatal("expected unsupported_version")
}
data, _ := resp.Err.Data.(ProtocolErrorData)
if data.Reason != ErrUnsupportedVersion {
t.Fatalf("reason = %q, want unsupported_version", data.Reason)
}
err, ok := serveDone.wait(5 * time.Second)
if !ok {
t.Fatal("Serve did not end after a failed handshake")
}
if err == nil {
t.Fatal("Serve returned nil after a failed handshake")
}
}
// TestUnknownMethod verifies the frozen unknown_method answer.
func TestUnknownMethod(t *testing.T) {
host, _ := startFakeHost(t, basicHandler(), Options{})
host.handshake(t)
resp := host.request("extension/bogus", struct{}{})
if resp.Err == nil || resp.Err.Code != CodeMethodNotFound {
t.Fatalf("expected -32601, got %+v", resp.Err)
}
data, _ := resp.Err.Data.(ProtocolErrorData)
if data.Reason != ErrUnknownMethod {
t.Fatalf("reason = %q, want unknown_method", data.Reason)
}
}
// TestOversizedInboundFrame kills the connection with a frame error.
func TestOversizedInboundFrame(t *testing.T) {
host, serveDone := startFakeHost(t, basicHandler(), Options{})
big := make([]byte, FrameBytes+16)
for i := range big {
big[i] = ' '
}
copy(big, []byte(`{"jsonrpc":"2.0","id":1,"method":"extension/initialize","params":{}}`))
host.writeRaw(big)
err, ok := serveDone.wait(5 * time.Second)
if !ok {
t.Fatal("Serve did not end on an oversized frame")
}
var tooLarge *FrameTooLargeError
if !errors.As(err, &tooLarge) {
t.Fatalf("Serve error = %v, want FrameTooLargeError", err)
}
}
// TestStrictFrameViolations checks envelope rejection: wrong jsonrpc
// version, string ids, and non-object params all answer -32600, and the
// connection survives to complete the handshake afterwards.
func TestStrictFrameViolations(t *testing.T) {
host, _ := startFakeHost(t, basicHandler(), Options{})
cases := []struct {
name string
frame string
wantID string // expected id of the rejection response
}{
{"wrong jsonrpc", `{"jsonrpc":"1.0","id":1,"method":"extension/initialize","params":{}}`, "1"},
{"missing jsonrpc", `{"id":2,"method":"extension/initialize","params":{}}`, "2"},
{"string id", `{"jsonrpc":"2.0","id":"abc","method":"extension/initialize","params":{}}`, "null"},
{"fractional id", `{"jsonrpc":"2.0","id":1.5,"method":"extension/initialize","params":{}}`, "null"},
{"array params", `{"jsonrpc":"2.0","id":3,"method":"extension/initialize","params":[]}`, "3"},
{"scalar params", `{"jsonrpc":"2.0","id":4,"method":"extension/initialize","params":42}`, "4"},
{"result and method", `{"jsonrpc":"2.0","id":5,"method":"extension/initialize","result":{}}`, "5"},
}
for _, tc := range cases {
host.writeRaw([]byte(tc.frame))
stray := host.nextStray()
if stray.Error == nil {
t.Fatalf("%s: frame %s: expected an error response, got a result", tc.name, tc.frame)
}
if stray.Error.Code != CodeInvalidRequest {
t.Fatalf("%s: code = %d, want %d", tc.name, stray.Error.Code, CodeInvalidRequest)
}
if string(stray.ID) != tc.wantID {
t.Fatalf("%s: response id = %s, want %s", tc.name, stray.ID, tc.wantID)
}
}
// The connection survived every rejection.
host.handshake(t)
}
// TestInterceptRouting exercises exact and wildcard routing plus the default
// continue answer.
func TestInterceptRouting(t *testing.T) {
var calls atomic.Int64
interceptors := map[string]InterceptorFunc{
"tool.before": func(_ context.Context, event string, payload json.RawMessage) (*InterceptResult, error) {
calls.Add(1)
if event != "tool.before" {
t.Errorf("event = %q, want tool.before", event)
}
return Block("no tools today"), nil
},
"*": func(_ context.Context, event string, payload json.RawMessage) (*InterceptResult, error) {
calls.Add(1)
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
resp := host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{"tool":"bash"}`), TimeoutMillis: 5000,
})
if resp.Err != nil {
t.Fatalf("intercept failed: %+v", resp.Err)
}
var result InterceptResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
t.Fatalf("decode intercept result: %v", err)
}
if result.Decision != DecisionBlock || result.Reason != "no tools today" {
t.Fatalf("result = %+v", result)
}
resp = host.request(MethodExtensionIntercept, InterceptParams{
Event: EventSessionStart, Seq: 2, Payload: json.RawMessage(`{}`),
})
if err := json.Unmarshal(resp.Result, &result); err != nil {
t.Fatalf("decode wildcard result: %v", err)
}
if result.Decision != DecisionContinue {
t.Fatalf("wildcard decision = %q, want continue", result.Decision)
}
if calls.Load() != 2 {
t.Fatalf("interceptor calls = %d, want 2", calls.Load())
}
}
// TestInterceptReplaceHelper checks Replace marshaling and the wire shape.
func TestInterceptReplace(t *testing.T) {
interceptors := map[string]InterceptorFunc{
"input.receive": func(_ context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
return Replace(map[string]any{"text": "rewritten", "n": 2})
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
resp := host.request(MethodExtensionIntercept, InterceptParams{
Event: EventInputReceive, Seq: 1, Payload: json.RawMessage(`{"text":"original"}`),
})
if resp.Err != nil {
t.Fatalf("intercept failed: %+v", resp.Err)
}
var wire struct {
Decision string `json:"decision"`
Replacement json.RawMessage `json:"replacement"`
}
if err := json.Unmarshal(resp.Result, &wire); err != nil {
t.Fatalf("decode: %v", err)
}
if wire.Decision != "replace" {
t.Fatalf("decision = %q", wire.Decision)
}
var replacement map[string]any
if err := json.Unmarshal(wire.Replacement, &replacement); err != nil {
t.Fatalf("replacement is not an object: %v", err)
}
if replacement["text"] != "rewritten" || replacement["n"] != float64(2) {
t.Fatalf("replacement = %v", replacement)
}
}
// TestInterceptAllowDeny covers the permission.decision rulings.
func TestInterceptAllowDeny(t *testing.T) {
interceptors := map[string]InterceptorFunc{
"permission.decision": func(_ context.Context, _ string, payload json.RawMessage) (*InterceptResult, error) {
if strings.Contains(string(payload), "dangerous") {
return Deny("too dangerous"), nil
}
return Allow(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
var result InterceptResult
resp := host.request(MethodExtensionIntercept, InterceptParams{
Event: EventPermissionDecision, Seq: 1, Payload: json.RawMessage(`{"command":"ls"}`),
})
if err := json.Unmarshal(resp.Result, &result); err != nil || result.Decision != DecisionAllow {
t.Fatalf("allow: result=%+v err=%v", result, err)
}
resp = host.request(MethodExtensionIntercept, InterceptParams{
Event: EventPermissionDecision, Seq: 2, Payload: json.RawMessage(`{"command":"dangerous"}`),
})
if err := json.Unmarshal(resp.Result, &result); err != nil || result.Decision != DecisionDeny || result.Reason != "too dangerous" {
t.Fatalf("deny: result=%+v err=%v", result, err)
}
}
// TestInterceptInvalidEnvelope verifies envelope validation stays with the
// SDK: unknown events, bad seqs, and missing payload keys answer
// invalid_params without reaching the interceptor.
func TestInterceptInvalidEnvelope(t *testing.T) {
var calls atomic.Int64
interceptors := map[string]InterceptorFunc{
"*": func(context.Context, string, json.RawMessage) (*InterceptResult, error) {
calls.Add(1)
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
frames := []string{
`{"event":"not.an.event","seq":1,"payload":{},"timeoutMillis":0}`,
`{"event":"tool.before","seq":0,"payload":{},"timeoutMillis":0}`,
`{"event":"tool.before","seq":1,"timeoutMillis":0}`,
`{"event":"tool.before","seq":1,"payload":{},"timeoutMillis":-1,"extra":1}`,
}
for _, params := range frames {
resp := host.request(MethodExtensionIntercept, json.RawMessage(params))
if resp.Err == nil || resp.Err.Code != CodeInvalidParams {
t.Fatalf("params %s: expected invalid_params, got %+v", params, resp.Err)
}
}
if calls.Load() != 0 {
t.Fatalf("interceptor ran %d times on invalid envelopes", calls.Load())
}
}
// TestInterceptHandlerPanic verifies a panicking interceptor answers the
// frozen internal error and the connection survives.
func TestInterceptHandlerPanic(t *testing.T) {
interceptors := map[string]InterceptorFunc{
"tool.before": func(context.Context, string, json.RawMessage) (*InterceptResult, error) {
panic("boom")
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
resp := host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if resp.Err == nil || resp.Err.Code != CodeInternal {
t.Fatalf("expected internal error, got %+v", resp.Err)
}
data, _ := resp.Err.Data.(ProtocolErrorData)
if data.Reason != ErrInternal {
t.Fatalf("reason = %q, want internal", data.Reason)
}
resp = host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 2, Payload: json.RawMessage(`{}`),
})
if resp.Err == nil {
t.Fatal("connection died after a handler panic")
}
}
// TestEventObservation checks extension/event reaches the observer and a
// panicking observer does not kill the loop.
func TestEventObservation(t *testing.T) {
seen := make(chan string, 4)
opts := Options{
Observer: func(_ context.Context, event string, payload json.RawMessage) {
seen <- event + ":" + string(payload)
},
}
host, _ := startFakeHost(t, basicHandler(), opts)
host.handshake(t)
host.notify(MethodExtensionEvent, EventParams{Event: EventToolAfter, Payload: json.RawMessage(`{"ok":true}`)})
select {
case got := <-seen:
if got != `tool.after:{"ok":true}` {
t.Fatalf("observation = %q", got)
}
case <-time.After(5 * time.Second):
t.Fatal("observer not called")
}
// A nil observer extension must not choke on events either.
host2, _ := startFakeHost(t, basicHandler(), Options{})
host2.handshake(t)
host2.notify(MethodExtensionEvent, EventParams{Event: EventToolAfter, Payload: json.RawMessage(`{}`)})
}
// TestResourcesChanged checks the resources/changed notification.
func TestResourcesChanged(t *testing.T) {
seen := make(chan []string, 1)
opts := Options{ResourcesChanged: func(_ context.Context, paths []string) { seen <- paths }}
host, _ := startFakeHost(t, basicHandler(), opts)
host.handshake(t)
host.notify(MethodExtensionResourcesChanged, ResourcesChangedParams{Paths: []string{"skills/a", "themes/b"}})
select {
case paths := <-seen:
if len(paths) != 2 || paths[0] != "skills/a" {
t.Fatalf("paths = %v", paths)
}
case <-time.After(5 * time.Second):
t.Fatal("ResourcesChanged not called")
}
}
// TestUIActionSubmit covers the Host → Extension UI calls.
func TestUIActionSubmit(t *testing.T) {
ui := UIHandler{
Action: func(_ context.Context, actionID string, args map[string]string) error {
if actionID == "fail" {
return errTest
}
if actionID != "open" || args["k"] != "v" {
return errors.New("bad action invocation")
}
return nil
},
Submit: func(_ context.Context, surfaceID string, values map[string]any) error {
if surfaceID != "form-1" || values["name"] != "reasonix" {
return errTest
}
return nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{UI: ui})
host.handshake(t)
resp := host.request(MethodExtensionUIAction, UIActionParams{
ActionID: "open", SessionID: "sess-1", Generation: 7, Args: map[string]string{"k": "v"},
})
var actionResult UIActionResult
if err := json.Unmarshal(resp.Result, &actionResult); err != nil || !actionResult.Accepted {
t.Fatalf("action: result=%+v err=%v respErr=%+v", actionResult, err, resp.Err)
}
resp = host.request(MethodExtensionUIAction, UIActionParams{ActionID: "fail", SessionID: "sess-1", Generation: 7})
if err := json.Unmarshal(resp.Result, &actionResult); err != nil {
t.Fatalf("action fail decode: %v", err)
}
if actionResult.Accepted || actionResult.Message != errTest.Error() {
t.Fatalf("action fail result = %+v", actionResult)
}
resp = host.request(MethodExtensionUISubmit, UISubmitParams{
SurfaceID: "form-1", SessionID: "sess-1", Generation: 7, Values: map[string]any{"name": "reasonix"},
})
var submitResult UISubmitResult
if err := json.Unmarshal(resp.Result, &submitResult); err != nil || !submitResult.Accepted {
t.Fatalf("submit: result=%+v err=%v", submitResult, err)
}
// Without UI configured the methods answer unknown_method.
host2, _ := startFakeHost(t, basicHandler(), Options{})
host2.handshake(t)
resp = host2.request(MethodExtensionUIAction, UIActionParams{ActionID: "x", SessionID: "s", Generation: 1})
if resp.Err == nil || resp.Err.Code != CodeMethodNotFound {
t.Fatalf("expected unknown_method without UI, got %+v", resp.Err)
}
}
// TestShutdownSequence runs the graceful stop: the fn runs with its timeout,
// {accepted:true} is answered, and Serve returns nil.
func TestShutdownSequence(t *testing.T) {
var ran atomic.Bool
opts := Options{Shutdown: func(ctx context.Context) {
ran.Store(true)
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
t.Errorf("shutdown ctx has no deadline despite timeoutMillis")
}
}}
host, serveDone := startFakeHost(t, basicHandler(), opts)
host.handshake(t)
resp := host.request(MethodExtensionShutdown, ShutdownParams{TimeoutMillis: 5000})
if resp.Err != nil {
t.Fatalf("shutdown failed: %+v", resp.Err)
}
var result ShutdownResult
if err := json.Unmarshal(resp.Result, &result); err != nil || !result.Accepted {
t.Fatalf("shutdown result = %+v", result)
}
if !ran.Load() {
t.Fatal("shutdown fn did not run")
}
err, ok := serveDone.wait(5 * time.Second)
if !ok {
t.Fatal("Serve did not return after shutdown")
}
if err != nil {
t.Fatalf("Serve returned %v after an orderly shutdown, want nil", err)
}
}
// TestShutdownWithoutFn still answers and exits when no Shutdown fn is set.
func TestShutdownWithoutFn(t *testing.T) {
host, serveDone := startFakeHost(t, basicHandler(), Options{})
host.handshake(t)
resp := host.request(MethodExtensionShutdown, ShutdownParams{TimeoutMillis: 0})
var result ShutdownResult
if err := json.Unmarshal(resp.Result, &result); err != nil || !result.Accepted {
t.Fatalf("shutdown result = %+v err=%v", result, resp.Err)
}
err, ok := serveDone.wait(5 * time.Second)
if !ok {
t.Fatal("Serve did not return")
}
if err != nil {
t.Fatalf("Serve returned %v, want nil", err)
}
}
// TestServeContextCancel verifies canceling the Serve context tears the
// transport down promptly.
func TestServeContextCancel(t *testing.T) {
sdkStdinR, sdkStdinW := io.Pipe()
sdkStdoutR, sdkStdoutW := io.Pipe()
defer sdkStdoutR.Close()
ctx, cancel := context.WithCancel(context.Background())
serveDone := make(chan error, 1)
go func() {
serveDone <- Serve(ctx, basicHandler(), Options{Stdin: sdkStdinR, Stdout: sdkStdoutW})
}()
go io.Copy(io.Discard, sdkStdoutR)
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-serveDone:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Serve returned %v, want context.Canceled", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Serve did not return after ctx cancel")
}
_ = sdkStdinW.Close()
}
// TestHostEOFCleanReturn covers the host closing the transport: Serve
// returns nil.
func TestHostEOFCleanReturn(t *testing.T) {
host, serveDone := startFakeHost(t, basicHandler(), Options{})
host.handshake(t)
if err := host.toSDK.Close(); err != nil {
t.Fatalf("close host pipe: %v", err)
}
err, ok := serveDone.wait(5 * time.Second)
if !ok {
t.Fatal("Serve did not return on host EOF")
}
if err != nil {
t.Fatalf("Serve returned %v on host EOF, want nil", err)
}
}
+199
View File
@@ -0,0 +1,199 @@
package extension
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// This file holds the handwritten half of the SDK's type layer: behavior the
// generator cannot emit (validators, error constructors, enum helpers). The
// wire DTOs, enums, method names, frozen limits, and the frozen error table
// live in types_generated.go, mirrored from the host's
// internal/extension/protocol package by cmd/extension-protocol-gen.
//
// Stability contract (mirrored from the host): within major version 1 only
// optional fields, new enum values, and new methods may be added. Existing
// required fields, method names, directions, limits, error reasons, and
// semantics never change.
// ---------------------------------------------------------------------------
// Enum helpers
// ---------------------------------------------------------------------------
// InterceptEvents returns the 17 frozen hook point names, sorted.
func InterceptEvents() []string {
out := []string{
string(EventSessionStart), string(EventSessionEnd), string(EventSessionLoad),
string(EventSessionSave), string(EventSessionRotate), string(EventInputReceive),
string(EventAgentBeforeStart), string(EventSystemPromptBuild), string(EventContextPrepare),
string(EventProviderRequest), string(EventProviderResponse), string(EventToolBefore),
string(EventToolAfter), string(EventPermissionDecision), string(EventCompactionPrepare),
string(EventCompactionComplete), string(EventFrontendEvent),
}
sortStrings(out)
return out
}
func validInterceptEvent(event InterceptEvent) bool {
switch event {
case EventSessionStart, EventSessionEnd, EventSessionLoad, EventSessionSave,
EventSessionRotate, EventInputReceive, EventAgentBeforeStart,
EventSystemPromptBuild, EventContextPrepare, EventProviderRequest,
EventProviderResponse, EventToolBefore, EventToolAfter,
EventPermissionDecision, EventCompactionPrepare, EventCompactionComplete,
EventFrontendEvent:
return true
}
return false
}
func validInterceptDecision(decision InterceptDecision) bool {
switch decision {
case DecisionContinue, DecisionBlock, DecisionReplace, DecisionAllow, DecisionDeny:
return true
}
return false
}
func validUIFieldKind(kind UIFieldKind) bool {
switch kind {
case UIFieldConfirm, UIFieldInput, UIFieldSelect, UIFieldMultiselect:
return true
}
return false
}
func validUISeverity(severity UISeverity) bool {
switch severity {
case "", UISeverityInfo, UISeverityWarn, UISeverityError:
return true
}
return false
}
// ---------------------------------------------------------------------------
// Wire DTO validators
// ---------------------------------------------------------------------------
// Validate enforces the deterministic wire shape.
func (request ProviderRequest) Validate() error {
if request.Messages == nil || request.Tools == nil {
return validationError("messages and tools must be arrays")
}
if request.MaxTokens < 0 {
return validationError("maxTokens must be non-negative")
}
for _, tool := range request.Tools {
parameters := bytes.TrimSpace(tool.Parameters)
if len(parameters) == 0 || parameters[0] != '{' || !json.Valid(parameters) {
return validationError("tool parameters must be a JSON object")
}
}
return nil
}
// Validate enforces chunk invariants the tags cannot express.
func (chunk ProviderChunk) Validate() error {
if chunk.ArgChars < 0 {
return validationError("argChars must be non-negative")
}
if chunk.Type == ChunkError && chunk.Error == nil {
return validationError("error chunks require error")
}
if chunk.Type != ChunkError && chunk.Error != nil {
return validationError("non-error chunks forbid error")
}
if chunk.Type == ChunkUsage && chunk.Usage == nil {
return validationError("usage chunks require usage")
}
return nil
}
// Validate enforces required identifiers plus the request invariants.
func (p StreamOpenParams) Validate() error {
if strings.TrimSpace(p.StreamID) == "" || strings.TrimSpace(p.ProviderRef) == "" {
return validationError("streamId and providerRef are required")
}
return p.Request.Validate()
}
// Validate enforces stream ordering preconditions and chunk invariants.
func (p StreamChunkParams) Validate() error {
if strings.TrimSpace(p.StreamID) == "" {
return validationError("streamId is required")
}
if p.Seq < 1 {
return validationError("seq must be >= 1")
}
return p.Chunk.Validate()
}
// Validate checks the data against the frozen error table.
func (d ProtocolErrorData) Validate() error {
spec, ok := frozenErrorSpecs[d.Reason]
if !ok {
return fmt.Errorf("unknown extension error reason %q", d.Reason)
}
if d.Retryable != spec.Retryable {
return fmt.Errorf("retryable must match the frozen error table for %q", d.Reason)
}
return nil
}
// ---------------------------------------------------------------------------
// ProtocolError
// ---------------------------------------------------------------------------
// ProtocolError is the extension protocol's structured error. Handlers may
// return one to choose the wire reason; the SDK answers with the frozen
// JSON-RPC code and structured data. Message should stay generic: it crosses
// the wire verbatim.
type ProtocolError struct {
Reason ErrorReason
Message string
}
func (e *ProtocolError) Error() string {
if e == nil {
return ""
}
return e.Message
}
// NewProtocolError builds the frozen error for a reason.
func NewProtocolError(reason ErrorReason) (*ProtocolError, error) {
spec, ok := frozenErrorSpecs[reason]
if !ok {
return nil, fmt.Errorf("extension: unknown error reason %q", reason)
}
return &ProtocolError{Reason: reason, Message: spec.Message}, nil
}
// MustProtocolError is NewProtocolError for reasons known to be frozen.
func MustProtocolError(reason ErrorReason) *ProtocolError {
errValue, err := NewProtocolError(reason)
if err != nil {
panic(err)
}
return errValue
}
// ---------------------------------------------------------------------------
// internal helpers shared with the validator
// ---------------------------------------------------------------------------
type validationFailure struct{ message string }
func (e *validationFailure) Error() string { return e.message }
func validationError(message string) error { return &validationFailure{message: message} }
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
for j := i; j > 0 && values[j] < values[j-1]; j-- {
values[j], values[j-1] = values[j-1], values[j]
}
}
}
+590
View File
@@ -0,0 +1,590 @@
// Code generated by cmd/extension-protocol-gen; DO NOT EDIT.
// Package extension: Extension Protocol v1 wire DTOs, enums, method
// names, frozen limits, and the frozen error table, mirrored from the
// host's internal/extension/protocol package. Behavior (validators,
// error constructors, helpers) lives in the handwritten files.
package extension
import "encoding/json"
// ProtocolID is the immutable identity string peers exchange during the
// initialize handshake.
const ProtocolID = "reasonix.extension.v1"
// ProtocolMajor is the frozen major version of this protocol build.
const ProtocolMajor = 1
// ProtocolVersion is the wire string form of ProtocolMajor carried in the
// initialize handshake.
const ProtocolVersion = "1"
// Frozen wire limits. These constants are part of the protocol contract.
const (
// FrameBytes caps one JSON-RPC frame on the extension transport.
FrameBytes = 8388608
// ExternalizeFieldBytes is the threshold above which an externalizable
// payload must move into a content ref instead of traveling inline.
ExternalizeFieldBytes = 65536
// ContentRefChunkBytes caps one host/content/read chunk.
ContentRefChunkBytes = 262144
// ContentRefObjectBytes caps one externalized object.
ContentRefObjectBytes = 8388608
)
// Method names, frozen for Extension Protocol v1.
const (
MethodExtensionEvent = "extension/event"
MethodExtensionInitialize = "extension/initialize"
MethodExtensionInitialized = "extension/initialized"
MethodExtensionIntercept = "extension/intercept"
MethodExtensionProviderCatalog = "extension/provider/catalog"
MethodExtensionProviderStreamCancel = "extension/provider/stream/cancel"
MethodExtensionProviderStreamChunk = "extension/provider/stream/chunk"
MethodExtensionProviderStreamEnd = "extension/provider/stream/end"
MethodExtensionProviderStreamOpen = "extension/provider/stream/open"
MethodExtensionResourcesChanged = "extension/resources/changed"
MethodExtensionShutdown = "extension/shutdown"
MethodExtensionUIAction = "extension/ui/action"
MethodExtensionUISubmit = "extension/ui/submit"
MethodHostContentRead = "host/content/read"
MethodHostUIPublish = "host/ui/publish"
MethodHostUIRequest = "host/ui/request"
)
// DomainErrorCode is the JSON-RPC code every extension domain error uses on
// the wire. The structured ProtocolErrorData reason distinguishes them.
const DomainErrorCode = -32000
// errorSpec is one frozen error table entry: the JSON-RPC code, the
// generic wire message, and whether the call may be retried.
type errorSpec struct {
Code int
Message string
Retryable bool
}
// frozenErrorSpecs mirrors the host's frozen error table. Adding an entry
// is a conscious protocol change.
var frozenErrorSpecs = map[ErrorReason]errorSpec{
ErrCapabilityNotDeclared: {DomainErrorCode, "The extension used a capability its manifest did not declare.", false},
ErrContentRefExpired: {DomainErrorCode, "The referenced content has expired.", true},
ErrFrameTooLarge: {DomainErrorCode, "The frame exceeds the frozen protocol size limit.", false},
ErrInterceptTimeout: {DomainErrorCode, "The extension did not answer an intercept within its timeout.", true},
ErrInternal: {CodeInternal, "An internal extension protocol error occurred.", true},
ErrInvalidParams: {CodeInvalidParams, "The params do not match the registered method schema.", false},
ErrProtocolError: {CodeInvalidRequest, "The extension protocol frame or envelope is invalid.", false},
ErrProviderFailed: {DomainErrorCode, "The extension provider stream failed.", true},
ErrProviderInterrupted: {DomainErrorCode, "The extension provider stream was interrupted.", true},
ErrShutdownTimeout: {DomainErrorCode, "The extension did not shut down within the requested timeout.", true},
ErrStreamCancelled: {DomainErrorCode, "The provider stream was cancelled.", false},
ErrStreamGap: {DomainErrorCode, "A provider stream chunk is missing from the ordered sequence.", true},
ErrUnknownMethod: {CodeMethodNotFound, "The method is not registered in Extension Protocol v1.", false},
ErrUnsupportedVersion: {DomainErrorCode, "The peer's extension protocol version is not supported.", false},
}
// EventParams is a generated Extension Protocol v1 wire DTO.
type EventParams struct {
Event InterceptEvent `json:"event"`
Payload json.RawMessage `json:"payload" externalizable:"true"`
Externalized []ExternalizedField `json:"externalized,omitempty"`
}
// InterceptEvent is a generated Extension Protocol v1 string enum.
type InterceptEvent string
const (
EventAgentBeforeStart InterceptEvent = "agent.before_start"
EventCompactionComplete InterceptEvent = "compaction.complete"
EventCompactionPrepare InterceptEvent = "compaction.prepare"
EventContextPrepare InterceptEvent = "context.prepare"
EventFrontendEvent InterceptEvent = "frontend.event"
EventInputReceive InterceptEvent = "input.receive"
EventPermissionDecision InterceptEvent = "permission.decision"
EventProviderRequest InterceptEvent = "provider.request"
EventProviderResponse InterceptEvent = "provider.response"
EventSessionEnd InterceptEvent = "session.end"
EventSessionLoad InterceptEvent = "session.load"
EventSessionRotate InterceptEvent = "session.rotate"
EventSessionSave InterceptEvent = "session.save"
EventSessionStart InterceptEvent = "session.start"
EventSystemPromptBuild InterceptEvent = "system_prompt.build"
EventToolAfter InterceptEvent = "tool.after"
EventToolBefore InterceptEvent = "tool.before"
)
// ExternalizedField is a generated Extension Protocol v1 wire DTO.
type ExternalizedField struct {
JSONPointer string `json:"jsonPointer" validate:"nonempty"`
ContentRef string `json:"contentRef" validate:"nonempty"`
TotalBytes int64 `json:"totalBytes" validate:"min=0"`
SHA256 string `json:"sha256" validate:"sha256"`
}
// InitializeParams is a generated Extension Protocol v1 wire DTO.
type InitializeParams struct {
ProtocolVersion string `json:"protocolVersion" validate:"nonempty"`
ProtocolID string `json:"protocolId" validate:"nonempty"`
Manifest ManifestExpectation `json:"manifest"`
Session SessionContext `json:"session"`
Capabilities HostCapabilities `json:"capabilities"`
}
// ManifestExpectation is a generated Extension Protocol v1 wire DTO.
type ManifestExpectation struct {
Intercepts []string `json:"intercepts,omitempty"`
Replaces []string `json:"replaces,omitempty"`
Providers []string `json:"providers,omitempty"`
UIActions []string `json:"uiActions,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// SessionContext is a generated Extension Protocol v1 wire DTO.
type SessionContext struct {
SessionID string `json:"sessionId" validate:"nonempty"`
WorkspaceRoot string `json:"workspaceRoot" validate:"nonempty"`
Generation uint64 `json:"generation"`
}
// HostCapabilities is a generated Extension Protocol v1 wire DTO.
type HostCapabilities struct {
ContentRefs bool `json:"contentRefs"`
UIHost UIHostKind `json:"uiHost"`
ProtocolVersion string `json:"protocolVersion" validate:"nonempty"`
}
// UIHostKind is a generated Extension Protocol v1 string enum.
type UIHostKind string
const (
UIHostTUI UIHostKind = "tui"
UIHostDesktop UIHostKind = "desktop"
UIHostACP UIHostKind = "acp"
UIHostHeadless UIHostKind = "headless"
)
// InitializeResult is a generated Extension Protocol v1 wire DTO.
type InitializeResult struct {
ProtocolVersion string `json:"protocolVersion" validate:"nonempty"`
Name string `json:"name" validate:"nonempty"`
Version string `json:"version" validate:"nonempty"`
Subscriptions []string `json:"subscriptions,omitempty"`
Replaces []string `json:"replaces,omitempty"`
Providers []ProviderDescriptor `json:"providers,omitempty"`
UIActions []UIActionDecl `json:"uiActions,omitempty"`
StateSchemaVersion int `json:"stateSchemaVersion" validate:"min=0"`
}
// ProviderDescriptor is a generated Extension Protocol v1 wire DTO.
type ProviderDescriptor struct {
Ref string `json:"ref" validate:"nonempty"`
DisplayName string `json:"displayName,omitempty"`
Model string `json:"model,omitempty"`
ContextWindow int `json:"contextWindow,omitempty" validate:"min=0"`
PricingCurrency string `json:"pricingCurrency,omitempty"`
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty" validate:"min=0"`
InputPerMillion float64 `json:"inputPerMillion,omitempty" validate:"min=0"`
OutputPerMillion float64 `json:"outputPerMillion,omitempty" validate:"min=0"`
Vision bool `json:"vision,omitempty"`
Tools bool `json:"tools,omitempty"`
Reasoning bool `json:"reasoning,omitempty"`
Efforts []string `json:"efforts,omitempty"`
DefaultEffort string `json:"defaultEffort,omitempty"`
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
WarnOnMissingToolCallReasoning bool `json:"warnOnMissingToolCallReasoning,omitempty"`
}
// UIActionDecl is a generated Extension Protocol v1 wire DTO.
type UIActionDecl struct {
ActionID string `json:"actionId" validate:"nonempty"`
Label string `json:"label,omitempty"`
}
// InitializedParams is a generated Extension Protocol v1 wire DTO.
type InitializedParams struct{}
// InterceptParams is a generated Extension Protocol v1 wire DTO.
type InterceptParams struct {
Event InterceptEvent `json:"event"`
Seq uint64 `json:"seq" validate:"min=1"`
Payload json.RawMessage `json:"payload" externalizable:"true"`
TimeoutMillis int `json:"timeoutMillis" validate:"min=0"`
Externalized []ExternalizedField `json:"externalized,omitempty"`
}
// InterceptResult is a generated Extension Protocol v1 wire DTO.
type InterceptResult struct {
Decision InterceptDecision `json:"decision"`
Reason string `json:"reason,omitempty"`
Replacement json.RawMessage `json:"replacement,omitempty" externalizable:"true"`
Externalized []ExternalizedField `json:"externalized,omitempty"`
}
// InterceptDecision is a generated Extension Protocol v1 string enum.
type InterceptDecision string
const (
DecisionContinue InterceptDecision = "continue"
DecisionBlock InterceptDecision = "block"
DecisionReplace InterceptDecision = "replace"
DecisionAllow InterceptDecision = "allow"
DecisionDeny InterceptDecision = "deny"
)
// ProviderCatalogParams is a generated Extension Protocol v1 wire DTO.
type ProviderCatalogParams struct{}
// ProviderCatalogResult is a generated Extension Protocol v1 wire DTO.
type ProviderCatalogResult struct {
Providers []ProviderDescriptor `json:"providers"`
}
// StreamCancelParams is a generated Extension Protocol v1 wire DTO.
type StreamCancelParams struct {
StreamID string `json:"streamId" validate:"nonempty"`
}
// StreamCancelResult is a generated Extension Protocol v1 wire DTO.
type StreamCancelResult struct {
Cancelled bool `json:"cancelled"`
}
// StreamChunkParams is a generated Extension Protocol v1 wire DTO.
type StreamChunkParams struct {
StreamID string `json:"streamId" validate:"nonempty"`
Seq int64 `json:"seq" validate:"min=1"`
Chunk ProviderChunk `json:"chunk"`
}
// ProviderChunk is a generated Extension Protocol v1 wire DTO.
type ProviderChunk struct {
Type ProviderChunkType `json:"type"`
Text string `json:"text,omitempty"`
Signature string `json:"signature,omitempty"`
ToolCall *ProviderToolCall `json:"toolCall,omitempty"`
ArgChars int `json:"argChars,omitempty" validate:"min=0"`
Usage *ProviderUsage `json:"usage,omitempty"`
Error *ProviderError `json:"error,omitempty"`
}
// ProviderChunkType is a generated Extension Protocol v1 string enum.
type ProviderChunkType string
const (
ChunkText ProviderChunkType = "text"
ChunkReasoning ProviderChunkType = "reasoning"
ChunkToolCallStart ProviderChunkType = "tool_call_start"
ChunkToolCallDelta ProviderChunkType = "tool_call_args_delta"
ChunkToolCall ProviderChunkType = "tool_call"
ChunkUsage ProviderChunkType = "usage"
ChunkDone ProviderChunkType = "done"
ChunkError ProviderChunkType = "error"
)
// ProviderToolCall is a generated Extension Protocol v1 wire DTO.
type ProviderToolCall struct {
ID string `json:"id" validate:"nonempty"`
Name string `json:"name" validate:"nonempty"`
Arguments string `json:"arguments"`
ThoughtSignature string `json:"thought_signature,omitempty"`
}
// ProviderUsage is a generated Extension Protocol v1 wire DTO.
type ProviderUsage struct {
PromptTokens int `json:"promptTokens" validate:"min=0"`
CompletionTokens int `json:"completionTokens" validate:"min=0"`
TotalTokens int `json:"totalTokens" validate:"min=0"`
CacheHitTokens int `json:"cacheHitTokens" validate:"min=0"`
CacheMissTokens int `json:"cacheMissTokens" validate:"min=0"`
ReasoningTokens int `json:"reasoningTokens" validate:"min=0"`
FinishReason string `json:"finishReason,omitempty"`
}
// ProviderError is a generated Extension Protocol v1 wire DTO.
type ProviderError struct {
Code ProviderErrorCode `json:"code"`
Message string `json:"message" validate:"nonempty"`
}
// ProviderErrorCode is a generated Extension Protocol v1 string enum.
type ProviderErrorCode string
const (
ProviderFailed ProviderErrorCode = "provider_failed"
ProviderInterrupted ProviderErrorCode = "provider_interrupted"
)
// StreamEndParams is a generated Extension Protocol v1 wire DTO.
type StreamEndParams struct {
StreamID string `json:"streamId" validate:"nonempty"`
LastSeq int64 `json:"lastSeq" validate:"min=0"`
Error string `json:"error,omitempty"`
Interrupted bool `json:"interrupted,omitempty"`
}
// StreamOpenParams is a generated Extension Protocol v1 wire DTO.
type StreamOpenParams struct {
StreamID string `json:"streamId" validate:"nonempty"`
ProviderRef string `json:"providerRef" validate:"nonempty"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
Request ProviderRequest `json:"request"`
SeqBase int `json:"seqBase" validate:"min=0"`
}
// ProviderRequest is a generated Extension Protocol v1 wire DTO.
type ProviderRequest struct {
Messages []ProviderMessage `json:"messages"`
Tools []ProviderToolSchema `json:"tools"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"maxTokens" validate:"min=0"`
}
// ProviderMessage is a generated Extension Protocol v1 wire DTO.
type ProviderMessage struct {
Role ProviderRole `json:"role,omitempty"`
Content string `json:"content,omitempty" externalizable:"true"`
Images []string `json:"images,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ReasoningSignature string `json:"reasoning_signature,omitempty"`
ToolCalls []ProviderToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
// ProviderRole is a generated Extension Protocol v1 string enum.
type ProviderRole string
const (
ProviderRoleSystem ProviderRole = "system"
ProviderRoleUser ProviderRole = "user"
ProviderRoleAssistant ProviderRole = "assistant"
ProviderRoleTool ProviderRole = "tool"
)
// ProviderToolSchema is a generated Extension Protocol v1 wire DTO.
type ProviderToolSchema struct {
Name string `json:"name" validate:"nonempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters"`
}
// StreamOpenResult is a generated Extension Protocol v1 wire DTO.
type StreamOpenResult struct {
Accepted bool `json:"accepted"`
}
// ResourcesChangedParams is a generated Extension Protocol v1 wire DTO.
type ResourcesChangedParams struct {
Paths []string `json:"paths"`
}
// ShutdownParams is a generated Extension Protocol v1 wire DTO.
type ShutdownParams struct {
TimeoutMillis int `json:"timeoutMillis" validate:"min=0"`
}
// ShutdownResult is a generated Extension Protocol v1 wire DTO.
type ShutdownResult struct {
Accepted bool `json:"accepted"`
}
// UIActionParams is a generated Extension Protocol v1 wire DTO.
type UIActionParams struct {
ActionID string `json:"actionId" validate:"nonempty"`
SessionID string `json:"sessionId" validate:"nonempty"`
Generation uint64 `json:"generation"`
Args map[string]string `json:"args,omitempty"`
}
// UIActionResult is a generated Extension Protocol v1 wire DTO.
type UIActionResult struct {
Accepted bool `json:"accepted"`
Message string `json:"message,omitempty"`
}
// UISubmitParams is a generated Extension Protocol v1 wire DTO.
type UISubmitParams struct {
SurfaceID string `json:"surfaceId" validate:"nonempty"`
SessionID string `json:"sessionId" validate:"nonempty"`
Generation uint64 `json:"generation"`
Values map[string]any `json:"values"`
}
// UISubmitResult is a generated Extension Protocol v1 wire DTO.
type UISubmitResult struct {
Accepted bool `json:"accepted"`
}
// ContentReadParams is a generated Extension Protocol v1 wire DTO.
type ContentReadParams struct {
ContentRef string `json:"contentRef" validate:"nonempty"`
Offset int64 `json:"offset" validate:"min=0"`
}
// ContentReadResult is a generated Extension Protocol v1 wire DTO.
type ContentReadResult struct {
ContentRef string `json:"contentRef" validate:"nonempty"`
Offset int64 `json:"offset" validate:"min=0"`
DataBase64 string `json:"dataBase64"`
NextOffset *int64 `json:"nextOffset,omitempty" validate:"min=0"`
TotalBytes int64 `json:"totalBytes" validate:"min=0"`
SHA256 string `json:"sha256" validate:"sha256"`
Encoding ContentEncoding `json:"encoding"`
}
// ContentEncoding is a generated Extension Protocol v1 string enum.
type ContentEncoding string
const (
ContentUTF8 ContentEncoding = "utf8"
)
// UIPublishParams is a generated Extension Protocol v1 wire DTO.
type UIPublishParams struct {
SurfaceID string `json:"surfaceId" validate:"nonempty"`
SessionID string `json:"sessionId" validate:"nonempty"`
Generation uint64 `json:"generation"`
Kind UISurfaceKind `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
// UISurfaceKind is a generated Extension Protocol v1 string enum.
type UISurfaceKind string
const (
UISurfaceStatus UISurfaceKind = "status"
UISurfaceCard UISurfaceKind = "card"
UISurfaceForm UISurfaceKind = "form"
UISurfaceNotification UISurfaceKind = "notification"
)
// UIPublishResult is a generated Extension Protocol v1 wire DTO.
type UIPublishResult struct {
Accepted bool `json:"accepted"`
}
// UIRequestParams is a generated Extension Protocol v1 wire DTO.
type UIRequestParams struct {
SurfaceID string `json:"surfaceId" validate:"nonempty"`
SessionID string `json:"sessionId" validate:"nonempty"`
Generation uint64 `json:"generation"`
Kind UIRequestKind `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
// UIRequestKind is a generated Extension Protocol v1 string enum.
type UIRequestKind string
const (
UIRequestConfirm UIRequestKind = "confirm"
UIRequestInput UIRequestKind = "input"
UIRequestSelect UIRequestKind = "select"
UIRequestMultiselect UIRequestKind = "multiselect"
)
// UIRequestResult is a generated Extension Protocol v1 wire DTO.
type UIRequestResult struct {
Cancelled bool `json:"cancelled"`
Values map[string]any `json:"values,omitempty"`
}
// UIStatusPayload is a generated Extension Protocol v1 wire DTO.
type UIStatusPayload struct {
Label string `json:"label" validate:"nonempty"`
Detail string `json:"detail,omitempty"`
Severity UISeverity `json:"severity,omitempty"`
Progress *float64 `json:"progress,omitempty"`
}
// UISeverity is a generated Extension Protocol v1 string enum.
type UISeverity string
const (
UISeverityInfo UISeverity = "info"
UISeverityWarn UISeverity = "warn"
UISeverityError UISeverity = "error"
)
// UICardPayload is a generated Extension Protocol v1 wire DTO.
type UICardPayload struct {
Title string `json:"title,omitempty"`
Markdown string `json:"markdown,omitempty"`
Text string `json:"text,omitempty"`
Fields []UIKeyValue `json:"fields,omitempty"`
Progress *float64 `json:"progress,omitempty"`
Actions []UIActionRef `json:"actions,omitempty"`
}
// UIKeyValue is a generated Extension Protocol v1 wire DTO.
type UIKeyValue struct {
Key string `json:"key" validate:"nonempty"`
Value string `json:"value"`
}
// UIActionRef is a generated Extension Protocol v1 wire DTO.
type UIActionRef struct {
ActionID string `json:"actionId" validate:"nonempty"`
Label string `json:"label" validate:"nonempty"`
}
// UIFormPayload is a generated Extension Protocol v1 wire DTO.
type UIFormPayload struct {
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Fields []UIFormField `json:"fields"`
}
// UIFormField is a generated Extension Protocol v1 wire DTO.
type UIFormField struct {
Key string `json:"key" validate:"nonempty"`
Label string `json:"label,omitempty"`
Kind UIFieldKind `json:"kind"`
Options []string `json:"options,omitempty"`
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
}
// UIFieldKind is a generated Extension Protocol v1 string enum.
type UIFieldKind string
const (
UIFieldConfirm UIFieldKind = "confirm"
UIFieldInput UIFieldKind = "input"
UIFieldSelect UIFieldKind = "select"
UIFieldMultiselect UIFieldKind = "multiselect"
)
// UINotificationPayload is a generated Extension Protocol v1 wire DTO.
type UINotificationPayload struct {
Title string `json:"title" validate:"nonempty"`
Body string `json:"body,omitempty"`
Severity UISeverity `json:"severity,omitempty"`
}
// ProtocolErrorData is a generated Extension Protocol v1 wire DTO.
type ProtocolErrorData struct {
Reason ErrorReason `json:"reason"`
Retryable bool `json:"retryable"`
}
// ErrorReason is a generated Extension Protocol v1 string enum.
type ErrorReason string
const (
ErrCapabilityNotDeclared ErrorReason = "capability_not_declared"
ErrContentRefExpired ErrorReason = "content_ref_expired"
ErrFrameTooLarge ErrorReason = "frame_too_large"
ErrInterceptTimeout ErrorReason = "intercept_timeout"
ErrInternal ErrorReason = "internal"
ErrInvalidParams ErrorReason = "invalid_params"
ErrProtocolError ErrorReason = "protocol_error"
ErrProviderFailed ErrorReason = "provider_failed"
ErrProviderInterrupted ErrorReason = "provider_interrupted"
ErrShutdownTimeout ErrorReason = "shutdown_timeout"
ErrStreamCancelled ErrorReason = "stream_cancelled"
ErrStreamGap ErrorReason = "stream_gap"
ErrUnknownMethod ErrorReason = "unknown_method"
ErrUnsupportedVersion ErrorReason = "unsupported_version"
)
+250
View File
@@ -0,0 +1,250 @@
package extension
import (
"context"
"encoding/json"
"errors"
"testing"
)
// uiCall runs fn inside an interceptor ctx (which carries the host
// connection) against a fake host, returning the fake host for frame
// assertions.
func uiCall(t *testing.T, fn func(ctx context.Context) error) (*fakeHost, error) {
t.Helper()
var callErr error
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
callErr = fn(ctx)
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostUIPublish, func(json.RawMessage) (any, *hostError) {
return UIPublishResult{Accepted: true}, nil
})
host.onRequest(MethodHostUIRequest, func(json.RawMessage) (any, *hostError) {
return UIRequestResult{Cancelled: false, Values: map[string]any{"value": true}}, nil
})
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
return host, callErr
}
// lastRawParams decodes the most recent host request params of one method.
func lastRawParams(t *testing.T, host *fakeHost, method string) json.RawMessage {
t.Helper()
return host.lastRawParams(t, method)
}
// TestHostUIPublishStatusGolden pins the exact wire field names of a status
// publish against the canonical schema.
func TestHostUIPublishStatusGolden(t *testing.T) {
progress := 0.5
host, err := uiCall(t, func(ctx context.Context) error {
ui := HostUI{}
return ui.PublishStatus(ctx, "sess-1", 7, "status-1", UIStatusPayload{
Label: "Indexing", Detail: "3/6", Severity: UISeverityWarn, Progress: &progress,
})
})
if err != nil {
t.Fatalf("PublishStatus: %v", err)
}
raw := lastRawParams(t, host, MethodHostUIPublish)
var golden map[string]any
if err := json.Unmarshal(raw, &golden); err != nil {
t.Fatalf("params not an object: %v", err)
}
assertJSONFields(t, golden, map[string]any{
"surfaceId": "status-1",
"sessionId": "sess-1",
"generation": float64(7),
"kind": "status",
})
payload, ok := golden["payload"].(map[string]any)
if !ok {
t.Fatalf("payload = %v", golden["payload"])
}
assertJSONFields(t, payload, map[string]any{
"label": "Indexing", "detail": "3/6", "severity": "warn", "progress": 0.5,
})
}
// TestHostUIPublishFormGolden pins the form surface shape.
func TestHostUIPublishFormGolden(t *testing.T) {
host, err := uiCall(t, func(ctx context.Context) error {
ui := HostUI{}
return ui.PublishForm(ctx, "sess-1", 7, "form-1", UIFormPayload{
Title: "Configure",
Message: "Pick values",
Fields: []UIFormField{
{Key: "name", Label: "Name", Kind: UIFieldInput, Default: "reasonix", Required: true},
{Key: "level", Label: "Level", Kind: UIFieldSelect, Options: []string{"low", "high"}},
},
})
})
if err != nil {
t.Fatalf("PublishForm: %v", err)
}
raw := lastRawParams(t, host, MethodHostUIPublish)
var doc struct {
Kind string `json:"kind"`
Payload struct {
Title string `json:"title"`
Message string `json:"message"`
Fields []struct {
Key string `json:"key"`
Label string `json:"label"`
Kind string `json:"kind"`
Options []string `json:"options,omitempty"`
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
} `json:"fields"`
} `json:"payload"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if doc.Kind != "form" || doc.Payload.Title != "Configure" || len(doc.Payload.Fields) != 2 {
t.Fatalf("form doc = %+v", doc)
}
name := doc.Payload.Fields[0]
if name.Key != "name" || name.Kind != "input" || name.Default != "reasonix" || !name.Required {
t.Fatalf("field 0 = %+v", name)
}
level := doc.Payload.Fields[1]
if level.Kind != "select" || len(level.Options) != 2 || level.Options[1] != "high" {
t.Fatalf("field 1 = %+v", level)
}
}
// TestHostUIRequestConfirmGolden pins the confirm prompt shape and answer
// mapping.
func TestHostUIRequestConfirmGolden(t *testing.T) {
var answer bool
host, err := uiCall(t, func(ctx context.Context) error {
ui := HostUI{}
var callErr error
answer, callErr = ui.RequestConfirm(ctx, "sess-1", 7, "confirm-1", "Delete everything?")
return callErr
})
if err != nil {
t.Fatalf("RequestConfirm: %v", err)
}
if !answer {
t.Fatal("confirm answer = false, want true from the scripted host")
}
raw := lastRawParams(t, host, MethodHostUIRequest)
var doc struct {
SurfaceID string `json:"surfaceId"`
SessionID string `json:"sessionId"`
Generation uint64 `json:"generation"`
Kind string `json:"kind"`
Payload struct {
Message string `json:"message"`
Fields []struct {
Key string `json:"key"`
Label string `json:"label"`
Kind string `json:"kind"`
} `json:"fields"`
} `json:"payload"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if doc.Kind != "confirm" || doc.SurfaceID != "confirm-1" || doc.Generation != 7 {
t.Fatalf("request doc = %+v", doc)
}
if len(doc.Payload.Fields) != 1 || doc.Payload.Fields[0].Key != "value" || doc.Payload.Fields[0].Kind != "confirm" {
t.Fatalf("confirm fields = %+v", doc.Payload.Fields)
}
}
// TestHostUIRequestCancelled maps dismissal to ErrUICancelled.
func TestHostUIRequestCancelled(t *testing.T) {
var callErr error
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
ui := HostUI{}
_, callErr = ui.RequestConfirm(ctx, "sess-1", 7, "c", "sure?")
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostUIRequest, func(json.RawMessage) (any, *hostError) {
return UIRequestResult{Cancelled: true}, nil
})
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if !errors.Is(callErr, ErrUICancelled) {
t.Fatalf("callErr = %v, want ErrUICancelled", callErr)
}
}
// TestHostUIRequestMultiSelect decodes a multi-answer from the wire's []any.
func TestHostUIRequestMultiSelect(t *testing.T) {
var picked []string
var callErr error
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
ui := HostUI{}
picked, callErr = ui.RequestMultiSelect(ctx, "sess-1", 7, "ms", MultiSelectPrompt{
Message: "Pick", Options: []string{"a", "b", "c"},
})
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.onRequest(MethodHostUIRequest, func(json.RawMessage) (any, *hostError) {
return UIRequestResult{Cancelled: false, Values: map[string]any{"value": []any{"a", "c"}}}, nil
})
host.handshake(t)
host.request(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
})
if callErr != nil {
t.Fatalf("RequestMultiSelect: %v", callErr)
}
if len(picked) != 2 || picked[0] != "a" || picked[1] != "c" {
t.Fatalf("picked = %v", picked)
}
}
// TestHostUIValidation rejects invalid payloads before they hit the wire.
func TestHostUIValidation(t *testing.T) {
ui := HostUI{}
ctx := context.Background()
cases := []error{
ui.PublishStatus(ctx, "s", 1, "x", UIStatusPayload{}),
ui.PublishStatus(ctx, "s", 1, "x", UIStatusPayload{Label: "l", Severity: "fatal"}),
ui.PublishNotification(ctx, "s", 1, "x", UINotificationPayload{}),
ui.PublishForm(ctx, "s", 1, "x", UIFormPayload{}),
ui.PublishForm(ctx, "s", 1, "x", UIFormPayload{Fields: []UIFormField{{Key: "k", Kind: "textarea"}}}),
ui.PublishCard(ctx, "s", 1, "x", UICardPayload{Fields: []UIKeyValue{{Value: "v"}}}),
}
for i, err := range cases {
if err == nil {
t.Fatalf("case %d: expected a validation error", i)
}
if errors.Is(err, ErrNoConnection) {
t.Fatalf("case %d: validation did not run before the connection check", i)
}
}
if _, err := ui.RequestSelect(ctx, "s", 1, "x", SelectPrompt{}); err == nil {
t.Fatal("select without options: expected a validation error")
}
}
// assertJSONFields checks want's key/value pairs against got.
func assertJSONFields(t *testing.T, got map[string]any, want map[string]any) {
t.Helper()
for key, value := range want {
if got[key] != value {
t.Fatalf("field %q = %v, want %v (doc %v)", key, got[key], value, got)
}
}
}
+734
View File
@@ -0,0 +1,734 @@
package extension
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"strconv"
"sync"
"sync/atomic"
)
// Standard JSON-RPC 2.0 error codes, plus the extension domain code and the
// transport-local overload code.
const (
CodeParseError = -32700
CodeInvalidRequest = -32600
CodeMethodNotFound = -32601
CodeInvalidParams = -32602
CodeInternal = -32603
// CodeServerBusy is a stable transport-local overload response. It is
// outside the JSON-RPC reserved range and intentionally carries no peer
// data.
CodeServerBusy = -32099
)
// Transport bounds.
const (
// maxConcurrentHandlers bounds inbound request and notification handlers.
maxConcurrentHandlers = 32
// maxQueuedNotifications bounds the outbound notification queue. A full
// queue fails the connection rather than silently dropping a provider
// stream chunk (a dropped chunk would surface as a stream_gap host-side);
// this mirrors the host side's policy.
maxQueuedNotifications = 256
)
// ResponseError is returned by outbound calls when the peer answers with a
// JSON-RPC error. Data remains raw so callers can decode ProtocolErrorData.
type ResponseError struct {
Code int
Message string
Data json.RawMessage
}
func (e *ResponseError) Error() string {
if e == nil {
return ""
}
return e.Message
}
// FrameTooLargeError reports a frame that violates the frozen NDJSON budget.
// Size and Limit include the trailing newline, matching the bytes sent over
// the transport.
type FrameTooLargeError struct {
Direction string
Size int
Limit int
}
func (e *FrameTooLargeError) Error() string {
return fmt.Sprintf("extension: %s frame is %d bytes; limit is %d", e.Direction, e.Size, e.Limit)
}
// rpcErrorObject is the JSON-RPC error object carried on the wire.
type rpcErrorObject struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// requestHandler answers an inbound JSON-RPC request.
type requestHandler func(ctx context.Context, params json.RawMessage) (any, error)
// notificationHandler handles an inbound JSON-RPC notification.
type notificationHandler func(ctx context.Context, params json.RawMessage)
// deferredResult lets a request handler run cleanup only after a successful
// response write (for example, starting a provider stream pump once the
// stream/open acknowledgment is on the wire).
type deferredResult struct {
result any
after func()
}
type rpcResult struct {
result json.RawMessage
err error
}
type outbound struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcErrorObject `json:"error,omitempty"`
}
type inbound struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *rpcErrorObject `json:"error"`
}
// conn is one bidirectional strict JSON-RPC 2.0 connection framed as NDJSON.
// The extension dialect narrows generic JSON-RPC: ids are integers only and
// params must be JSON objects.
type conn struct {
r io.Reader
w io.Writer
log *log.Logger
wmu sync.Mutex
nextID atomic.Int64
pmu sync.Mutex
pending map[int64]chan rpcResult
reqH map[string]requestHandler
notH map[string]notificationHandler
// beforeRequest and beforeNotification run synchronously on the read loop
// after strict frame validation and before dispatch, letting the
// handshake barrier observe wire arrival order.
beforeRequest func(method string) error
beforeNotification func(method string) error
wg sync.WaitGroup
closeOnce sync.Once
closed chan struct{}
closeMu sync.Mutex
closeErr error
handlerSlots chan struct{}
notifyQueue chan []byte
}
func newConn(r io.Reader, w io.Writer, logger *log.Logger) *conn {
return &conn{
r: r,
w: w,
log: logger,
pending: make(map[int64]chan rpcResult),
reqH: make(map[string]requestHandler),
notH: make(map[string]notificationHandler),
closed: make(chan struct{}),
handlerSlots: make(chan struct{}, maxConcurrentHandlers),
notifyQueue: make(chan []byte, maxQueuedNotifications),
}
}
// serve reads and dispatches frames until EOF, cancellation, or a
// framing/read error. In-flight handler contexts are cancelled when the
// transport ends.
func (c *conn) serve(ctx context.Context) error {
serveCtx, cancel := context.WithCancel(ctx)
defer cancel()
c.wg.Add(1)
go c.serveOutboundNotifications()
// Unblock a read parked on ctx cancellation: closing the reader is the
// only reliable way to interrupt it.
if closer, ok := c.r.(io.Closer); ok {
c.wg.Add(1)
go func() {
defer c.wg.Done()
select {
case <-serveCtx.Done():
_ = closer.Close()
case <-c.closed:
}
}()
}
br := bufio.NewReaderSize(c.r, 64<<10)
var loopErr error
for {
line, err := readLine(br, FrameBytes)
if len(line) > 0 {
c.dispatch(serveCtx, line)
}
if err != nil {
if !errors.Is(err, io.EOF) {
loopErr = err
}
break
}
if err := serveCtx.Err(); err != nil {
loopErr = err
break
}
}
cancel()
close(c.notifyQueue)
c.wg.Wait()
// A connection that was failed or shut down deliberately makes the
// resulting read error a consequence, not the cause: report the recorded
// terminal error (nil for an orderly shutdown). Otherwise a parent ctx
// cancellation explains the forced reader close.
select {
case <-c.closed:
loopErr = c.recordedCloseError()
default:
if err := ctx.Err(); err != nil {
loopErr = err
}
}
c.shutdown(loopErr)
return loopErr
}
// serveOutboundNotifications is the single ordered writer for fire-and-forget
// notifications (provider stream chunks). The queue is bounded; a full queue
// fails the connection instead of dropping a frame.
func (c *conn) serveOutboundNotifications() {
defer c.wg.Done()
for frame := range c.notifyQueue {
if err := c.writeFrame(frame); err != nil {
c.fail(err)
return
}
}
}
func (c *conn) dispatch(ctx context.Context, line []byte) {
var in inbound
if err := json.Unmarshal(line, &in); err != nil {
if json.Valid(line) {
c.respondError(json.RawMessage("null"), CodeInvalidRequest, "invalid request", nil)
} else {
c.respondError(json.RawMessage("null"), CodeParseError, "parse error", nil)
}
return
}
if err := validateStrictFrame(line, &in); err != nil {
if c.log != nil {
c.log.Printf("extension: rejecting frame: %v", err)
}
c.respondError(responseIDForError(in.ID), CodeInvalidRequest, "invalid request", nil)
return
}
select {
case <-c.closed:
return
default:
}
hasID := len(in.ID) > 0
switch {
case in.Method != "" && hasID:
if c.beforeRequest != nil {
if err := c.beforeRequest(in.Method); err != nil {
c.respondHandlerError(in.ID, err)
return
}
}
if !c.tryStartHandler() {
c.respondError(in.ID, CodeServerBusy, "server busy", nil)
return
}
c.wg.Add(1)
go func() {
defer c.finishHandler()
defer c.wg.Done()
c.serveRequest(ctx, in.ID, in.Method, in.Params)
}()
case in.Method != "" && !hasID:
if c.beforeNotification != nil {
if err := c.beforeNotification(in.Method); err != nil {
return
}
}
h := c.notH[in.Method]
if h == nil {
if c.log != nil {
c.log.Printf("extension: dropping notification for unhandled method %q", in.Method)
}
return
}
// No response is possible for a notification, so a saturated handler
// pool drops with a diagnostic rather than failing the connection.
if !c.tryStartHandler() {
if c.log != nil {
c.log.Printf("extension: dropping %q notification: handler pool saturated", in.Method)
}
return
}
c.wg.Add(1)
go func() {
defer c.finishHandler()
defer c.wg.Done()
c.runNotification(ctx, h, in.Params)
}()
case in.Method == "" && hasID:
c.resolve(&in)
default:
c.respondError(json.RawMessage("null"), CodeInvalidRequest, "invalid request", nil)
}
}
func (c *conn) tryStartHandler() bool {
select {
case c.handlerSlots <- struct{}{}:
return true
default:
return false
}
}
func (c *conn) finishHandler() { <-c.handlerSlots }
// validateStrictFrame enforces the extension dialect of JSON-RPC 2.0:
// jsonrpc=="2.0", request/response shapes are mutually exclusive, ids are
// integers (or null), and params, when present, is a JSON object.
func validateStrictFrame(line []byte, in *inbound) error {
var members map[string]json.RawMessage
if err := json.Unmarshal(line, &members); err != nil {
return err
}
if in.JSONRPC != "2.0" {
return errors.New("jsonrpc must be 2.0")
}
_, hasID := members["id"]
_, hasMethod := members["method"]
_, hasParams := members["params"]
_, hasResult := members["result"]
_, hasError := members["error"]
if hasID && !validRPCID(in.ID) {
return errors.New("id must be an integer or null")
}
if hasMethod {
if in.Method == "" || hasResult || hasError {
return errors.New("invalid request shape")
}
if hasParams {
trimmed := bytes.TrimSpace(in.Params)
if len(trimmed) == 0 || trimmed[0] != '{' {
return errors.New("params must be a JSON object")
}
}
return nil
}
if !hasID || hasParams || hasResult == hasError {
return errors.New("invalid response shape")
}
if hasError {
if in.Error == nil {
return errors.New("invalid error object")
}
var errorMembers map[string]json.RawMessage
if err := json.Unmarshal(members["error"], &errorMembers); err != nil {
return errors.New("invalid error object")
}
if _, ok := errorMembers["code"]; !ok {
return errors.New("error code is required")
}
if _, ok := errorMembers["message"]; !ok {
return errors.New("error message is required")
}
}
return nil
}
// validRPCID reports whether raw is an integer or null id. Unlike generic
// JSON-RPC, the extension protocol does not use string ids.
func validRPCID(raw json.RawMessage) bool {
raw = bytes.TrimSpace(raw)
if bytes.Equal(raw, []byte("null")) {
return true
}
if len(raw) == 0 {
return false
}
i := 0
if raw[0] == '-' {
i++
if i == len(raw) {
return false
}
}
if raw[i] == '0' && i+1 != len(raw) {
return false
}
for ; i < len(raw); i++ {
if raw[i] < '0' || raw[i] > '9' {
return false
}
}
return true
}
// responseIDForError extracts the id member for an error response to a
// rejected frame, falling back to null when the id is absent or invalid.
func responseIDForError(raw json.RawMessage) json.RawMessage {
if len(bytes.TrimSpace(raw)) == 0 || !validRPCID(raw) {
return json.RawMessage("null")
}
return raw
}
func (c *conn) serveRequest(ctx context.Context, id json.RawMessage, method string, params json.RawMessage) {
h := c.reqH[method]
if h == nil {
notFound := MustProtocolError(ErrUnknownMethod)
spec := frozenErrorSpecs[ErrUnknownMethod]
c.respondError(id, spec.Code, "method not found: "+method, ProtocolErrorData{Reason: notFound.Reason, Retryable: spec.Retryable})
return
}
result, err := c.runHandler(ctx, h, params)
if err != nil {
c.respondHandlerError(id, err)
return
}
var after func()
if deferred, ok := result.(deferredResult); ok {
result = deferred.result
after = deferred.after
}
raw, err := json.Marshal(result)
if err != nil {
c.respondError(id, CodeInternal, "marshal result: "+err.Error(), nil)
return
}
writeErr := c.write(outbound{JSONRPC: "2.0", ID: id, Result: raw})
if writeErr != nil {
var tooLarge *FrameTooLargeError
if errors.As(writeErr, &tooLarge) {
c.respondError(id, CodeInternal, "response exceeds frame size limit", nil)
return
}
c.fail(writeErr)
return
}
if after != nil {
c.runAfterWrite(after)
}
}
// runNotification executes one notification handler, converting a panic into
// a diagnostic so the read loop and the connection survive.
func (c *conn) runNotification(ctx context.Context, h notificationHandler, params json.RawMessage) {
defer func() {
if recovered := recover(); recovered != nil && c.log != nil {
c.log.Printf("extension: notification handler panic: %v", recovered)
}
}()
h(ctx, params)
}
// runHandler executes one request handler, converting a panic into the frozen
// internal error so the read loop and the connection survive.
func (c *conn) runHandler(ctx context.Context, h requestHandler, params json.RawMessage) (result any, err error) {
defer func() {
if recovered := recover(); recovered != nil {
if c.log != nil {
c.log.Printf("extension: handler panic: %v", recovered)
}
result = nil
err = MustProtocolError(ErrInternal)
}
}()
return h(ctx, params)
}
func (c *conn) runAfterWrite(after func()) {
defer func() {
if recovered := recover(); recovered != nil {
c.fail(fmt.Errorf("extension: after-response callback panic: %v", recovered))
}
}()
after()
}
func (c *conn) respondHandlerError(id json.RawMessage, err error) {
// A fatal error (a failed handshake) is answered first and only then ends
// the connection, so the peer sees the reason.
var fatal *fatalError
isFatal := errors.As(err, &fatal)
respond := err
if isFatal {
respond = fatal.err
}
var protocolErr *ProtocolError
if errors.As(respond, &protocolErr) {
spec := frozenErrorSpecs[protocolErr.Reason]
message := protocolErr.Message
if message == "" {
message = spec.Message
}
c.respondError(id, spec.Code, message, ProtocolErrorData{Reason: protocolErr.Reason, Retryable: spec.Retryable})
} else {
if c.log != nil {
c.log.Printf("extension: handler error: %v", respond)
}
// Unknown handler errors never leak internals onto the wire: the peer
// sees the frozen internal error, the diagnostic goes to the logger.
spec := frozenErrorSpecs[ErrInternal]
c.respondError(id, spec.Code, spec.Message, ProtocolErrorData{Reason: ErrInternal, Retryable: spec.Retryable})
}
if isFatal {
c.fail(fatal.err)
}
}
func (c *conn) resolve(in *inbound) {
id, err := strconv.ParseInt(string(in.ID), 10, 64)
if err != nil {
return
}
c.pmu.Lock()
ch := c.pending[id]
delete(c.pending, id)
c.pmu.Unlock()
if ch == nil {
return
}
if in.Error != nil {
ch <- rpcResult{err: &ResponseError{Code: in.Error.Code, Message: in.Error.Message, Data: in.Error.Data}}
return
}
ch <- rpcResult{result: in.Result}
}
// notify queues a fire-and-forget notification. Notifications travel through
// one bounded FIFO queue so provider stream chunks stay ordered; a full queue
// fails the connection rather than silently dropping a frame (mirroring the
// host side). A marshaled frame beyond FrameBytes fails only that call.
func (c *conn) notify(method string, params any) error {
raw, err := json.Marshal(params)
if err != nil {
return err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(outbound{JSONRPC: "2.0", Method: method, Params: raw}); err != nil {
return err
}
if buf.Len() > FrameBytes {
return &FrameTooLargeError{Direction: "outbound", Size: buf.Len(), Limit: FrameBytes}
}
select {
case <-c.closed:
return c.closedError()
default:
}
select {
case c.notifyQueue <- buf.Bytes():
return nil
default:
err := fmt.Errorf("extension: outbound notification queue overflow (%d)", maxQueuedNotifications)
c.fail(err)
return err
}
}
// call sends a request and waits for its response, cancellation, or closure.
func (c *conn) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
id := c.nextID.Add(1)
ch := make(chan rpcResult, 1)
c.pmu.Lock()
select {
case <-c.closed:
c.pmu.Unlock()
return nil, c.closedError()
default:
}
c.pending[id] = ch
c.pmu.Unlock()
defer func() {
c.pmu.Lock()
delete(c.pending, id)
c.pmu.Unlock()
}()
idRaw, _ := json.Marshal(id)
if err := c.write(outbound{JSONRPC: "2.0", ID: idRaw, Method: method, Params: raw}); err != nil {
var tooLarge *FrameTooLargeError
if !errors.As(err, &tooLarge) {
c.fail(err)
}
return nil, err
}
select {
case res := <-ch:
return res.result, res.err
case <-ctx.Done():
return nil, ctx.Err()
case <-c.closed:
return nil, c.closedError()
}
}
func (c *conn) write(m outbound) error {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(m); err != nil {
return err
}
if buf.Len() > FrameBytes {
return &FrameTooLargeError{Direction: "outbound", Size: buf.Len(), Limit: FrameBytes}
}
return c.writeFrame(buf.Bytes())
}
func (c *conn) writeFrame(frame []byte) error {
c.wmu.Lock()
defer c.wmu.Unlock()
for len(frame) > 0 {
n, err := c.w.Write(frame)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
frame = frame[n:]
}
return nil
}
func (c *conn) respondError(id json.RawMessage, code int, message string, data any) {
var raw json.RawMessage
if data != nil {
encoded, err := json.Marshal(data)
if err != nil {
code = CodeInternal
message = "marshal error data: " + err.Error()
} else if string(encoded) != "null" {
raw = encoded
}
}
if err := c.write(outbound{JSONRPC: "2.0", ID: id, Error: &rpcErrorObject{Code: code, Message: message, Data: raw}}); err != nil {
var tooLarge *FrameTooLargeError
if !errors.As(err, &tooLarge) {
c.fail(err)
}
}
}
func (c *conn) fail(err error) {
if err == nil {
return
}
c.shutdown(err)
if closer, ok := c.r.(io.Closer); ok {
_ = closer.Close()
}
}
func (c *conn) closedError() error {
c.closeMu.Lock()
defer c.closeMu.Unlock()
if c.closeErr != nil {
return c.closeErr
}
return errors.New("extension: connection closed")
}
// recordedCloseError returns the terminal error recorded at shutdown, which
// may be nil for an orderly close.
func (c *conn) recordedCloseError() error {
c.closeMu.Lock()
defer c.closeMu.Unlock()
return c.closeErr
}
func (c *conn) shutdown(err error) {
c.closeOnce.Do(func() {
c.closeMu.Lock()
c.closeErr = err
c.closeMu.Unlock()
close(c.closed)
c.pmu.Lock()
for id, ch := range c.pending {
pendingErr := err
if pendingErr == nil {
pendingErr = errors.New("extension: connection closed")
}
ch <- rpcResult{err: pendingErr}
delete(c.pending, id)
}
c.pmu.Unlock()
})
}
// readLine reads one NDJSON frame, enforcing the byte budget across bufio
// refills and trimming the trailing line ending.
func readLine(br *bufio.Reader, maxBytes int) ([]byte, error) {
var buf []byte
for {
chunk, err := br.ReadSlice('\n')
buf = append(buf, chunk...)
if maxBytes > 0 && len(buf) > maxBytes {
return nil, &FrameTooLargeError{Direction: "inbound", Size: len(buf), Limit: maxBytes}
}
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
n := len(buf)
for n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r') {
n--
}
return trimSpaceBytes(buf[:n]), err
}
}
func trimSpaceBytes(b []byte) []byte {
i, j := 0, len(b)
for i < j && isSpaceByte(b[i]) {
i++
}
for j > i && isSpaceByte(b[j-1]) {
j--
}
return b[i:j]
}
func isSpaceByte(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
+82
View File
@@ -0,0 +1,82 @@
package extension
import (
"context"
"encoding/json"
"testing"
"time"
)
// TestHandlerPoolSaturated verifies the bounded inbound concurrency: with all
// 32 handler slots occupied, the next request is answered -32099 (server
// busy) while the parked requests still complete afterwards.
func TestHandlerPoolSaturated(t *testing.T) {
release := make(chan struct{})
interceptors := map[string]InterceptorFunc{
"tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
select {
case <-release:
case <-ctx.Done():
}
return Continue(), nil
},
}
host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
host.handshake(t)
channels := make([]chan hostResponse, 0, maxConcurrentHandlers+1)
for i := 0; i < maxConcurrentHandlers+1; i++ {
_, ch := host.startRequest(MethodExtensionIntercept, InterceptParams{
Event: EventToolBefore, Seq: uint64(i + 1), Payload: json.RawMessage(`{}`),
})
channels = append(channels, ch)
}
// Exactly one request — the one finding no handler slot — is rejected
// immediately; the rest stay parked on release.
busyIdx := -1
deadline := time.Now().Add(5 * time.Second)
for busyIdx < 0 && time.Now().Before(deadline) {
for i, ch := range channels {
select {
case resp := <-ch:
if resp.Err == nil || resp.Err.Code != CodeServerBusy {
t.Fatalf("request %d: unexpected early response %+v", i, resp)
}
busyIdx = i
default:
}
}
if busyIdx < 0 {
time.Sleep(5 * time.Millisecond)
}
}
if busyIdx < 0 {
t.Fatal("no request was answered server-busy")
}
for i, ch := range channels {
if i == busyIdx {
continue
}
select {
case resp := <-ch:
t.Fatalf("request %d: expected parked, got %+v", i, resp)
default:
}
}
close(release)
for i, ch := range channels {
if i == busyIdx {
continue
}
select {
case resp := <-ch:
if resp.Err != nil {
t.Fatalf("parked request %d errored: %+v", i, resp.Err)
}
case <-time.After(5 * time.Second):
t.Fatalf("parked request %d did not complete after release", i)
}
}
}