mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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, ¬ice); 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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/esengine/DeepSeek-Reasonix/sdk/go
|
||||
|
||||
go 1.23
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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' }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user