Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/daemon/internal/agent/claudesdk/bridge_frames_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//go:build unix

package claudesdk

import (
"bytes"
"encoding/json"
"os"
"testing"

"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
)

// The TypeScript bridge reads the same file in packages/claude-sdk-adapter/tests/bridge_frames.test.mjs.
func TestBridgeFramesMatchSharedFixtures(t *testing.T) {
data, err := os.ReadFile("../../../../../packages/claude-sdk-adapter/fixtures/bridge-frames.json")
if err != nil {
t.Fatal(err)
}
var fixtures struct {
ToBridge []json.RawMessage `json:"to_bridge"`
FromBridge []json.RawMessage `json:"from_bridge"`
}
if err := json.Unmarshal(data, &fixtures); err != nil {
t.Fatal(err)
}
strict := func(raw json.RawMessage, into any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
return decoder.Decode(into)
}
for _, raw := range fixtures.ToBridge {
var kind struct{ Type string }
if err := json.Unmarshal(raw, &kind); err != nil {
t.Fatal(err)
}
var frame any
switch kind.Type {
case "executor_prepare":
frame = &startRequest{}
case "turn_start":
frame = &turnStartFrame{}
case "turn_cancel":
frame = &turnCancelFrame{}
case "steer":
frame = &steerFrame{}
case "function_result":
frame = &functionResultFrame{}
default:
t.Fatalf("fixture frame %q has no Go producer", kind.Type)
}
if err := strict(raw, frame); err != nil {
t.Fatalf("%s: %v", kind.Type, err)
}
// Encoding must reproduce every fixture field, so no field is dropped or renamed.
encoded, err := json.Marshal(frame)
if err != nil {
t.Fatal(err)
}
var want, got any
if json.Unmarshal(raw, &want) != nil || json.Unmarshal(encoded, &got) != nil {
t.Fatal("invalid JSON")
}
if !bytes.Equal(canonical(t, want), canonical(t, got)) {
t.Errorf("%s: Go encodes\n%s\nfixture\n%s", kind.Type, encoded, raw)
}
}
for _, raw := range fixtures.FromBridge {
var event bridgeEvent
if err := strict(raw, &event); err != nil {
t.Errorf("%s: %v", raw, err)
continue
}
if event.Type == "executor_ready" {
continue
}
// Whatever else the Turn makes of the frame, it must know the kind.
config, req := persistentConfig(t, "replay")
compact := new(bytes.Buffer)
if err := json.Compact(compact, raw); err != nil {
t.Fatal(err)
}
config.Env = append(config.Env, "SDK_REPLAY_FRAME="+compact.String())
owner, err := NewExecutorFactory(config)(t.Context(), req)
if err != nil {
t.Fatal(err)
}
_, out := consumeExecutorTurn(t, owner, "run", "hello")
for envelope := range out {
var failure proto.ErrorPayload
if envelope.Type == proto.TypeError && envelope.DecodePayload(&failure) == nil && failure.Error == "claudesdk: unknown SDK bridge event" {
t.Errorf("Go does not handle bridge frame kind %q", event.Type)
}
}
_ = owner.Close(t.Context())
}
}

func canonical(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}
5 changes: 1 addition & 4 deletions apps/daemon/internal/agent/claudesdk/cancellation.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,7 @@ func (s *session) Cancel(ctx context.Context) error {
if current {
stopWrite := context.AfterFunc(ctx, s.invalidate)
defer stopWrite()
if err := s.owner.write(struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
}{"turn_cancel", s.runID}); err != nil {
if err := s.owner.write(turnCancelFrame{"turn_cancel", s.runID}); err != nil {
s.invalidate()
}
}
Expand Down
6 changes: 1 addition & 5 deletions apps/daemon/internal/agent/claudesdk/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,7 @@ func (e *executor) StartTurn(ctx context.Context, run string, input proto.Messag
go s.runTurn(start, out)
// Once the write is attempted, a lost receipt has an unknown input outcome.
stopWrite := context.AfterFunc(ctx, s.invalidate)
err := e.write(struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
Input proto.MessageInput `json:"input"`
}{"turn_start", run, input})
err := e.write(turnStartFrame{"turn_start", run, input})
stopWrite()
if err != nil {
s.invalidate()
Expand Down
9 changes: 9 additions & 0 deletions apps/daemon/internal/agent/claudesdk/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ func runPersistentExecutorHelper() {
encode(bridgeEvent{Type: "delta", TurnID: old, ItemID: "item", Delta: "late"})
continue
}
if frame := os.Getenv("SDK_REPLAY_FRAME"); frame != "" {
// Replay one shared-fixture frame as this Turn's only bridge output.
var fields map[string]any
_ = json.Unmarshal([]byte(frame), &fields)
fields["turn_id"] = active
_ = json.NewEncoder(os.Stdout).Encode(fields)
settle(true)
continue
}
encode(bridgeEvent{Type: "turn_started", TurnID: active})
encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"})
encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "item", Delta: "partial"})
Expand Down
6 changes: 1 addition & 5 deletions apps/daemon/internal/agent/claudesdk/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio
if err := result.ValidateContent(); err != nil {
return err
}
data, err := json.Marshal(struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
proto.FunctionResultPayload
}{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result})
data, err := json.Marshal(functionResultFrame{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result})
if err != nil {
return err
}
Expand Down
26 changes: 26 additions & 0 deletions apps/daemon/internal/agent/claudesdk/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ type session struct {
outcome proto.DonePayload
}

// Frames the Executor writes to the bridge; bridgeEvent is what the bridge sends back.
// packages/claude-sdk-adapter/fixtures/bridge-frames.json is checked against both.
type turnStartFrame struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
Input proto.MessageInput `json:"input"`
}

type turnCancelFrame struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
}

type steerFrame struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
InputID string `json:"input_id"`
Input proto.MessageInput `json:"input"`
}

type functionResultFrame struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
proto.FunctionResultPayload
}

type bridgeEvent struct {
EngineErrorCode json.RawMessage `json:"engine_error_code"`
TurnID string `json:"turn_id"`
Expand Down
7 changes: 1 addition & 6 deletions apps/daemon/internal/agent/claudesdk/steering.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP
if strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || input.Input.Validate() != nil {
return fmt.Errorf("%w: input identity and text are required", agent.ErrSteeringRejected)
}
data, err := json.Marshal(struct {
Type string `json:"type"`
TurnID string `json:"turn_id"`
InputID string `json:"input_id"`
Input proto.MessageInput `json:"input"`
}{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input})
data, err := json.Marshal(steerFrame{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input})
if err != nil || len(data) > 1024*1024 {
return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-sdk-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The package test compiles TypeScript before running its tests. The Make target a

### Bridge protocol

`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary.
`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. `fixtures/bridge-frames.json` holds one example of every frame kind in each direction. The Go package test decodes it strictly into the Go frame types and replays each bridge frame through a Turn to confirm Go handles its kind. The TypeScript tests feed it to the bridge parsers and check that every frame the bridge emits in them matches a fixture kind and its fields; a frame or field added on one side fails the other side's test until the fixture changes. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary.

It also emits the neutral `output_message` start/completion snapshots and tags deltas with the native Messages API message ID, not the SDK event UUID. Text blocks in one native message share that identity. The SDK's per-block assistant snapshots replace draft block text; only native `message_stop` completes the message, without replaying its text as another delta. Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. Turn-owned native work and output draining precede reuse. Executor close releases the SDK Query and native process. The private `turn_settled` frame requires `confirmed` independently of `reusable`: confirmed native Turn/cancellation settlement, confirmed resource cleanup, and reuse eligibility are separate facts. Unknown or nonempty interrupt receipts and unsettled input/function/tool work remain unconfirmed even after successful teardown. Go rejects cancellation and AwaitSettlement when native confirmation is missing or false, or its own receipt ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; that state alone does not turn a verified cancellation into an error. Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts.

Expand Down
87 changes: 87 additions & 0 deletions packages/claude-sdk-adapter/fixtures/bridge-frames.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"to_bridge": [
{
"type": "executor_prepare",
"model": "claude-fixture",
"system_prompt": "Answer briefly.",
"cwd": "/tmp",
"resume": "native-session",
"native_model_options": { "effort": "high", "thinking": { "type": "enabled", "budgetTokens": 2048, "display": "summarized" } },
"output_format": { "type": "json_schema", "schema": { "type": "object" } },
"subagents": { "max_concurrent": 2 },
"tool_search": true,
"reasoning_summary": true,
"web_search": "live",
"functions": [{ "name": "lookup", "description": "Look a value up.", "parameters": { "type": "object" }, "defer_loading": true }],
"mcp_http_servers": [{
"server_label": "docs",
"server_url": "https://example.com/mcp",
"allowed_tools": ["search"],
"required": true,
"bearer_token_env_var": "OAC_RUNTIME_MCP_BEARER_AAAAAAAAAAAAAAAAAAAAAAAAAA",
"headers": { "x-fixture": "1" }
}]
},
{
"type": "executor_prepare",
"model": "claude-fixture",
"system_prompt": "",
"cwd": "/tmp",
"require_history": true,
"workspace": {
"tool_env": { "FIXTURE": "1" },
"capability_root": "/tmp",
"mcp": [{
"server_label": "local",
"command": "/usr/bin/runtime",
"args": ["runtime-mcp-exec", "/tmp", "mcp/local", "local"],
"allowed_tools": null
}, {
"server_label": "declared",
"command": "server",
"args": [],
"allowed_tools": null,
"declared": true
}],
"skills": [{ "metadata": { "type": "inline", "name": "fixture", "description": "A fixture skill." }, "relative_root": "skills/fixture", "package_root": "skills" }],
"home": "/tmp",
"state": "/tmp",
"scratch": "/tmp",
"env_names": ["ANTHROPIC_API_KEY"]
}
},
{ "type": "turn_start", "turn_id": "run-1", "input": [{ "content": [{ "type": "input_text", "text": "Hello" }] }] },
{ "type": "turn_cancel", "turn_id": "run-1" },
{ "type": "steer", "turn_id": "run-1", "input_id": "input-1", "input": [{ "content": [{ "type": "input_text", "text": "Also this" }] }] },
{ "type": "function_result", "turn_id": "run-1", "call_id": "call-1", "delivery_id": "delivery-1", "success": true, "content": [{ "type": "input_text", "text": "42" }] }
],
"from_bridge": [
{ "type": "executor_ready", "protocol": 3 },
{ "type": "turn_started", "turn_id": "run-1" },
{ "type": "turn_settled", "turn_id": "run-1", "confirmed": true, "reusable": false, "reason": "native_error" },
{ "type": "input_ready", "turn_id": "run-1", "session_id": "native-session" },
{ "type": "input_closed", "turn_id": "run-1", "session_id": "native-session" },
{ "type": "input_applied", "turn_id": "run-1", "input_id": "input-1" },
{ "type": "input_rejected", "turn_id": "run-1", "input_id": "input-1" },
{ "type": "delta", "turn_id": "run-1", "item_id": "message-1", "delta": "Hi" },
{ "type": "output_message", "turn_id": "run-1", "message": { "id": "message-1", "status": "completed", "text": "Hi" } },
{ "type": "reasoning", "turn_id": "run-1", "reasoning": { "id": "message-1:0", "status": "in_progress", "delta": "Thinking" } },
{ "type": "function_call", "turn_id": "run-1", "call": { "call_id": "call-1", "name": "lookup", "arguments": { "text": "x" }, "native_id": "child-1", "turn_id": "child-turn-1" } },
{ "type": "function_withdrawn", "turn_id": "run-1", "withdrawn": { "call_id": "call-1", "native_id": "child-1", "turn_id": "child-turn-1" } },
{ "type": "function_applied", "turn_id": "run-1", "call_id": "call-1", "delivery_id": "delivery-1" },
{ "type": "command_observation", "turn_id": "run-1", "session_id": "native-session", "id": "tool-1", "stage": "after", "observation": { "kind": "command", "status": "completed", "command": "ls", "output": "a" } },
{ "type": "search_observation", "turn_id": "run-1", "session_id": "native-session", "id": "tool-2", "stage": "before", "observation": { "kind": "web_search", "status": "in_progress", "action": { "type": "search", "query": "oac" } } },
{ "type": "mcp_observation", "turn_id": "run-1", "id": "tool-3", "stage": "after", "observation": { "kind": "mcp", "status": "completed", "server": "docs", "name": "search", "arguments": {}, "output": "ok", "error": null } },
{ "type": "subagent_identity", "turn_id": "run-1", "fact": { "native_id": "child-1" } },
{ "type": "subagent_turn", "turn_id": "run-1", "fact": { "native_id": "child-1" } },
{ "type": "subagent_item", "turn_id": "run-1", "fact": { "native_id": "child-1" } },
{ "type": "subagent_coordination", "turn_id": "run-1", "fact": { "native_id": "child-1" } },
{
"type": "usage", "turn_id": "run-1", "session_id": "native-session", "result_id": "result-1",
"usage": { "usage": { "input_tokens": 1 }, "modelUsage": {}, "total_cost_usd": 0.01, "subtype": "success", "is_error": false,
"scopes": { "usage": "native_turn_main_loop", "modelUsage": "query_cumulative", "total_cost_usd": "query_cumulative_estimate" } }
},
{ "type": "result", "turn_id": "run-1", "session_id": "native-session", "text": "Hi" },
{ "type": "error", "turn_id": "run-1", "code": "execution_failed", "engine_error_code": "rate_limit", "session_id": "native-session", "result_id": "result-1" }
]
}
Loading
Loading