From 29d0b8610fec1fa390f00898f8e5187be707e44c Mon Sep 17 00:00:00 2001 From: winterfx Date: Fri, 4 Sep 2026 10:40:31 +0800 Subject: [PATCH 1/5] feat(runtime): publish provider-neutral agent events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every runner collapsed its provider's structured event stream into a plain text transcript, so tool calls, tool results and token accounting never reached consumers. Only codex forwarded raw SDK events, which pushed a six-way parse onto every client; claude, opencode and pi emitted a single `{type:"output"}` text frame, and gemini and dsh produced no agent events at all. Nothing ever produced ProjectRunEventKindAgentActivity. Introduce a neutral AgentEvent union (agent-event.ts) and an optional `onEvent` sink on RunnerOptions, then map all six providers onto it. A provider that structurally cannot produce a kind emits nothing of that kind rather than an empty placeholder, so consumers can tell "did not happen" from "never reported". The frame name becomes the kind and the daemon's projector reads that instead of codex's item shapes; AttachAgentEvent needs no proto change, since payload_json already carries anything. Three behaviours are load-bearing and were measured against recorded runs: - inputTokens always excludes cached tokens. codex and gemini report an inclusive prompt count upstream and now subtract, so the field means the same thing across providers. - dsh publishes byte-identical usage twice (assistant/chunk and assistant/message); only the latter is mapped, or every count doubles. - claude and pi close a step before its tool events arrive, so consumers must group by the `step` field, never by the step_start/step_end interval. Fixed while mapping: - claude never handled the user-role message that carries tool_result, so every tool's output was invisible; its completed tool_call used the content-block index as id and could not correlate with its result. - codex swallowed the top-level ThreadErrorEvent, because the guard for item-bearing events returned before reaching it. - interactive sessions dropped `effort` and `skills` entirely. dsh needed three fixes to reach parity: - it rejected any agent that did not spell out / while codex and claude fall back to the daemon default; the requirement was incidental, since SplitDshModel ran before the shared default resolution could be reached. - its facade pinned chat completions, so every turn against a Responses provider went through protocol conversion — which also carries whatever the upstream sends that the bridge does not model, and this gateway's own codex.response.metadata and responsesapi.websocket_timing events reached the guest as assistant text. That constant was inherited, not chosen: the profile configured dsh-base's llm-deepseek row, whose Config has no protocol field. dsh-base also mounts llm-pi-ai, dormant until a profile supplies routes and able to name its protocol per route, so the guest now follows the provider and stays on the passthrough path. - its profile kept dsh's own bash and fs sandboxes, which enforce nothing extra inside agent-compose's sandbox but do add a `sandbox_permissions` escalation argument to every tool schema. Nothing tells the model which mode it holds, so it asks for one narrower than the danger-full-access it already has and the call is rejected as "not strictly wider" — a wasted turn each time (deepseek-harness#468). The unconfined executors drop ctx.shell.sandboxMode, which removes the argument from the schema. Fixtures are real recorded runs of one prompt against each provider, kept as regression cover for provider field drift — codex sends a cache_write_input_tokens its own SDK type does not declare. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R46kvDYupMBjXvJHaHoinr --- .../profiles/agent-compose/cordis.patch.yml | 66 ++++++- docs/design/dsh_agent_provider_design.md | 10 +- .../proxy/runtime_llm_coverage_test.go | 1 + pkg/llms/dsh_facade.go | 87 +++++++-- pkg/llms/dsh_facade_test.go | 48 ++++- pkg/runs/coverage_shape_workflows_test.go | 18 +- pkg/runs/prompt_attach.go | 17 +- pkg/runs/prompt_projection.go | 54 ++---- runtime/javascript/src/agent-event.ts | 160 ++++++++++++++++ runtime/javascript/src/interactive.ts | 67 ++++--- runtime/javascript/src/runners/claude.ts | 178 ++++++++++++++++++ runtime/javascript/src/runners/codex.ts | 144 +++++++++++++- runtime/javascript/src/runners/dsh.ts | 138 +++++++++++++- runtime/javascript/src/runners/gemini.ts | 97 ++++++++++ runtime/javascript/src/runners/opencode.ts | 99 ++++++++++ runtime/javascript/src/runners/pi.ts | 121 ++++++++++++ runtime/javascript/src/stream.ts | 20 ++ runtime/javascript/src/types.ts | 7 + runtime/javascript/test/dsh-runner.test.ts | 33 +++- .../test/fixtures/providers/claude.jsonl | 25 +++ .../test/fixtures/providers/codex.jsonl | 9 + .../test/fixtures/providers/dsh.jsonl | 95 ++++++++++ .../test/fixtures/providers/gemini.jsonl | 7 + .../test/fixtures/providers/opencode.jsonl | 7 + .../test/fixtures/providers/pi.jsonl | 66 +++++++ .../test/provider-event-mapping.test.ts | 171 +++++++++++++++++ .../javascript/test/runner-execution.test.ts | 7 +- runtime/javascript/test/stream.test.ts | 52 +++-- 28 files changed, 1674 insertions(+), 130 deletions(-) create mode 100644 runtime/javascript/src/agent-event.ts create mode 100644 runtime/javascript/test/fixtures/providers/claude.jsonl create mode 100644 runtime/javascript/test/fixtures/providers/codex.jsonl create mode 100644 runtime/javascript/test/fixtures/providers/dsh.jsonl create mode 100644 runtime/javascript/test/fixtures/providers/gemini.jsonl create mode 100644 runtime/javascript/test/fixtures/providers/opencode.jsonl create mode 100644 runtime/javascript/test/fixtures/providers/pi.jsonl create mode 100644 runtime/javascript/test/provider-event-mapping.test.ts diff --git a/assets/.dsh/profiles/agent-compose/cordis.patch.yml b/assets/.dsh/profiles/agent-compose/cordis.patch.yml index b63fc8eda..795b230b0 100644 --- a/assets/.dsh/profiles/agent-compose/cordis.patch.yml +++ b/assets/.dsh/profiles/agent-compose/cordis.patch.yml @@ -4,18 +4,45 @@ # runtime/javascript/src/runners/dsh.ts — see docs/design/dsh_agent_provider_design.md # §3.2/§3.5. This file ships as a repo asset (assets/.dsh/...), not an npm package. +# dsh-base mounts two LLM adapters: llm-deepseek (its native one, active) and +# llm-pi-ai (dormant until a profile supplies routes). agent-compose uses the +# latter because llm-deepseek speaks only chat completions — its Config has no +# protocol field at all — which forces a protocol conversion whenever the +# resolved provider serves something else. A conversion also carries whatever +# the upstream sends that the bridge does not model: this gateway emits its own +# codex.response.metadata and responsesapi.websocket_timing events, which the +# chat-completions encoder rendered into the assistant's answer. +# llm-pi-ai names its wire protocol per route, so the guest can speak whatever +# the facade resolved and the request stays on the passthrough path. +# +# Disabling llm-deepseek keeps one adapter owning the route namespace: two +# adapters claiming the same provider id fail plugin loading. - id: llm-deepseek + disabled: true + +# A hand-declared route: pi-ai ships nothing under this key, so the profile +# supplies the whole provider. Such a route requires api, baseURL, and a +# non-empty models list. All three come from the spawn environment, and +# DSH_WIRE_API is the protocol the daemon's facade resolved for this run. +- id: llm-pi-ai config: - apiKeyEnv: LLM_API_KEY - baseURL: !!js process.env.LLM_API_ENDPOINT - thinking: enabled - reasoningEffort: !!js process.env.DSH_REASONING_EFFORT || 'max' + providers: + agent-compose: + displayName: agent-compose facade + apiKeyEnv: LLM_API_KEY + api: !!js process.env.DSH_WIRE_API || 'openai-completions' + baseURL: !!js process.env.LLM_API_ENDPOINT + models: + - id: !!js process.env.DSH_MODEL || 'deepseek-v4-flash' + reasoningEfforts: + off: + low: low + high: high + max: max -# llm-deepseek registers a single route, 'deepseek-official' (see §4.2); the -# model name alone comes from the spawn environment. - id: agent-default-model config: - provider: deepseek-official + provider: agent-compose model: !!js process.env.DSH_MODEL || 'deepseek-v4-flash' # agent-compose assembles persona/skills-catalog/workspace context host-side, @@ -53,6 +80,31 @@ includeDefaultRoots: false customSkillDirs: !!js (process.env.DSH_SKILL_DIRS || '').split(':').filter(Boolean) +# dsh-base confines tool execution itself: bash-sandbox, pwsh-sandbox and +# fs-sandbox enforce a mode, and sandbox-policy pins it from +# DSH_PERMISSION_MODE. agent-compose already runs the whole guest inside its +# own sandbox, so that inner layer enforces nothing extra — it only adds a +# `sandbox_permissions` escalation argument to every tool schema. +# +# That argument is unusable here and actively costly. Its enum is advertised +# whenever a confining executor is mounted, but nothing tells the model which +# mode it currently holds, so a model reaches for it, asks for a mode narrower +# than the danger-full-access it already has, and approveEscalation rejects the +# call as "not strictly wider" — one wasted turn per occurrence. See +# https://github.com/deepseek-ai/deepseek-harness/discussions/468. +# +# Swapping in the unconfined executors drops `ctx.shell.sandboxMode`, the gate +# the tools read: with no mode the escalation fields leave the schema entirely, +# so the model cannot ask for something it already holds. Narrowing the mode +# instead would be worse, since dsh-base derives the approval policy from the +# same variable ('never' only under danger-full-access) and this guest has no +# one to answer an approval prompt. +- id: bash-sandbox + name: '@deepseek-ai/dsh-bash-local' + +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-local' + # No approval/sandbox-policy overrides here: dsh-base's own rows already key # off DSH_PERMISSION_MODE (agent-compose always sets danger-full-access — see # §5.3/§5.5, guest sandboxing is provided by the agent-compose sandbox, not a diff --git a/docs/design/dsh_agent_provider_design.md b/docs/design/dsh_agent_provider_design.md index f34acefc0..63e2dc656 100644 --- a/docs/design/dsh_agent_provider_design.md +++ b/docs/design/dsh_agent_provider_design.md @@ -54,11 +54,15 @@ Env vars aren't unbounded: Linux caps a single `argv`/`envp` string at `MAX_ARG_ ### 4.1 Facade token and wire protocol -`EnsureDshFacadeConfig` (`pkg/llms/dsh_facade.go`) always issues a chat-completions facade token and points the guest at `/llm/openai/v1`, regardless of the resolved upstream provider's own protocol — the facade bridges the difference, so DSH's own upstream protocol is irrelevant to the guest. Model selection is `/` (`SplitDshModel`), the same shape Pi and OpenCode use. +`EnsureDshFacadeConfig` (`pkg/llms/dsh_facade.go`) issues a facade token whose wire API **follows the resolved provider**, and exports the same choice as `DSH_WIRE_API` for the profile's `llm-pi-ai` route to name its protocol. Matching the provider keeps the request on the proxy's passthrough path instead of the conversion path, where an upstream event the bridge does not model would reach the guest as assistant text. It was unconditionally chat-completions while the profile used `llm-deepseek`, whose Config has no protocol field at all (see §4.2). Model selection is `/` (`SplitDshModel`), the same shape Pi and OpenCode use; an agent naming no model falls back to the daemon's default catalog entry. -### 4.2 `llm-deepseek` route +### 4.2 LLM adapter and route -`cordis.patch.yml`'s `llm-deepseek` row registers a single route, `deepseek-official`, reading its API key/base URL/reasoning effort from the spawn environment. `agent-default-model` selects `deepseek-official` + `DSH_MODEL`. +`cordis.patch.yml` disables dsh-base's `llm-deepseek` row and configures `llm-pi-ai` instead, which dsh-base mounts dormant until a profile supplies routes. + +`llm-deepseek` is DSH's native adapter and speaks only chat completions — its Config exposes `apiKeyEnv`, `baseURL`, `thinking` and `reasoningEffort`, and no protocol field — so any provider serving something else forced a conversion on every turn. `llm-pi-ai` names its wire protocol per route (`openai-completions`, `openai-responses`, `anthropic-messages`), so the guest can speak whatever the facade resolved. + +The profile declares one hand-declared route, `agent-compose`: pi-ai ships nothing under that key, so the route supplies `api` (from `DSH_WIRE_API`), `baseURL`, and a `models` list, all from the spawn environment. `agent-default-model` selects that route + `DSH_MODEL`. ## 5. Security and isolation diff --git a/pkg/agentcompose/proxy/runtime_llm_coverage_test.go b/pkg/agentcompose/proxy/runtime_llm_coverage_test.go index a87d4aa55..7fab56366 100644 --- a/pkg/agentcompose/proxy/runtime_llm_coverage_test.go +++ b/pkg/agentcompose/proxy/runtime_llm_coverage_test.go @@ -709,3 +709,4 @@ type errRuntimeLLMReader struct{} func (errRuntimeLLMReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + diff --git a/pkg/llms/dsh_facade.go b/pkg/llms/dsh_facade.go index 3c3f6dd79..8ea29abf0 100644 --- a/pkg/llms/dsh_facade.go +++ b/pkg/llms/dsh_facade.go @@ -2,6 +2,7 @@ package llms import ( "context" + "fmt" "strings" appconfig "github.com/chaitin/agent-compose/pkg/config" @@ -15,8 +16,10 @@ type DshFacadeStore interface { SaveLLMFacadeToken(context.Context, FacadeToken) error } -// SplitDshModel parses DSH's required / -// selection (same format as Pi/OpenCode, see agent-compose-yaml-manual.md). +// SplitDshModel parses DSH's / selection (same +// format as Pi/OpenCode, see agent-compose-yaml-manual.md). An agent that +// names no model at all does not reach here: EnsureDshFacadeConfig resolves +// the daemon default instead. func SplitDshModel(value string) (string, string, error) { providerID, model, ok := strings.Cut(strings.TrimSpace(value), "/") providerID = strings.TrimSpace(providerID) @@ -39,30 +42,36 @@ type DshFacadeConfigRequest struct { RunID string } -// EnsureDshFacadeConfig resolves DSH's explicit provider/model selection and -// returns run-scoped facade credentials. Unlike Pi, DSH's llm-deepseek -// adapter only ever speaks chat completions on the wire (its own upstream -// protocol is irrelevant: the facade bridges), so the issued token's WireAPI -// is unconditionally APIProtocolChatCompletions and the guest is always -// routed to /llm/openai/v1 (see docs/design/dsh_agent_provider_design.md §4.1). +// EnsureDshFacadeConfig resolves DSH's model selection and returns run-scoped +// facade credentials. An explicit / picks that +// pair; an absent model falls back to the daemon's default catalog entry, the +// same way codex and claude behave. +// +// The wire protocol follows the resolved provider rather than being fixed. +// The profile's llm-pi-ai route names its protocol per request through +// DSH_WIRE_API, so the guest speaks whatever the provider serves and the +// request stays on the proxy's passthrough path — no conversion, and none of +// the vendor-event leakage a conversion can carry. The previous adapter, +// llm-deepseek, could only speak chat completions, which is why this was +// unconditional before (see docs/design/dsh_agent_provider_design.md §4.1). func EnsureDshFacadeConfig(ctx context.Context, req DshFacadeConfigRequest) (map[string]string, error) { config, store, sandbox := req.Config, req.Store, req.Sandbox - providerID, modelName, err := SplitDshModel(req.Model) - if err != nil { - return nil, err - } baseURL := GuestRuntimeBaseURL(config, sandbox) if strings.TrimSpace(baseURL) == "" { return nil, nil } - target, err := resolveDshFacadeTarget(ctx, dshFacadeTargetInput{Config: config, Store: store, Sandbox: sandbox, ProviderID: providerID, Model: modelName}) + target, err := resolveDshTarget(ctx, req) + if err != nil { + return nil, err + } + wireAPI, piAiAPI, err := dshWireAPI(target) if err != nil { return nil, err } facadeBaseURL := strings.TrimRight(baseURL, "/") + "/api/runtime/sandboxes/" + sandbox.Summary.ID + "/llm/openai/v1" tokenValue, token, err := NewFacadeToken(NewFacadeTokenRequest{ - SandboxID: sandbox.Summary.ID, Model: target.Model.Name, ProviderID: target.Provider.ID, WireAPI: APIProtocolChatCompletions, Source: req.Source, RunID: req.RunID, + SandboxID: sandbox.Summary.ID, Model: target.Model.Name, ProviderID: target.Provider.ID, WireAPI: wireAPI, Source: req.Source, RunID: req.RunID, }) if err != nil { return nil, err @@ -75,12 +84,56 @@ func EnsureDshFacadeConfig(ctx context.Context, req DshFacadeConfigRequest) (map "AGENT_COMPOSE_SANDBOX_TOKEN": tokenValue, "LLM_API_ENDPOINT": facadeBaseURL, "LLM_API_KEY": tokenValue, - "LLM_API_PROTOCOL": APIProtocolChatCompletions, - "DSH_MODEL": target.Model.Name, - "DSH_PERMISSION_MODE": "danger-full-access", + "LLM_API_PROTOCOL": wireAPI, + // The profile's llm-pi-ai route reads this to name its wire protocol. + "DSH_WIRE_API": piAiAPI, + "DSH_MODEL": target.Model.Name, + "DSH_PERMISSION_MODE": "danger-full-access", }, nil } +// dshWireAPI maps the resolved target onto the facade token's wire API and the +// spelling llm-pi-ai uses for the same protocol in its route config. +func dshWireAPI(target ResolvedTarget) (string, string, error) { + switch NormalizeWireAPI(target.WireAPI) { + case APIProtocolResponses: + return APIProtocolResponses, "openai-responses", nil + case APIProtocolChatCompletions: + return APIProtocolChatCompletions, "openai-completions", nil + default: + return "", "", domain.ClassifyError(domain.ErrFailedPrecondition, + fmt.Sprintf("dsh does not support wire api %q", target.WireAPI), nil) + } +} + +// resolveDshTarget picks the provider/model pair for this run. +// +// With no model configured it delegates to the shared default resolution +// (SelectModelAndProvider picks the catalog's default entry) exactly as +// codex does, rather than going through resolveDshFacadeTarget: that +// function dispatches on the provider id, and an empty id falls through to +// the custom-OpenAI branch, which needs a concrete provider to resolve. +// OpenAI is the preferred family because the DSH facade always issues a +// chat-completions token and routes the guest to /llm/openai/v1. +func resolveDshTarget(ctx context.Context, req DshFacadeConfigRequest) (ResolvedTarget, error) { + config, store, sandbox := req.Config, req.Store, req.Sandbox + if strings.TrimSpace(req.Model) == "" { + envItems, err := SandboxProviderEnvItems(ctx, store, sandbox, ProviderFamilyOpenAI) + if err != nil { + return ResolvedTarget{}, err + } + return ResolveRuntimeLLMTargetWithEnv(ctx, store, RuntimeLLMTargetQuery{ + Config: config, SessionID: sandbox.Summary.ID, PreferredProviderFamily: ProviderFamilyOpenAI, + RequestedModel: "", ProviderID: "", EnvItems: envItems, + }) + } + providerID, modelName, err := SplitDshModel(req.Model) + if err != nil { + return ResolvedTarget{}, err + } + return resolveDshFacadeTarget(ctx, dshFacadeTargetInput{Config: config, Store: store, Sandbox: sandbox, ProviderID: providerID, Model: modelName}) +} + // dshFacadeTargetInput groups resolveDshFacadeTarget's inputs. type dshFacadeTargetInput struct { Config *appconfig.Config diff --git a/pkg/llms/dsh_facade_test.go b/pkg/llms/dsh_facade_test.go index e0f3dbabd..75a4f5e70 100644 --- a/pkg/llms/dsh_facade_test.go +++ b/pkg/llms/dsh_facade_test.go @@ -41,9 +41,16 @@ func TestEnsureDshFacadeConfigBindsConfiguredProviderToken(t *testing.T) { if err != nil { t.Fatalf("EnsureDshFacadeConfig returned error: %v", err) } - if env["DSH_MODEL"] != "org/deepseek-v4" || env["LLM_API_PROTOCOL"] != APIProtocolChatCompletions { + // The wire protocol follows the resolved provider (Responses here) rather + // than being fixed, so the guest speaks what the provider serves and the + // proxy never has to convert. DSH_WIRE_API carries the same choice in the + // spelling the profile's llm-pi-ai route expects. + if env["DSH_MODEL"] != "org/deepseek-v4" || env["LLM_API_PROTOCOL"] != APIProtocolResponses { t.Fatalf("DSH environment = %#v", env) } + if env["DSH_WIRE_API"] != "openai-responses" { + t.Fatalf("DSH_WIRE_API = %q", env["DSH_WIRE_API"]) + } if env["LLM_API_ENDPOINT"] != "http://runtime.test/base/api/runtime/sandboxes/sandbox-1/llm/openai/v1" { t.Fatalf("LLM_API_ENDPOINT = %q", env["LLM_API_ENDPOINT"]) } @@ -55,7 +62,7 @@ func TestEnsureDshFacadeConfigBindsConfiguredProviderToken(t *testing.T) { } token := store.savedTokens[0] if token.SandboxID != "sandbox-1" || token.Model != "org/deepseek-v4" || token.ProviderID != "deepseek-catalog" || - token.WireAPI != APIProtocolChatCompletions || token.Source != "agent" || token.RunID != "run-1" { + token.WireAPI != APIProtocolResponses || token.Source != "agent" || token.RunID != "run-1" { t.Fatalf("saved token = %#v", token) } } @@ -107,7 +114,7 @@ func TestEnsureDshFacadeConfigUsesSessionEnvProvider(t *testing.T) { if len(store.savedTokens) != 1 || store.savedTokens[0].ProviderID != wantProviderID { t.Fatalf("saved tokens = %#v, want session provider %q", store.savedTokens, wantProviderID) } - if store.savedTokens[0].Model != "org/deepseek-v4" || store.savedTokens[0].WireAPI != APIProtocolChatCompletions { + if store.savedTokens[0].Model != "org/deepseek-v4" || store.savedTokens[0].WireAPI != APIProtocolResponses { t.Fatalf("saved token = %#v", store.savedTokens[0]) } if env["DSH_MODEL"] != "org/deepseek-v4" { @@ -145,3 +152,38 @@ func (s *dshFacadeTestStore) SaveLLMFacadeToken(_ context.Context, token FacadeT s.savedTokens = append(s.savedTokens, token) return nil } + +// TestEnsureDshFacadeConfigFollowsChatCompletionsProvider is the other half of +// the contract the two tests above cover for Responses: dsh no longer pins a +// wire protocol, so a provider serving chat completions gets a guest speaking +// chat completions. Matching the provider is what keeps the request off the +// proxy's conversion path, where an unmodelled upstream event would otherwise +// reach the guest as assistant text. +func TestEnsureDshFacadeConfigFollowsChatCompletionsProvider(t *testing.T) { + store := newDshFacadeTestStore() + store.providers = []Provider{{ + ID: "compat-gateway", ProviderType: ProviderFamilyOpenAI, + DefaultWireAPI: APIProtocolChatCompletions, BaseURL: "https://compat.test", APIKey: "secret", Enabled: true, + }} + store.models = []Model{{ID: "model-id", Name: "org/compat-model", Enabled: true}} + store.wire["compat-gateway\x00model-id"] = APIProtocolChatCompletions + + env, err := EnsureDshFacadeConfig(context.Background(), DshFacadeConfigRequest{ + Config: &appconfig.Config{RuntimeBaseURL: "http://runtime.test/base/"}, + Store: store, + Sandbox: &domain.Sandbox{Summary: domain.SandboxSummary{ID: "sandbox-chat"}}, + Model: "compat-gateway/org/compat-model", Source: "agent", RunID: "run-chat", + }) + if err != nil { + t.Fatalf("EnsureDshFacadeConfig returned error: %v", err) + } + if env["LLM_API_PROTOCOL"] != APIProtocolChatCompletions { + t.Fatalf("LLM_API_PROTOCOL = %q", env["LLM_API_PROTOCOL"]) + } + if env["DSH_WIRE_API"] != "openai-completions" { + t.Fatalf("DSH_WIRE_API = %q", env["DSH_WIRE_API"]) + } + if len(store.savedTokens) != 1 || store.savedTokens[0].WireAPI != APIProtocolChatCompletions { + t.Fatalf("saved token = %#v", store.savedTokens) + } +} diff --git a/pkg/runs/coverage_shape_workflows_test.go b/pkg/runs/coverage_shape_workflows_test.go index 33e596f28..71ef14f81 100644 --- a/pkg/runs/coverage_shape_workflows_test.go +++ b/pkg/runs/coverage_shape_workflows_test.go @@ -922,7 +922,7 @@ func TestRunsControllerRunProjectPromptAttachProjectsAgentFrames(t *testing.T) { controller, configDB, runtime := newTestRunAttachController(t, []driverpkg.RuntimeOutputFrame{ {Type: driverpkg.RuntimeOutputStarted}, {Type: driverpkg.RuntimeOutputStdout, Data: []byte(`{"v":1,"seq":0,"type":"started","provider":"claude","sessionId":"thread-1"}` + "\n")}, - {Type: driverpkg.RuntimeOutputStdout, Data: []byte(`{"v":1,"seq":1,"type":"agent_event","event":{"type":"output","provider":"claude","text":"hello agent\n"}}` + "\n")}, + {Type: driverpkg.RuntimeOutputStdout, Data: []byte(`{"v":1,"seq":1,"type":"agent_event","event":{"kind":"text_delta","text":"hello agent\n"}}` + "\n")}, {Type: driverpkg.RuntimeOutputStdout, Data: []byte(`{"v":1,"seq":2,"type":"agent_turn_completed","provider":"claude","sessionId":"thread-1","finalText":"hello agent\n","finalTextSource":"provider_message"}` + "\n")}, {Type: driverpkg.RuntimeOutputStdout, Data: []byte(`{"v":1,"seq":3,"type":"result","provider":"claude","sessionId":"thread-1","stopReason":"eof","finalText":"hello agent\n","finalTextSource":"provider_message","transcript":"hello agent\n"}` + "\n")}, {Type: driverpkg.RuntimeOutputResult, Result: &driverpkg.RuntimeResult{OperationID: "run-attach", ExitCode: 0, Success: true}}, @@ -1032,17 +1032,17 @@ func TestRunsControllerRunProjectPromptAttachGatesQueuedTurnsAndOrdersTranscript interaction.frames <- driverpkg.RuntimeOutputFrame{Type: driverpkg.RuntimeOutputStarted} interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":0,"type":"started","provider":"codex","sessionId":"thread-1"}`) - interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":1,"type":"agent_event","event":{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"agent-1\n"}}}`) + interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":1,"type":"agent_event","event":{"kind":"text_delta","text":"agent-1\n"}}`) interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":2,"type":"agent_turn_completed","provider":"codex","sessionId":"thread-1","finalText":"agent-1\n","finalTextSource":"provider_message"}`) assertPromptRuntimeFrame(t, receiveRuntimeInputFrame(t, interaction.sent), "human_message", "human-2") assertNoRuntimeInputFrame(t, interaction.sent) - interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":3,"type":"agent_event","event":{"type":"item.completed","item":{"id":"m2","type":"agent_message","text":"agent-2\n"}}}`) + interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":3,"type":"agent_event","event":{"kind":"text_delta","text":"agent-2\n"}}`) interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":4,"type":"agent_turn_completed","provider":"codex","sessionId":"thread-1","finalText":"agent-2\n","finalTextSource":"provider_message"}`) assertPromptRuntimeFrame(t, receiveRuntimeInputFrame(t, interaction.sent), "human_message", "human-3") assertPromptRuntimeFrame(t, receiveRuntimeInputFrame(t, interaction.sent), "eof", "") - interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":5,"type":"agent_event","event":{"type":"item.completed","item":{"id":"m3","type":"agent_message","text":"agent-3\n"}}}`) + interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":5,"type":"agent_event","event":{"kind":"text_delta","text":"agent-3\n"}}`) interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":6,"type":"agent_turn_completed","provider":"codex","sessionId":"thread-1","finalText":"agent-3\n","finalTextSource":"provider_message"}`) interaction.frames <- promptRuntimeStdoutFrame(`{"v":1,"seq":7,"type":"result","provider":"codex","sessionId":"thread-1","stopReason":"eof","finalText":"agent-3\n","finalTextSource":"provider_message","transcript":"agent-1\nagent-2\nagent-3\n"}`) interaction.frames <- driverpkg.RuntimeOutputFrame{Type: driverpkg.RuntimeOutputResult, Result: &driverpkg.RuntimeResult{OperationID: "run-attach", Success: true}} @@ -1108,7 +1108,7 @@ func TestPromptAttachProjectorLogsHumanMessagesAndTurnFinalText(t *testing.T) { sub := hub.Subscribe("run-follow") defer sub.Close() projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-follow"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-follow"}}, logsPath, hub) - if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"first answer\n"}}}` + "\n")); err != nil { + if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"kind":"text_delta","text":"first answer\n"}}` + "\n")); err != nil { t.Fatalf("project first answer: %v", err) } if err := projector.AppendHumanMessage("next question"); err != nil { @@ -1149,7 +1149,7 @@ func TestPromptAttachProjectorLogsTurnFinalTextWithoutAgentEventText(t *testing. func TestPromptAttachProjectorSeparatesHumanMessageAfterUnterminatedAgentText(t *testing.T) { logsPath := filepath.Join(t.TempDir(), "transcript.txt") projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-boundary"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-boundary"}}, logsPath, nil) - if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"first answer"}}}` + "\n")); err != nil { + if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"kind":"text_delta","text":"first answer"}}` + "\n")); err != nil { t.Fatalf("project agent text: %v", err) } if err := projector.AppendHumanMessage("next question"); err != nil { @@ -1167,7 +1167,7 @@ func TestPromptAttachProjectorSeparatesHumanMessageAfterUnterminatedAgentText(t func TestPromptAttachProjectorDoesNotDuplicateSeparatorsBetweenQueuedHumanMessages(t *testing.T) { logsPath := filepath.Join(t.TempDir(), "transcript.txt") projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-human-tail"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-human-tail"}}, logsPath, nil) - if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"agent"}}}` + "\n")); err != nil { + if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"kind":"text_delta","text":"agent"}}` + "\n")); err != nil { t.Fatalf("project agent text: %v", err) } if err := projector.AppendHumanMessage("human-2"); err != nil { @@ -1223,7 +1223,7 @@ func TestPromptAttachProjectorPersistsEachFrameIdempotently(t *testing.T) { if err := projector.AppendHumanMessageFrame("question", "client-frame-1"); err != nil { t.Fatalf("retry human frame: %v", err) } - activity := []byte(`{"seq":41,"type":"agent_event","event":{"type":"item.completed","item":{"id":"cmd-1","type":"command_execution","command":"curl https://weather.test","aggregated_output":"{\"temperature\":26}\n"}}}` + "\n") + activity := []byte(`{"seq":41,"type":"agent_event","event":{"kind":"text_delta","text":"\\n$ curl https://weather.test\\n{\"temperature\":26}\n"}}` + "\n") if _, _, err := projector.Project(activity); err != nil { t.Fatalf("project activity: %v", err) } @@ -1360,7 +1360,7 @@ func TestRunsControllerRunProjectPromptAttachUnsupportedProvidersDoNotOpenRuntim if len(responses) != 1 || responses[0].GetResult() == nil || responses[0].GetResult().GetSuccess() { t.Fatalf("prompt attach unsupported provider responses = %#v", responses) } - if got := responses[0].GetResult().GetError(); !strings.Contains(got, "prompt attach currently supports codex, claude, opencode, and pi providers only") { + if got := responses[0].GetResult().GetError(); !strings.Contains(got, "prompt attach currently supports codex, claude, opencode, pi, and dsh providers only") { t.Fatalf("prompt attach unsupported provider error = %q", got) } run := responses[0].GetResult().GetRun() diff --git a/pkg/runs/prompt_attach.go b/pkg/runs/prompt_attach.go index 32e4c0269..b79f24e6e 100644 --- a/pkg/runs/prompt_attach.go +++ b/pkg/runs/prompt_attach.go @@ -32,6 +32,19 @@ type preparedPromptInteraction struct { InteractionRuntime InteractionRuntime } +// promptAttachProviders lists the providers whose guest runner can drive the +// interactive `agent-compose-runtime stream` loop. Membership requires the +// runner to resume its provider session between turns, because each turn spawns +// a fresh runPrompt: gemini is absent because GeminiRunner persists no thread +// id and would silently lose the previous turn's context. +var promptAttachProviders = map[string]bool{ + "codex": true, + "claude": true, + "opencode": true, + "pi": true, + "dsh": true, +} + func (c *Controller) preparePromptInteractionRuntime(ctx context.Context, runCtx interactionRunContext) (preparedPromptInteraction, error) { run := runCtx.Run sandbox := runCtx.Sandbox @@ -65,8 +78,8 @@ func (c *Controller) preparePromptInteractionRuntime(ctx context.Context, runCtx if err != nil { return preparedPromptInteraction{}, err } - if agentConfig.Provider != "codex" && agentConfig.Provider != "claude" && agentConfig.Provider != "opencode" && agentConfig.Provider != "pi" { - return preparedPromptInteraction{}, fmt.Errorf("%w: prompt attach currently supports codex, claude, opencode, and pi providers only", domain.ErrUnsupported) + if !promptAttachProviders[agentConfig.Provider] { + return preparedPromptInteraction{}, fmt.Errorf("%w: prompt attach currently supports codex, claude, opencode, pi, and dsh providers only", domain.ErrUnsupported) } systemPrompt, err := c.projectRunAgentSystemPrompt(ctx, run) if err != nil { diff --git a/pkg/runs/prompt_projection.go b/pkg/runs/prompt_projection.go index 8783119c5..fe0fbca8f 100644 --- a/pkg/runs/prompt_projection.go +++ b/pkg/runs/prompt_projection.go @@ -132,54 +132,32 @@ func (p *promptAttachProjector) projectLine(line []byte) ([]RunAttachOutput, *Tr } } +// agentEventText derives the frame's name and human-readable text from a +// runtime agent event. +// +// The runtime publishes provider-neutral events: the frame name is the event +// kind and only text_delta carries transcript text. Reasoning deliberately +// contributes no text, so a consumer reading just that field never splices the +// model's thinking into the answer. func (p *promptAttachProjector) agentEventText(raw json.RawMessage) (string, string) { var event struct { - Type string `json:"type"` + Kind string `json:"kind"` Text string `json:"text"` - Item *struct { - ID string `json:"id"` - Type string `json:"type"` - Text string `json:"text"` - AggregatedOutput string `json:"aggregated_output"` - Command string `json:"command"` - } `json:"item"` + Type string `json:"type"` } if err := json.Unmarshal(raw, &event); err != nil { return "agent_event", "" } - name := firstNonEmpty(event.Type, "agent_event") - if event.Text != "" { - return name, event.Text - } - if event.Item == nil { - return name, "" - } - key := firstNonEmpty(event.Item.ID, name) - var text string - switch event.Item.Type { - case "agent_message", "reasoning": - text = event.Item.Text - case "command_execution": - if event.Item.Command != "" { - commandKey := key + ":command" - if p.itemTexts[commandKey] == "" { - p.itemTexts[commandKey] = event.Item.Command - text += "\n$ " + event.Item.Command + "\n" - } + if event.Kind != "" { + name := event.Kind + if event.Kind == "text_delta" { + return name, event.Text } - text += event.Item.AggregatedOutput - default: return name, "" } - if text == "" { - return name, "" - } - previous := p.itemTexts[key] - p.itemTexts[key] = text - if strings.HasPrefix(text, previous) { - return name, text[len(previous):] - } - return name, text + // Legacy shape: a raw provider event with no neutral kind. Keep the frame + // addressable but contribute nothing to the transcript. + return firstNonEmpty(event.Type, "agent_event"), "" } func (p *promptAttachProjector) appendLogText(text string) error { diff --git a/runtime/javascript/src/agent-event.ts b/runtime/javascript/src/agent-event.ts new file mode 100644 index 000000000..b16884d84 --- /dev/null +++ b/runtime/javascript/src/agent-event.ts @@ -0,0 +1,160 @@ +/** + * Provider-neutral agent event model. Every runner maps its provider's native + * event stream onto this union so consumers do not need six parsers. + * + * Two rules the mappers must honour, both derived from measured provider + * behaviour against the recorded fixtures under + * test/fixtures/providers/: + * + * - A provider that structurally cannot produce a kind emits no event of that + * kind at all. Never emit a placeholder with empty fields: consumers cannot + * distinguish "did not happen" from "this provider never reports it". + * - `inputTokens` always EXCLUDES cached tokens. Providers that report an + * inclusive count (codex, gemini) subtract before emitting. + */ + +/** Tool categories, mirroring ACP's ToolKind minus its editor-only `switch_mode`. */ +export type ToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "other"; + +export type AgentStopReason = "stop" | "tool_use" | "max_tokens" | "cancelled" | "error"; + +export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; + +/** + * Which aggregation level a usage record covers. Providers disagree: codex + * reports per turn, gemini per run, the rest per step. Consumers must not sum + * records of differing scope. + */ +export type UsageScope = "step" | "turn" | "run"; + +export interface FileChange { + path: string; + kind: "add" | "delete" | "update"; +} + +export interface TodoItem { + text: string; + completed: boolean; +} + +export type AgentEvent = + | { kind: "step_start"; step?: number } + | { kind: "step_end"; step?: number; stopReason?: AgentStopReason; rawStopReason?: string } + | { kind: "text_delta"; step?: number; blockIndex?: number; text: string } + | { kind: "reasoning_delta"; step?: number; blockIndex?: number; text: string } + | { + kind: "tool_call"; + step?: number; + parentToolUseId?: string; + id: string; + name: string; + toolKind: ToolKind; + status: ToolCallStatus; + input?: unknown; + /** Present when toolKind is "execute". */ + command?: string; + exitCode?: number; + /** Present when the call is a patch application. */ + changes?: FileChange[]; + } + | { + kind: "tool_result"; + step?: number; + parentToolUseId?: string; + id: string; + ok: boolean; + output?: string; + error?: string; + } + | { kind: "todo"; items: TodoItem[] } + | { + kind: "usage"; + step?: number; + scope: UsageScope; + model?: string; + /** Always excludes cached tokens; see the module comment. */ + inputTokens: number; + /** Includes reasoning tokens, matching every provider's own convention. */ + outputTokens: number; + reasoningTokens?: number; + cachedTokens?: number; + cacheWriteTokens?: number; + costUsd?: number; + } + | { + kind: "retry"; + reason: "rate_limit" | "overloaded" | "network" | "other"; + attempt: number; + maxAttempts?: number; + message?: string; + } + | { kind: "compaction"; phase: "start" | "end" } + | { + kind: "error"; + severity: "warning" | "error" | "fatal"; + code?: string; + retryable?: boolean; + message: string; + }; + +/** Sink a runner calls for each mapped event. Sequencing is the sink's job. */ +export type AgentEventSink = (event: AgentEvent) => void; + +const shellToolNames = new Set([ + "bash", + "shell", + "run_shell_command", + "run_command", + "execute_command", + "terminal", +]); +const readToolNames = new Set(["read", "read_file", "view", "cat", "read_many_files"]); +const editToolNames = new Set(["write", "edit", "write_file", "replace", "apply_patch", "str_replace", "multiedit"]); +const searchToolNames = new Set(["grep", "glob", "search", "search_file_content", "find"]); +const fetchToolNames = new Set(["fetch", "web_fetch", "web_search", "google_web_search", "webfetch", "websearch"]); + +/** + * Classify a provider tool name. Only codex separates shell and patch calls at + * the protocol level; every other provider reports them as ordinary tools, so + * the name is all we have. Cross-provider counting must therefore key on + * `kind === "tool_call"`, never on the resulting ToolKind. + */ +export function toolKindForName(name: string): ToolKind { + const normalized = String(name || "").trim().toLowerCase(); + if (!normalized) { + return "other"; + } + if (shellToolNames.has(normalized)) return "execute"; + if (readToolNames.has(normalized)) return "read"; + if (editToolNames.has(normalized)) return "edit"; + if (searchToolNames.has(normalized)) return "search"; + if (fetchToolNames.has(normalized)) return "fetch"; + if (normalized === "delete" || normalized === "rm") return "delete"; + if (normalized === "move" || normalized === "mv" || normalized === "rename") return "move"; + if (normalized.startsWith("think") || normalized === "sequentialthinking") return "think"; + return "other"; +} + +/** Coerce a tool result payload into the string shape `tool_result.output` expects. */ +export function toolOutputText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/runtime/javascript/src/interactive.ts b/runtime/javascript/src/interactive.ts index 18ecccc5c..4504588f0 100644 --- a/runtime/javascript/src/interactive.ts +++ b/runtime/javascript/src/interactive.ts @@ -8,8 +8,10 @@ import { import { stringEnv } from "./env.js"; import { warn } from "./mpi.js"; import { buildPromptRuntimeOptions } from "./prompt.js"; +import type { AgentEvent } from "./agent-event.js"; import { ClaudeRunner } from "./runners/claude.js"; import { CodexRunner } from "./runners/codex.js"; +import { DshRunner } from "./runners/dsh.js"; import { OpenCodeRunner } from "./runners/opencode.js"; import { PiRunner } from "./runners/pi.js"; import { readStoredThread, writeStoredThread } from "./session-state.js"; @@ -22,6 +24,8 @@ export interface InteractiveStartOptions { workspace?: string; home?: string; model?: string; + effort?: "low" | "medium" | "high" | "xhigh" | "max"; + skills?: string[]; outputSchemaFile?: string; abortController?: AbortController; } @@ -119,9 +123,9 @@ export class CodexInteractiveSession implements InteractiveSession { : undefined; const { events } = await this.thread.runStreamed(message, turnOptions); for await (const event of events) { - const sdkEvent = event as Record; - this.emit("agent_event", { event: sdkEvent }); - this.runner.handleEvent(sdkEvent, this.result); + // The runner's onEvent sink publishes the neutral events; the raw SDK + // event is no longer forwarded verbatim. + this.runner.handleEvent(event as Record, this.result); } } catch (error) { if (!this.options.abortController?.signal.aborted) { @@ -167,6 +171,20 @@ export class CodexInteractiveSession implements InteractiveSession { } } + +/** + * Wrap the runner's neutral events into `agent_event` frames. + * + * The frame carries only the event; the daemon derives the frame name and the + * transcript text from its `kind` (see promptAttachProjector.agentEventText), + * so there is one source of truth for both. + */ +function agentEventEmitter(emit: EmitInteractiveFrame): (event: AgentEvent) => void { + return (event) => { + emit("agent_event", { event }); + }; +} + interface PromptTurnRunner { runPrompt(message: string): Promise; } @@ -184,7 +202,7 @@ class PromptRunnerInteractiveSession implements InteractiveSession { private readonly emit: EmitInteractiveFrame, createRunner: (writer: TranscriptTextWriter) => PromptTurnRunner, ) { - this.writer = new InteractiveTextWriter(provider, emit); + this.writer = new InteractiveTextWriter(); this.runner = createRunner(this.writer); this.result = { provider, @@ -266,34 +284,21 @@ class BufferedTextWriter implements TextWriter { } } -class InteractiveTextWriter extends BufferedTextWriter implements TranscriptTextWriter { - constructor( - private readonly provider: Provider, - private readonly emit: EmitInteractiveFrame, - ) { - super(); - } - - override write(text: string): void { - if (!text) { - return; - } - super.write(text); - this.emit("agent_event", { - event: { - type: "output", - provider: this.provider, - text, - }, - }); - } -} +/** + * Transcript accumulator for the prompt-runner sessions. It no longer + * synthesises `agent_event` frames: structured events now come from the + * runner's own `onEvent` sink, so the writer is purely a text buffer. + */ +class InteractiveTextWriter extends BufferedTextWriter implements TranscriptTextWriter {} export async function createInteractiveSession( startOptions: InteractiveStartOptions, emit: EmitInteractiveFrame, ): Promise { - const options = await buildPromptRuntimeOptions(startOptions); + const base = await buildPromptRuntimeOptions(startOptions); + // One sink for every provider: the runners publish neutral events, the + // session classes no longer synthesise frames of their own. + const options = { ...base, onEvent: agentEventEmitter(emit) }; let session: InteractiveSession; switch (options.provider) { case "codex": @@ -323,6 +328,14 @@ export async function createInteractiveSession( (writer) => new PiRunner(options, writer), ); break; + case "dsh": + session = new PromptRunnerInteractiveSession( + "dsh", + options, + emit, + (writer) => new DshRunner(options, writer), + ); + break; default: throw new UnsupportedProviderError(options.provider); } diff --git a/runtime/javascript/src/runners/claude.ts b/runtime/javascript/src/runners/claude.ts index 79b823ec0..df67f6e28 100644 --- a/runtime/javascript/src/runners/claude.ts +++ b/runtime/javascript/src/runners/claude.ts @@ -4,10 +4,13 @@ import { uniqueDirectories } from "../paths.js"; import { readStoredThread, writeStoredThread } from "../session-state.js"; import { jsonString } from "../text.js"; import { TranscriptWriter, type TranscriptTextWriter } from "../transcript.js"; +import type { AgentEvent } from "../agent-event.js"; +import { toolKindForName, toolOutputText } from "../agent-event.js"; import type { AgentResult, RunnerOptions, StoredThread } from "../types.js"; import { cancellationRequested } from "../shutdown.js"; type PendingToolUse = { + id: string; name: string; partialJson: string; }; @@ -77,6 +80,11 @@ function toClaudeMCPConfig(config: Record | undefined): Record< export class ClaudeRunner { private readonly pendingToolUses = new Map(); + private step = 0; + + private emit(event: AgentEvent): void { + this.options.onEvent?.(event); + } constructor( private readonly options: RunnerOptions, @@ -123,11 +131,154 @@ export class ClaudeRunner { }; } + /** + * Map one partial-message stream event. Claude does not label model calls, so + * `message_start` opens a step and `message_stop` closes it. Tool results + * arrive later as a top-level `user` message (see emitTopLevel), i.e. after + * the step that requested them has already ended. + */ + private emitStreamEvent(event: Record): void { + const index = typeof event.index === "number" ? event.index : undefined; + if (event.type === "message_start") { + this.step += 1; + this.emit({ kind: "step_start", step: this.step }); + return; + } + if (event.type === "content_block_start") { + const block = event.content_block as Record | undefined; + if (block?.type === "tool_use" && typeof block.name === "string") { + this.emit({ + kind: "tool_call", + step: this.step, + id: String(block.id || ""), + name: block.name, + toolKind: toolKindForName(block.name), + status: "in_progress", + }); + } + return; + } + if (event.type === "content_block_delta") { + const delta = event.delta as Record | undefined; + if (delta?.type === "text_delta" && typeof delta.text === "string") { + this.emit({ kind: "text_delta", step: this.step, blockIndex: index, text: delta.text }); + } else if (delta?.type === "thinking_delta" && typeof delta.thinking === "string") { + this.emit({ kind: "reasoning_delta", step: this.step, blockIndex: index, text: delta.thinking }); + } + return; + } + if (event.type === "message_delta") { + const usage = event.usage as Record | undefined; + if (usage) { + const details = usage.output_tokens_details as Record | undefined; + this.emit({ + kind: "usage", + step: this.step, + scope: "step", + // Claude reports input_tokens exclusive of cache already, so no + // subtraction here (unlike codex/gemini). + inputTokens: Number(usage.input_tokens ?? 0), + outputTokens: Number(usage.output_tokens ?? 0), + reasoningTokens: typeof details?.thinking_tokens === "number" ? details.thinking_tokens : undefined, + cachedTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined, + cacheWriteTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined, + }); + } + return; + } + if (event.type === "message_stop") { + this.emit({ kind: "step_end", step: this.step }); + } + } + + /** Map the top-level SDKMessage types that are not partial-message events. */ + emitTopLevel(message: Record): void { + if (message.type === "user") { + const payload = message.message as Record | undefined; + const content = Array.isArray(payload?.content) ? payload.content : []; + const parentToolUseId = typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : undefined; + for (const entry of content) { + const block = entry as Record; + if (block?.type !== "tool_result") { + continue; + } + const isError = block.is_error === true; + this.emit({ + kind: "tool_result", + step: this.step, + ...(parentToolUseId ? { parentToolUseId } : {}), + id: String(block.tool_use_id || ""), + ok: !isError, + output: toolOutputText(block.content), + ...(isError ? { error: "tool error" } : {}), + }); + } + return; + } + if (message.type === "assistant" && typeof message.error === "string") { + this.emit({ + kind: "error", + severity: "error", + code: message.error, + retryable: ["rate_limit", "overloaded", "server_error"].includes(message.error), + message: message.error, + }); + return; + } + if (message.type === "system" && message.subtype === "api_retry") { + this.emit({ + kind: "retry", + reason: "other", + attempt: Number(message.attempt ?? 0), + message: typeof message.error === "string" ? message.error : undefined, + }); + return; + } + if (message.type === "system" && message.subtype === "compact_boundary") { + this.emit({ kind: "compaction", phase: "end" }); + return; + } + if (message.type === "result") { + const usage = message.usage as Record | undefined; + // Emit usage only when the result actually reported it: a record of all + // zeros is indistinguishable from a genuinely free turn. + if (usage) { + const details = usage.output_tokens_details as Record | undefined; + const modelUsage = (message.modelUsage || {}) as Record; + this.emit({ + kind: "usage", + scope: "run", + model: Object.keys(modelUsage)[0], + inputTokens: Number(usage.input_tokens ?? 0), + outputTokens: Number(usage.output_tokens ?? 0), + reasoningTokens: typeof details?.thinking_tokens === "number" ? details.thinking_tokens : undefined, + cachedTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined, + cacheWriteTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined, + costUsd: typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined, + }); + } + const stopReason = typeof message.stop_reason === "string" ? message.stop_reason : undefined; + this.emit({ + kind: "step_end", + stopReason: stopReason === "end_turn" ? "stop" : stopReason === "max_tokens" ? "max_tokens" : undefined, + rawStopReason: stopReason, + }); + if (message.subtype !== "success") { + this.emit({ + kind: "error", + severity: "fatal", + message: typeof message.result === "string" && message.result.trim() ? message.result : "claude execution failed", + }); + } + } + } + handleStreamEvent(message: Record): void { const event = message.event as Record | undefined; if (!event || typeof event !== "object") { return; } + this.emitStreamEvent(event); if (event.type === "content_block_start") { const block = event.content_block as Record | undefined; if (typeof block?.name === "string" && block.name) { @@ -140,6 +291,7 @@ export class ClaudeRunner { } if (input && typeof input === "object") { this.pendingToolUses.set(contentBlockKey(event, String(block.id ?? this.pendingToolUses.size)), { + id: String(block.id ?? ""), name: block.name, partialJson: "", }); @@ -154,6 +306,26 @@ export class ClaudeRunner { const key = contentBlockKey(event); const pending = this.pendingToolUses.get(key); if (pending) { + // The tool's arguments only become complete here, so re-emit the call + // with its parsed input rather than leaving consumers with the stub + // published at content_block_start. + let input: unknown; + try { + input = pending.partialJson.trim() ? JSON.parse(pending.partialJson) : {}; + } catch { + input = pending.partialJson; + } + this.emit({ + kind: "tool_call", + step: this.step, + // pending.id is the tool_use id the matching tool_result will carry; + // `key` is only the content-block index and would not correlate. + id: pending.id || key, + name: pending.name, + toolKind: toolKindForName(pending.name), + status: "completed", + input, + }); this.pendingToolUses.delete(key); this.writer.line(`\n[tool:${pending.name}]`); if (pending.partialJson.trim()) { @@ -211,10 +383,16 @@ export class ClaudeRunner { messages: for await (const rawMessage of stream) { const message = rawMessage as Record; result.threadId = String(message.session_id || result.threadId); + this.emitTopLevel(message); switch (message.type) { case "stream_event": this.handleStreamEvent(message); break; + case "user": + // Tool results reach the SDK as a user-role message. Nothing else + // in the stream carries them, so without this branch the runner + // sees every tool's input and none of its output. + break; case "assistant": { if (!result.finalText) { const assistantMessage = message.message as Record | undefined; diff --git a/runtime/javascript/src/runners/codex.ts b/runtime/javascript/src/runners/codex.ts index bed3628d3..6c9c112e4 100644 --- a/runtime/javascript/src/runners/codex.ts +++ b/runtime/javascript/src/runners/codex.ts @@ -11,6 +11,8 @@ import { uniqueDirectories } from "../paths.js"; import { readStoredThread, writeStoredThread } from "../session-state.js"; import { extractText, jsonString } from "../text.js"; import { appendDelta, TranscriptWriter, type TextWriter } from "../transcript.js"; +import type { AgentEvent, AgentEventSink } from "../agent-event.js"; +import { toolOutputText } from "../agent-event.js"; import type { AgentResult, RunnerOptions } from "../types.js"; import { cancellationRequested } from "../shutdown.js"; @@ -54,6 +56,93 @@ export class CodexRunner { return this.writer.transcript(); } + private readonly emittedText = new Map(); + + /** Codex re-sends the whole text on every item update; emit only the delta. */ + private emitTextDelta(kind: "text_delta" | "reasoning_delta", key: string, next: string): void { + const previous = this.emittedText.get(key) || ""; + if (next === previous) { + return; + } + const delta = next.startsWith(previous) ? next.slice(previous.length) : next; + this.emittedText.set(key, next); + if (delta) { + this.emit({ kind, text: delta }); + } + } + + private emit(event: AgentEvent): void { + const sink: AgentEventSink | undefined = this.options.onEvent; + sink?.(event); + } + + /** + * Codex is the only provider that separates shell execution and patch + * application from ordinary tool calls at the protocol level, so the + * ToolKind comes from the item type rather than the tool name. + */ + private emitCommandEvents(item: Record & { id: string }): void { + const status = String(item.status || "in_progress"); + const exitCode = typeof item.exit_code === "number" ? item.exit_code : undefined; + this.emit({ + kind: "tool_call", + id: item.id, + name: "shell", + toolKind: "execute", + status: status === "completed" ? "completed" : status === "failed" ? "failed" : "in_progress", + command: String(item.command || ""), + ...(exitCode === undefined ? {} : { exitCode }), + }); + if (status !== "in_progress") { + this.emit({ + kind: "tool_result", + id: item.id, + ok: exitCode === 0, + output: String(item.aggregated_output || ""), + }); + } + } + + private emitFileChangeEvents(item: Record & { id: string }): void { + const status = String(item.status || ""); + this.emit({ + kind: "tool_call", + id: item.id, + name: "apply_patch", + toolKind: "edit", + status: status === "completed" ? "completed" : status === "failed" ? "failed" : "in_progress", + changes: (Array.isArray(item.changes) ? item.changes : []).map((change) => { + const record = change as Record; + return { path: String(record.path || ""), kind: String(record.kind || "update") as "add" | "delete" | "update" }; + }), + }); + if (status === "completed" || status === "failed") { + this.emit({ kind: "tool_result", id: item.id, ok: status === "completed" }); + } + } + + private emitMcpEvents(item: Record & { id: string }): void { + const status = String(item.status || "in_progress"); + this.emit({ + kind: "tool_call", + id: item.id, + name: `${item.server}/${item.tool}`, + toolKind: "other", + status: status === "completed" ? "completed" : status === "failed" ? "failed" : "in_progress", + input: item.arguments, + }); + if (status !== "in_progress") { + const error = item.error as Record | undefined; + this.emit({ + kind: "tool_result", + id: item.id, + ok: status === "completed", + output: toolOutputText((item.result as Record | undefined)?.content), + ...(typeof error?.message === "string" ? { error: error.message } : {}), + }); + } + } + threadOptions(): Record { if (this.options.effort === "max") { throw new Error("Codex runner does not support reasoning effort max"); @@ -156,9 +245,41 @@ export class CodexRunner { result.threadId = String(event.thread_id || result.threadId); return; } + if (event.type === "turn.completed") { + // Codex accounts usage per turn, not per model call, and reports input + // tokens inclusive of the cached prefix; subtract so `inputTokens` means + // the same thing across providers. `cache_write_input_tokens` is present + // on the wire but absent from @openai/codex-sdk's Usage type, so read it + // defensively rather than trusting the declaration. + const usage = event.usage as Record | undefined; + if (!usage) { + return; + } + const input = usage.input_tokens ?? 0; + const cached = usage.cached_input_tokens ?? 0; + this.emit({ + kind: "usage", + scope: "turn", + inputTokens: Math.max(input - cached, 0), + outputTokens: usage.output_tokens ?? 0, + reasoningTokens: usage.reasoning_output_tokens, + cachedTokens: cached, + cacheWriteTokens: usage.cache_write_input_tokens, + }); + return; + } if (event.type === "turn.failed") { const error = event.error as Record | undefined; - throw new Error(String(error?.message || "codex turn failed")); + const message = String(error?.message || "codex turn failed"); + this.emit({ kind: "error", severity: "fatal", message }); + throw new Error(message); + } + if (event.type === "error") { + // A fatal stream error carries no `item`, so the guard below used to drop + // it silently and the turn just ended with no explanation. + const message = String(event.message || "codex stream error"); + this.emit({ kind: "error", severity: "fatal", message }); + throw new Error(message); } if (!event.item || typeof event.item !== "object") { return; @@ -166,6 +287,7 @@ export class CodexRunner { const item = event.item as Record & { id: string; type: string }; switch (item.type) { case "agent_message": + this.emitTextDelta("text_delta", item.id, String(item.text || "")); appendDelta(this.writer, this.itemState as Map, item.id, String(item.text || "")); if (event.type === "item.completed") { const finalText = String(item.text || ""); @@ -176,24 +298,44 @@ export class CodexRunner { } break; case "reasoning": + this.emitTextDelta("reasoning_delta", `reasoning:${item.id}`, String(item.text || "")); appendDelta(this.writer, this.itemState as Map, item.id, String(item.text || "")); break; case "command_execution": + this.emitCommandEvents(item); this.emitCommand(item); break; case "file_change": + this.emitFileChangeEvents(item); this.emitFileChange(item); break; case "mcp_tool_call": + this.emitMcpEvents(item); this.emitMcp(item); break; case "web_search": + this.emit({ + kind: "tool_call", + id: item.id, + name: "web_search", + toolKind: "fetch", + status: event.type === "item.completed" ? "completed" : "in_progress", + input: { query: webSearchQuery(item) }, + }); this.emitWebSearch(item, event.type); break; case "todo_list": + this.emit({ + kind: "todo", + items: (Array.isArray(item.items) ? item.items : []).map((entry) => { + const record = entry as Record; + return { text: String(record.text || ""), completed: Boolean(record.completed) }; + }), + }); this.emitTodo(item); break; case "error": + this.emit({ kind: "error", severity: "error", message: String(item.message || "codex item error") }); this.writer.line(String(item.message || "codex item error")); break; default: diff --git a/runtime/javascript/src/runners/dsh.ts b/runtime/javascript/src/runners/dsh.ts index 3e3cafa49..e54143298 100644 --- a/runtime/javascript/src/runners/dsh.ts +++ b/runtime/javascript/src/runners/dsh.ts @@ -7,6 +7,8 @@ import { extractText } from "../text.js"; import { flattenEnvMap, type RuntimeMCPServer } from "../mcp-config.js"; import { readStoredThread, writeStoredThread } from "../session-state.js"; import { TranscriptWriter, type TranscriptTextWriter } from "../transcript.js"; +import type { AgentEvent } from "../agent-event.js"; +import { toolKindForName } from "../agent-event.js"; import type { AgentResult, RunnerOptions } from "../types.js"; import { cancellationRequested } from "../shutdown.js"; import { waitForChildExit } from "../child-process.js"; @@ -108,11 +110,14 @@ export class DshRunner { } else { delete env.DSH_RESUME; } + // DSH_MODEL is the one conditional DSH_* var with a legitimate inherited + // value: the daemon's facade config sets it to the model it resolved and + // minted the token against. Deleting it when no --model was passed would + // drop that and let the profile fall back to its hardcoded default, so + // only overwrite when this invocation actually names a model. const modelName = dshModelName(this.options.model); if (modelName) { env.DSH_MODEL = modelName; - } else { - delete env.DSH_MODEL; } const effort = dshReasoningEffort(this.options.effort); if (effort) { @@ -203,7 +208,136 @@ export class DshRunner { } } + private emit(event: AgentEvent): void { + this.options.onEvent?.(event); + } + + /** + * Map one DSH SessionEvent onto the neutral model. + * + * DSH reports the same per-step usage twice — once as an `assistant/chunk` + * of type "usage" and once on `assistant/message.usage`, byte-identical. + * Only the latter is mapped; taking both doubles every token count. + */ + private emitNeutral(event: Record): void { + const type = String(event.type || ""); + const data = recordValue(event.data); + const step = typeof data?.step === "number" ? data.step : undefined; + if (type === "step/start") { + this.emit({ kind: "step_start", step }); + return; + } + if (type === "step/end") { + this.emit({ kind: "step_end", step }); + return; + } + if (type === "assistant/chunk") { + const chunk = recordValue(data?.chunk); + const chunkType = String(chunk?.type || ""); + const index = typeof chunk?.index === "number" ? chunk.index : undefined; + if (chunkType === "text-delta" && typeof chunk?.text === "string" && chunk.text) { + this.emit({ kind: "text_delta", step, blockIndex: index, text: chunk.text }); + } else if (chunkType === "reasoning-delta" && typeof chunk?.text === "string" && chunk.text) { + this.emit({ kind: "reasoning_delta", step, blockIndex: index, text: chunk.text }); + } + return; + } + if (type === "assistant/message") { + const usage = recordValue(data?.usage); + if (usage) { + this.emit({ + kind: "usage", + step, + scope: "step", + inputTokens: Number(usage.inputTokens ?? 0), + outputTokens: Number(usage.outputTokens ?? 0), + reasoningTokens: typeof usage.reasoningTokens === "number" ? usage.reasoningTokens : undefined, + cachedTokens: typeof usage.cacheReadTokens === "number" ? usage.cacheReadTokens : undefined, + }); + } + return; + } + if (type === "tool/call") { + const name = firstString(data, "name"); + let input: unknown; + try { + input = JSON.parse(String(data?.arguments ?? "null")); + } catch { + input = data?.arguments; + } + this.emit({ + kind: "tool_call", + step, + id: firstString(data, "callId"), + name, + toolKind: toolKindForName(name), + status: "in_progress", + input, + }); + return; + } + if (type === "tool/result") { + const message = recordValue(data?.message); + const blocks = Array.isArray(message?.content) ? message.content : []; + const results = blocks.filter((block): block is Record => isRecord(block) && block.type === "tool-result"); + const text = results + .flatMap((block) => (Array.isArray(block.content) ? block.content : [])) + .filter((entry): entry is Record => isRecord(entry) && entry.type === "text") + .map((entry) => String(entry.text || "")) + .join(""); + const errorDetail = recordValue(data?.error); + const failed = Boolean(errorDetail) || results.some((block) => block.isError === true); + this.emit({ + kind: "tool_result", + step, + id: firstString(results[0], "toolCallId") || firstString(recordValue(message?.source), "callId"), + ok: !failed, + output: text, + ...(errorDetail ? { error: firstString(errorDetail, "message") || "dsh tool error" } : {}), + }); + return; + } + if (type === "todo/write") { + const todos = Array.isArray(data?.todos) ? data.todos : []; + this.emit({ + kind: "todo", + items: todos.map((entry) => { + const record = entry as Record; + return { text: String(record.text ?? record.content ?? ""), completed: String(record.status || "") === "completed" || record.completed === true }; + }), + }); + return; + } + if (type === "turn/end") { + const reason = recordValue(data?.reason); + const kind = firstString(reason, "kind") || "completed"; + this.emit({ + kind: "step_end", + stopReason: kind === "completed" ? "stop" : kind === "cancelled" ? "cancelled" : kind === "error" ? "error" : undefined, + rawStopReason: kind, + }); + if (kind === "error") { + const errorDetail = recordValue(reason?.error); + this.emit({ + kind: "error", + severity: "fatal", + code: firstString(errorDetail, "code") || undefined, + message: firstString(errorDetail, "message") || "unknown dsh error", + }); + } + return; + } + if (type.startsWith("compaction/")) { + this.emit({ kind: "compaction", phase: type.endsWith("start") ? "start" : "end" }); + return; + } + if (type.startsWith("llm/retry")) { + this.emit({ kind: "retry", reason: "other", attempt: 0 }); + } + } + handleEvent(event: Record, result: AgentResult): void { + this.emitNeutral(event); // event is a DSH SessionEvent: {type, seq, time, data}. See // packages/core/session/src/types.ts in deepseek-harness. const type = String(event.type || ""); diff --git a/runtime/javascript/src/runners/gemini.ts b/runtime/javascript/src/runners/gemini.ts index 8cda7a955..4b39c4266 100644 --- a/runtime/javascript/src/runners/gemini.ts +++ b/runtime/javascript/src/runners/gemini.ts @@ -5,6 +5,8 @@ import readline from "node:readline"; import { flattenEnvMap } from "../mcp-config.js"; import { extractText, jsonString } from "../text.js"; import { TranscriptWriter } from "../transcript.js"; +import type { AgentEvent } from "../agent-event.js"; +import { toolKindForName } from "../agent-event.js"; import type { AgentResult, RunnerOptions } from "../types.js"; import { cancellationRequested } from "../shutdown.js"; import { waitForChildExit } from "../child-process.js"; @@ -14,6 +16,100 @@ export class GeminiRunner { constructor(private readonly options: RunnerOptions) {} + private emit(event: AgentEvent): void { + this.options.onEvent?.(event); + } + + /** + * Map one `--output-format stream-json` event. + * + * The vocabulary is exactly six types: init, message, tool_use, tool_result, + * error, result. Gemini reports no step boundaries and no reasoning on this + * channel (thought chunks exist only under --experimental-acp), so + * `step_start` / `step_end`(per call) / `reasoning_delta` never appear. + * + * Field names here intentionally differ from the legacy transcript branch + * below, which reads `name`/`result`/`response` — none of which the CLI + * actually sends. Fixing that path changes existing finalText behaviour and + * is tracked separately; see the design doc §4.2. + */ + handleEvent(event: Record, result: AgentResult): void { + const type = String(event?.type || ""); + if (type === "message") { + // role is "user" for the echoed prompt; only assistant text is agent output. + if (String(event.role || "") !== "assistant") { + return; + } + const text = typeof event.content === "string" ? event.content : extractText(event.content); + if (text) { + this.emit({ kind: "text_delta", text }); + } + return; + } + if (type === "tool_use") { + const name = String(event.tool_name || event.toolName || event.name || "tool"); + this.emit({ + kind: "tool_call", + id: String(event.tool_id || event.toolId || name), + name, + toolKind: toolKindForName(name), + status: "in_progress", + input: event.parameters, + }); + return; + } + if (type === "tool_result") { + const errorDetail = event.error as Record | undefined; + const ok = String(event.status || "") !== "error" && !errorDetail; + this.emit({ + kind: "tool_result", + id: String(event.tool_id || event.toolId || ""), + ok, + output: typeof event.output === "string" ? event.output : undefined, + ...(errorDetail ? { error: String(errorDetail.message || "tool error") } : {}), + }); + return; + } + if (type === "error") { + const severity = String(event.severity || "error"); + this.emit({ + kind: "error", + severity: severity === "warning" ? "warning" : "error", + message: String(event.message || "gemini error"), + }); + return; + } + if (type === "result") { + const stats = (event.stats || {}) as Record; + const models = (stats.models || {}) as Record; + // `stats.input_tokens` counts cached tokens too; `stats.input` is the + // uncached remainder, which is what inputTokens means here. + this.emit({ + kind: "usage", + scope: "run", + model: Object.keys(models)[0], + inputTokens: Number(stats.input ?? 0), + outputTokens: Number(stats.output_tokens ?? 0), + cachedTokens: typeof stats.cached === "number" ? stats.cached : undefined, + }); + const errorDetail = event.error as Record | undefined; + this.emit({ + kind: "step_end", + stopReason: errorDetail ? "error" : "stop", + rawStopReason: String(event.status || ""), + }); + if (errorDetail) { + this.emit({ + kind: "error", + severity: "fatal", + code: typeof errorDetail.type === "string" ? errorDetail.type : undefined, + message: String(errorDetail.message || "gemini execution failed"), + }); + } + void result; + } + } + async writeSettingsFile(): Promise { const mcps = this.options.mcpConfig as Record> | undefined; const geminiDir = path.join(this.options.home, ".gemini"); @@ -104,6 +200,7 @@ export class GeminiRunner { } catch { continue; } + this.handleEvent(event, result); const eventType = String(event?.type || ""); if (eventType === "init") { result.threadId = String(event.sessionId || event.session_id || result.threadId); diff --git a/runtime/javascript/src/runners/opencode.ts b/runtime/javascript/src/runners/opencode.ts index 83723aa3d..f5848a8fb 100644 --- a/runtime/javascript/src/runners/opencode.ts +++ b/runtime/javascript/src/runners/opencode.ts @@ -7,6 +7,8 @@ import { formatError } from "../errors.js"; import { readStoredThread, writeStoredThread } from "../session-state.js"; import { extractText, jsonString } from "../text.js"; import { TranscriptWriter, type TranscriptTextWriter } from "../transcript.js"; +import type { AgentEvent } from "../agent-event.js"; +import { toolKindForName, toolOutputText } from "../agent-event.js"; import type { AgentResult, RunnerOptions, StoredThread } from "../types.js"; import { flattenEnvMap } from "../mcp-config.js"; import { cancellationRequested } from "../shutdown.js"; @@ -14,6 +16,7 @@ import { waitForChildExit } from "../child-process.js"; export class OpenCodeRunner { private skillsConfigDir?: string; + private step = 0; private providerMessageID = ""; private providerMessageText = ""; @@ -127,7 +130,99 @@ export class OpenCodeRunner { } } + private emit(event: AgentEvent): void { + this.options.onEvent?.(event); + } + + /** + * Map one `opencode run --format json` event. The CLI emits only six types + * (`step_start` / `step_finish` / `text` / `reasoning` / `tool_use` / `error`), + * all wrapped as `{type, timestamp, sessionID, part}`; the other branches + * below are kept for older CLI shapes the transcript path still tolerates. + */ + private emitNeutral(event: Record): void { + const type = String(event.type || event.event || ""); + const part = isRecord(event.part) ? event.part : {}; + switch (type) { + case "step_start": + this.step += 1; + this.emit({ kind: "step_start", step: this.step }); + return; + case "step_finish": { + const tokens = isRecord(part.tokens) ? part.tokens as Record : undefined; + const cache = isRecord(tokens?.cache) ? tokens.cache as Record : {}; + if (tokens) { + this.emit({ + kind: "usage", + step: this.step, + scope: "step", + inputTokens: Number(tokens.input ?? 0), + outputTokens: Number(tokens.output ?? 0), + reasoningTokens: numberOrUndefined(tokens.reasoning), + cachedTokens: numberOrUndefined(cache.read), + cacheWriteTokens: numberOrUndefined(cache.write), + costUsd: numberOrUndefined(part.cost), + }); + } + this.emit({ kind: "step_end", step: this.step, rawStopReason: stringField(part, "reason") || undefined }); + return; + } + case "text": + // opencode publishes a text part only once it is complete, so this is a + // whole block rather than a token-level delta. + if (typeof part.text === "string" && part.text) { + this.emit({ kind: "text_delta", step: this.step, text: part.text }); + } + return; + case "reasoning": + if (typeof part.text === "string" && part.text) { + this.emit({ kind: "reasoning_delta", step: this.step, text: part.text }); + } + return; + case "tool_use": { + const state = isRecord(part.state) ? part.state as Record : {}; + const status = String(state.status || ""); + const name = String(part.tool || "tool"); + const id = String(part.id || name); + this.emit({ + kind: "tool_call", + step: this.step, + id, + name, + toolKind: toolKindForName(name), + status: status === "completed" ? "completed" : status === "error" ? "failed" : "in_progress", + input: state.input, + }); + if (status === "completed" || status === "error") { + this.emit({ + kind: "tool_result", + step: this.step, + id, + ok: status === "completed", + output: toolOutputText(state.output), + ...(status === "error" ? { error: toolOutputText(state.error) || "tool error" } : {}), + }); + } + return; + } + case "error": { + const error = isRecord(event.error) ? event.error as Record : {}; + const data = isRecord(error.data) ? error.data as Record : {}; + this.emit({ + kind: "error", + severity: "fatal", + code: stringField(error, "name") || undefined, + message: stringField(data, "message") || stringField(error, "name") || "opencode error", + }); + return; + } + default: + return; + } + } + handleEvent(event: Record, result: AgentResult): void { + this.emitNeutral(event); const providerThreadID = stringField(event, "sessionID", "sessionId", "session_id"); if (providerThreadID) { result.threadId = providerThreadID; @@ -313,6 +408,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + function uniqueStrings(values: string[]): string[] { return Array.from(new Set(values)); } diff --git a/runtime/javascript/src/runners/pi.ts b/runtime/javascript/src/runners/pi.ts index 5f8f210ae..cb9766dd7 100644 --- a/runtime/javascript/src/runners/pi.ts +++ b/runtime/javascript/src/runners/pi.ts @@ -5,6 +5,8 @@ import readline from "node:readline"; import { extractText, jsonString } from "../text.js"; import { readStoredThread, writeStoredThread } from "../session-state.js"; import { TranscriptWriter, type TranscriptTextWriter } from "../transcript.js"; +import type { AgentEvent } from "../agent-event.js"; +import { toolKindForName, toolOutputText } from "../agent-event.js"; import type { AgentResult, RunnerOptions } from "../types.js"; import { piMCPAdapterExtension, writePiMCPConfig } from "./pi-mcp.js"; import { cancellationRequested } from "../shutdown.js"; @@ -15,6 +17,7 @@ const maxDiagnosticBytes = 64 * 1024; export class PiRunner { private reportedError: Error | null = null; private latestAssistantError: Error | null = null; + private step = 0; constructor( private readonly options: RunnerOptions, @@ -149,7 +152,125 @@ export class PiRunner { return args; } + private emit(event: AgentEvent): void { + this.options.onEvent?.(event); + } + + /** + * Map one `pi --mode json` event. Pi calls one model call plus its tool + * executions a "turn" (an `agent_start`/`agent_end` pair wraps several of + * them), which is what every other provider calls a step — hence the + * turn_start -> step_start mapping. + * + * Pi closes the assistant message before running the tools, so tool events + * for a step arrive after that step's `step_end`. Consumers must group by the + * `step` field, never by the step_start/step_end interval. + */ + private emitNeutral(event: Record): void { + const type = String(event.type || ""); + if (type === "turn_start") { + this.step += 1; + this.emit({ kind: "step_start", step: this.step }); + return; + } + if (type === "message_update") { + const update = recordValue(event.assistantMessageEvent) || recordValue(event.assistant_message_event); + const updateType = String(update?.type || ""); + const text = firstString(update, "delta", "text"); + const blockIndex = typeof update?.contentIndex === "number" ? update.contentIndex : undefined; + if (updateType === "text_delta" && text) { + this.emit({ kind: "text_delta", step: this.step, blockIndex, text }); + } else if (updateType === "thinking_delta" && text) { + this.emit({ kind: "reasoning_delta", step: this.step, blockIndex, text }); + } + return; + } + if (type === "message_end") { + const message = recordValue(event.message); + if (String(message?.role || "") !== "assistant") { + return; + } + const usage = recordValue(message?.usage); + if (usage) { + const cost = recordValue(usage.cost); + this.emit({ + kind: "usage", + step: this.step, + scope: "step", + model: firstString(message, "model") || undefined, + inputTokens: Number(usage.input ?? 0), + outputTokens: Number(usage.output ?? 0), + reasoningTokens: typeof usage.reasoning === "number" ? usage.reasoning : undefined, + cachedTokens: typeof usage.cacheRead === "number" ? usage.cacheRead : undefined, + cacheWriteTokens: typeof usage.cacheWrite === "number" ? usage.cacheWrite : undefined, + costUsd: typeof cost?.total === "number" ? cost.total : undefined, + }); + } + const stopReason = firstString(message, "stopReason", "stop_reason"); + this.emit({ + kind: "step_end", + step: this.step, + stopReason: stopReason === "toolUse" ? "tool_use" : stopReason === "stop" ? "stop" : stopReason === "error" ? "error" : undefined, + rawStopReason: firstString(message, "rawStopReason", "raw_stop_reason") || stopReason || undefined, + }); + if (stopReason === "error") { + this.emit({ + kind: "error", + severity: "error", + message: firstString(message, "errorMessage", "error_message") || "pi model error", + }); + } + return; + } + if (type === "tool_execution_start") { + const name = firstString(event, "toolName"); + this.emit({ + kind: "tool_call", + step: this.step, + id: firstString(event, "toolCallId"), + name, + toolKind: toolKindForName(name), + status: "in_progress", + input: event.args, + }); + return; + } + if (type === "tool_execution_end") { + this.emit({ + kind: "tool_result", + step: this.step, + id: firstString(event, "toolCallId"), + ok: !event.isError, + output: toolOutputText(event.result), + ...(event.isError ? { error: "tool error" } : {}), + }); + return; + } + if (type === "auto_retry_start") { + this.emit({ + kind: "retry", + reason: "other", + attempt: Number(event.attempt ?? 0), + maxAttempts: typeof event.maxAttempts === "number" ? event.maxAttempts : undefined, + message: firstString(event, "errorMessage") || undefined, + }); + return; + } + if (type === "compaction_start" || type === "compaction_end") { + this.emit({ kind: "compaction", phase: type === "compaction_start" ? "start" : "end" }); + return; + } + if (type === "error") { + this.emit({ + kind: "error", + severity: "error", + message: extractText(event.error) || extractText(event.message) || jsonString(event), + }); + } + } + handleEvent(event: Record, result: AgentResult): void { + this.emitNeutral(event); const type = String(event.type || ""); if (type === "session") { result.threadId = firstString(event, "id", "sessionId", "session_id") || result.threadId; diff --git a/runtime/javascript/src/stream.ts b/runtime/javascript/src/stream.ts index ce6a5a100..dc9c31eb7 100644 --- a/runtime/javascript/src/stream.ts +++ b/runtime/javascript/src/stream.ts @@ -65,6 +65,8 @@ export async function runStreamCommand(options: RunStreamOptions = {}): Promise< workspace: stringField(frame, "workspace"), home: stringField(frame, "home"), model: stringField(frame, "model"), + effort: effortField(frame), + skills: skillsField(frame), outputSchemaFile: stringField(frame, "outputSchemaFile"), abortController: options.abortController, }, emit); @@ -169,6 +171,24 @@ function emitOutputFrame( emit("output", { source, text: chunk.toString("utf8") }); } +const efforts = new Set(["low", "medium", "high", "xhigh", "max"]); + +/** Read the optional reasoning effort from a start frame. */ +function effortField(frame: StreamFrame): "low" | "medium" | "high" | "xhigh" | "max" | undefined { + const value = stringField(frame, "effort") ?? ""; + return efforts.has(value) ? value as "low" | "medium" | "high" | "xhigh" | "max" : undefined; +} + +/** Read the optional skill names from a start frame. */ +function skillsField(frame: StreamFrame): string[] | undefined { + const value = frame.skills; + if (!Array.isArray(value)) { + return undefined; + } + const names = value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== ""); + return names.length > 0 ? names : undefined; +} + function stringField(frame: StreamFrame, field: string): string | undefined { const value = frame[field]; return typeof value === "string" ? value : undefined; diff --git a/runtime/javascript/src/types.ts b/runtime/javascript/src/types.ts index e50957492..20e489f8a 100644 --- a/runtime/javascript/src/types.ts +++ b/runtime/javascript/src/types.ts @@ -1,3 +1,5 @@ +import type { AgentEventSink } from "./agent-event.js"; + export type Provider = "codex" | "claude" | "gemini" | "opencode" | "pi" | "dsh"; export type RuntimeJsonSchema = Record; export type FinalTextSource = "none" | "provider_message" | "transcript_fallback"; @@ -26,6 +28,11 @@ export interface RunnerOptions { skills?: string[]; outputSchema?: RuntimeJsonSchema; abortController?: AbortController; + /** + * Receives provider-neutral events as the runner maps them. Optional and + * defaulted to a no-op, so the non-attach `prompt` path pays nothing. + */ + onEvent?: AgentEventSink; } export interface StoredThread { diff --git a/runtime/javascript/test/dsh-runner.test.ts b/runtime/javascript/test/dsh-runner.test.ts index 2f405396a..5cfd19fb5 100644 --- a/runtime/javascript/test/dsh-runner.test.ts +++ b/runtime/javascript/test/dsh-runner.test.ts @@ -291,23 +291,48 @@ describe("DshRunner", () => { }); }); - it("clears host-inherited DSH_RESUME/DSH_MODEL/DSH_REASONING_EFFORT/DSH_SKILL_DIRS when this run doesn't set them", async () => { + it("clears inherited DSH_RESUME/DSH_REASONING_EFFORT/DSH_SKILL_DIRS when this run doesn't set them", async () => { vi.stubEnv("DSH_RESUME", "1"); - vi.stubEnv("DSH_MODEL", "host-leaked-model"); vi.stubEnv("DSH_REASONING_EFFORT", "max"); vi.stubEnv("DSH_SKILL_DIRS", "/host/leaked/skills"); const { DshRunner } = await import("../src/runners/dsh.js"); await withTempSession(async (root) => { - // No stored thread (so resume=false), no model/effort/skills configured. + // No stored thread (so resume=false), no effort/skills configured. await new DshRunner(runnerOptions(root, "", "dsh")).runPrompt("prompt"); const env = processState.calls[0].options.env as Record; expect(env.DSH_RESUME).toBeUndefined(); - expect(env.DSH_MODEL).toBeUndefined(); expect(env.DSH_REASONING_EFFORT).toBeUndefined(); expect(env.DSH_SKILL_DIRS).toBeUndefined(); }); }); + it("keeps an inherited DSH_MODEL when the agent names no model", async () => { + // DSH_MODEL is deliberately exempt from the clearing rule the test above + // covers: the daemon's facade config sets it to the model it resolved and + // minted the run's token against, and the runner cannot tell that apart + // from ambient environment. Dropping it would send DSH to the profile's + // hardcoded fallback model, which the facade token does not authorise. + // Unlike DSH_SKILL_DIRS an unexpected value here is not a privilege + // escalation: the facade rejects a model the token is not bound to. + vi.stubEnv("DSH_MODEL", "daemon-resolved-model"); + const { DshRunner } = await import("../src/runners/dsh.js"); + await withTempSession(async (root) => { + await new DshRunner(runnerOptions(root, "", "dsh")).runPrompt("prompt"); + const env = processState.calls[0].options.env as Record; + expect(env.DSH_MODEL).toBe("daemon-resolved-model"); + }); + }); + + it("overrides an inherited DSH_MODEL when the agent does name one", async () => { + vi.stubEnv("DSH_MODEL", "daemon-resolved-model"); + const { DshRunner } = await import("../src/runners/dsh.js"); + await withTempSession(async (root) => { + await new DshRunner({ ...runnerOptions(root, "", "dsh"), model: "default/configured-model" }).runPrompt("prompt"); + const env = processState.calls[0].options.env as Record; + expect(env.DSH_MODEL).toBe("configured-model"); + }); + }); + it("fails fast when DSH_MCP_SERVERS would exceed the exec() argument limit", async () => { const { DshRunner } = await import("../src/runners/dsh.js"); await withTempSession(async (root) => { diff --git a/runtime/javascript/test/fixtures/providers/claude.jsonl b/runtime/javascript/test/fixtures/providers/claude.jsonl new file mode 100644 index 000000000..2666ef073 --- /dev/null +++ b/runtime/javascript/test/fixtures/providers/claude.jsonl @@ -0,0 +1,25 @@ +{"type":"system","subtype":"init","cwd":"/workspace","session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","ListAgents","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskOutput","TaskStop","ToolSearch","WebFetch","WebSearch","Write"],"mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"}],"model":"claude-opus-5","permissionMode":"bypassPermissions","slash_commands":["git-worktree-cow","modern-go-guidelines:use-modern-go","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","run","run-skill-generator","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding"],"terminal_slash_commands":["doctor","color"],"apiKeySource":"none","claude_code_version":"2.1.259","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["git-worktree-cow","modern-go-guidelines:use-modern-go","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","run","run-skill-generator"],"plugins":[{"name":"modern-go-guidelines","path":"/home/agent/.claude/plugins/cache/goland-claude-marketplace/modern-go-guidelines/1.1.1","source":"modern-go-guidelines@goland-claude-marketplace","version":"1.1.1"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"19567772-85c8-4ced-9046-c7be5ac1ec0e","memory_paths":{"auto":"/home/agent/.claude/projects/-private-tmp-claude-501--Users-winter-Developer-agent-compose-1672030f-72fe-4d86-be01-069148e567b9-scratchpad-ws-claude/memory/"},"messaging_socket_path":"/tmp/cc-socks/4166.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"} +{"type":"system","subtype":"status","status":"requesting","session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","uuid":"dfabdc20-862e-44b4-bc1b-562c1d717d7d"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5","id":"msg_011Cefq5eNJ1qyeaLACToxpU","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6630,"cache_read_input_tokens":8028,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6630},"output_tokens":17,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"9b67108d-736e-4436-a5b2-786e402826f6","ttft_ms":1417} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_0171dnkdr7YP7Y8ytnGQjwa7","name":"Bash","input":{},"caller":{"type":"direct"}}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"88e01983-14ba-4c24-aace-2e44701f0d7c"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"7c8a4d38-5fe3-4e3e-8bd1-13eadd333c31"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1788420000,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.38,"resetsAt":1788420000},"seven_day":{"utilization":0.24,"resetsAt":1788469200}}},"uuid":"19805f7f-5292-43cc-8ce2-5dfd765c4b39","session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\": \"printf 'hi\\\\n' > hello.txt && cat hello.txt"}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"b73126bb-0c07-43b3-8515-294312a5ba6e"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\", \"description\": \"Create hello.txt and read it back"}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"e48bfbb1-64d0-445e-b569-7cd08b968757"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"4ebed874-4a79-47bd-aef8-1d48420b1ec8"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cefq5eNJ1qyeaLACToxpU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0171dnkdr7YP7Y8ytnGQjwa7","name":"Bash","input":{"command":"printf 'hi\\n' > hello.txt && cat hello.txt","description":"Create hello.txt and read it back"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6630,"cache_read_input_tokens":8028,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6630},"output_tokens":17,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","uuid":"d9367cc2-d180-4d3e-a308-a5f90336bc9b","timestamp":"2026-09-03T03:54:04.269Z","request_id":"req_011Cefq5d9shxuVDjAKFZwhc"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"bc332a3a-22af-4c71-9079-5bdc6782ee47"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":6630,"cache_read_input_tokens":8028,"output_tokens":100,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":100,"cache_read_input_tokens":8028,"cache_creation_input_tokens":6630,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6630},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"2778d3e2-107f-4edf-ad58-8e6c4099ad04"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"4e896f03-1c50-495a-8a35-80bfded2ec1f"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0171dnkdr7YP7Y8ytnGQjwa7","type":"tool_result","content":"hi","is_error":false}]},"parent_tool_use_id":null,"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","uuid":"7f04f5be-e216-4083-acfc-aec7806f4529","timestamp":"2026-09-03T03:54:05.919Z","tool_use_result":{"stdout":"hi","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"system","subtype":"status","status":"requesting","session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","uuid":"e900f1b5-b270-4a0c-8067-4247b4c47922"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5","id":"msg_011Cefq5vWuEeo56b4RpzQy9","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":131,"cache_read_input_tokens":14658,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":131},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"a016ebb8-bb4a-48c9-b324-a5f26d9717e5","ttft_ms":1053} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"e5ddf7ee-3f5b-4691-8f32-a919bf3a097a"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"h"}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"12f3c789-5c6d-4fbd-b00d-41108b3f054a"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"i"}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"0da089a9-d9c7-44de-91d8-f38d99971752"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cefq5vWuEeo56b4RpzQy9","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":131,"cache_read_input_tokens":14658,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":131},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","uuid":"8bd8746c-72ff-42df-b57e-124d4cfa97e4","timestamp":"2026-09-03T03:54:07.051Z","request_id":"req_011Cefq5ukkv5w5eBSPdYNLL"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"bfea0a18-e13e-4d59-aa88-8e31df7f431b"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":131,"cache_read_input_tokens":14658,"output_tokens":4,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":14658,"cache_creation_input_tokens":131,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":131},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"9470ae5e-fb3f-49b3-9c98-ad66860b0e27"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","parent_tool_use_id":null,"uuid":"80be81a7-e093-404a-801c-890edc19bf1b"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1788420000,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.39,"resetsAt":1788420000},"seven_day":{"utilization":0.24,"resetsAt":1788469200}}},"uuid":"c2096393-9f1b-4865-911c-d9c81b6ed449","session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384"} +{"duration_api_ms":5259,"stop_reason":"end_turn","session_id":"3a8af4d4-4b22-4c3f-b322-0d74d903f384","total_cost_usd":0.08257900000000001,"usage":{"input_tokens":4,"cache_creation_input_tokens":6761,"cache_read_input_tokens":22686,"output_tokens":104,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":6761,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":14658,"cache_creation_input_tokens":131,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":131},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":931,"outputTokens":15,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.001006,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"},"claude-opus-5":{"inputTokens":4,"outputTokens":104,"cacheReadInputTokens":22686,"cacheCreationInputTokens":6761,"webSearchRequests":0,"costUSD":0.081573,"contextWindow":1000000,"maxOutputTokens":64000,"thinkingTokens":0,"canonicalModel":"claude-opus-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"hi","ttft_ms":2354,"type":"result","duration_ms":5196,"uuid":"07445f10-bcd3-4c44-aa2f-9c920c0783d8","ttft_stream_ms":1471,"time_to_request_ms":53,"queued_turn_count":0} diff --git a/runtime/javascript/test/fixtures/providers/codex.jsonl b/runtime/javascript/test/fixtures/providers/codex.jsonl new file mode 100644 index 000000000..dd6de9380 --- /dev/null +++ b/runtime/javascript/test/fixtures/providers/codex.jsonl @@ -0,0 +1,9 @@ +{"type":"thread.started","thread_id":"01a06566-48b9-7d00-9a37-a3ceb39cd2bc"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"I’ll create `hello.txt`, then read it back from the shell."}} +{"type":"item.started","item":{"id":"item_1","type":"file_change","changes":[{"path":"/workspace/hello.txt","kind":"add"}],"status":"in_progress"}} +{"type":"item.completed","item":{"id":"item_1","type":"file_change","changes":[{"path":"/workspace/hello.txt","kind":"add"}],"status":"completed"}} +{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"/bin/zsh -lc 'cat hello.txt'","aggregated_output":"","exit_code":null,"status":"in_progress"}} +{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"/bin/zsh -lc 'cat hello.txt'","aggregated_output":"hi\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"hi"}} +{"type":"turn.completed","usage":{"input_tokens":28604,"cached_input_tokens":2816,"cache_write_input_tokens":0,"output_tokens":201,"reasoning_output_tokens":53}} diff --git a/runtime/javascript/test/fixtures/providers/dsh.jsonl b/runtime/javascript/test/fixtures/providers/dsh.jsonl new file mode 100644 index 000000000..5d7517f4d --- /dev/null +++ b/runtime/javascript/test/fixtures/providers/dsh.jsonl @@ -0,0 +1,95 @@ +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"agent/inbox/spliced","seq":3,"time":1788408001376,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short.\n"}],"source":{"kind":"user"},"role":"user","id":"c662c794-e269-4af1-9840-34eb442bb130"}]}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"turn/start","seq":4,"time":1788408001381,"data":{"turn":1}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"agent/inbox/spliced","seq":5,"time":1788408001397,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"step/start","seq":6,"time":1788408001578,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"user/message","seq":7,"time":1788408001578,"data":{"content":[{"type":"text","text":"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short.\n"}],"source":{"kind":"user"},"role":"user","id":"c662c794-e269-4af1-9840-34eb442bb130"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"user/message","seq":8,"time":1788408001580,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5182b2a5-4603-449e-a05d-1bf564ab2a30"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"session/title","seq":9,"time":1788408001581,"data":{"title":"Create a file named hello.txt","messageSeqs":[7],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"request/header","seq":10,"time":1788408001583,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"You are an AI agent powered by DeepSeek Harness.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nUse the glob tool — not shell find — to discover files by path pattern. A pattern with no \"/\" matches basenames at any depth, so \"*\" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.\n\nUse the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.\n\nUse goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"create_goal","description":"Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The concrete completion objective inferred from the direct human request."},"max_goal_rounds":{"type":"number","description":"Optional positive safe-integer limit on automatic continuation rounds."}},"required":["objective"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"exit_plan_mode","description":"Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.","parameters":{"type":"object","properties":{"plan":{"type":"string","description":"The complete plan, as markdown, starting with a # heading that names it."}},"required":["plan"]}},{"name":"get_goal","description":"Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.","parameters":{"type":"object","properties":{}}},{"name":"glob","description":"Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.","parameters":{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth."},"path":{"type":"string","description":"Directory to search in. Defaults to the session workspace; a relative path resolves against it."}},"required":["pattern"]}},{"name":"grep","description":"Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.","parameters":{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression to search for (ripgrep syntax)."},"path":{"type":"string","description":"File or directory to search. Defaults to the session workspace; a relative path resolves against it."},"include":{"type":"string","description":"One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported."}},"required":["pattern"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_agents","description":"List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.","parameters":{"type":"object","properties":{"scope":{"type":"string","description":"children (default) lists direct children only; descendants walks the complete tree below you.","enum":["children","descendants"]}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"read_image","description":"Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to the image file, resolved by the filesystem backend."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"str_replace_editor","description":"Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.","enum":["view","create","str_replace","insert"]},"path":{"type":"string","description":"Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."},"file_text":{"type":"string","description":"Required parameter of `create` command, with the content of the file to be created."},"insert_line":{"type":"integer","description":"Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."},"new_str":{"type":"string","description":"Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."},"old_str":{"type":"string","description":"Required parameter of `str_replace` command containing the string in `path` to replace."},"view_range":{"type":"array","description":"Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.","items":{"type":"integer"}}},"required":["command","path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"update_goal","description":"Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.","parameters":{"type":"object","properties":{"goal_id":{"type":"string","description":"Exact id returned by get_goal."},"revision":{"type":"number","description":"Exact positive revision returned by get_goal."},"action":{"type":"string","description":"edit | pause | resume | complete | blocked","enum":["edit","pause","resume","complete","blocked"]},"objective":{"type":"string","description":"Replacement objective; valid only with action edit."},"max_goal_rounds":{"type":"number","description":"Replacement cap; valid only with action edit."},"blocked_reason":{"type":"string","description":"Concrete blocking condition; required only with action blocked."}},"required":["goal_id","revision","action"]}},{"name":"web_search","description":"Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.","parameters":{"type":"object","properties":{"queries":{"type":"array","description":"Required search queries; accepts 1–4 items and merges their results.","items":{"type":"string"}}},"required":["queries"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"request/context","seq":11,"time":1788408001585,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"session/title-llm-request","seq":12,"time":1788408001586,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short.\\n\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"2f7513d4-1985-4f34-92f0-1cc64e86e048"}],"maxTokens":64}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"session/title","seq":13,"time":1788408003050,"data":{"title":"Create and read hello.txt","messageSeqs":[7],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":14,"time":1788408003498,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":15,"time":1788408003499,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":16,"time":1788408003502,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"{"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":17,"time":1788408003502,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":18,"time":1788408003503,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"file"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":19,"time":1788408003504,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"_path"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":20,"time":1788408003505,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":21,"time":1788408003506,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":": "}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":22,"time":1788408003524,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":23,"time":1788408003524,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"hello"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":24,"time":1788408003524,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":".txt"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":25,"time":1788408003525,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":26,"time":1788408003554,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":", "}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":27,"time":1788408003554,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":28,"time":1788408003555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"content"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":29,"time":1788408003555,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":30,"time":1788408003556,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":": "}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":31,"time":1788408003570,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":32,"time":1788408003571,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"hi"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":33,"time":1788408003572,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":34,"time":1788408003572,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","argumentsDelta":"}"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":35,"time":1788408003584,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","arguments":"{\"file_path\": \"hello.txt\", \"content\": \"hi\"}"}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":36,"time":1788408003585,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7504,"outputTokens":61,"cacheReadTokens":0,"reasoningTokens":0}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":37,"time":1788408003585,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/message","seq":38,"time":1788408003587,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","arguments":"{\"file_path\": \"hello.txt\", \"content\": \"hi\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"63d8d6fa-9871-4ba1-88db-6b191a9708a7"},"usage":{"inputTokens":7504,"outputTokens":61,"cacheReadTokens":0,"reasoningTokens":0}},"sourceEventSeqs":[14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"tool/call","seq":39,"time":1788408003588,"data":{"turn":1,"step":1,"callId":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","name":"write","arguments":"{\"file_path\": \"hello.txt\", \"content\": \"hi\"}"}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"tool/result","seq":40,"time":1788408003618,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_yfH9lPcGP2Xthlr7NVCp8622","content":[{"type":"text","text":"/workspace/hello.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"2973b00e-b2a4-45ef-b46d-d6208b71ff96"},"meta":{"diffs":[]}},"sourceEventSeqs":[39],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"step/end","seq":41,"time":1788408003618,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"step/start","seq":42,"time":1788408003629,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":43,"time":1788408005217,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":44,"time":1788408005217,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":45,"time":1788408005244,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"{"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":46,"time":1788408005244,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":47,"time":1788408005244,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"command"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":48,"time":1788408005244,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":49,"time":1788408005244,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":": "}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":50,"time":1788408005268,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":51,"time":1788408005270,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"cat"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":52,"time":1788408005276,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":" hello"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":53,"time":1788408005276,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":".txt"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":54,"time":1788408005277,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":55,"time":1788408005296,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":", "}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":56,"time":1788408005296,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":57,"time":1788408005296,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"description"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":58,"time":1788408005296,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":59,"time":1788408005297,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":": "}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":60,"time":1788408005325,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":61,"time":1788408005325,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"Read"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":62,"time":1788408005343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":" hello"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":63,"time":1788408005344,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":".txt"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":64,"time":1788408005345,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":" with"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":65,"time":1788408005345,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":" shell"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":66,"time":1788408005365,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"\""}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":67,"time":1788408005368,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","argumentsDelta":"}"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":68,"time":1788408005419,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","arguments":"{\"command\": \"cat hello.txt\", \"description\": \"Read hello.txt with shell\"}"}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":69,"time":1788408005420,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":222,"outputTokens":65,"cacheReadTokens":7424,"reasoningTokens":0}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":70,"time":1788408005422,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/message","seq":71,"time":1788408005425,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","arguments":"{\"command\": \"cat hello.txt\", \"description\": \"Read hello.txt with shell\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9591217c-adc3-4958-9512-7f1300a40e9e"},"usage":{"inputTokens":222,"outputTokens":65,"cacheReadTokens":7424,"reasoningTokens":0}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"tool/call","seq":72,"time":1788408005428,"data":{"turn":1,"step":2,"callId":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","name":"bash","arguments":"{\"command\": \"cat hello.txt\", \"description\": \"Read hello.txt with shell\"}"}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"tool/result","seq":73,"time":1788408005517,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_8x5u7AhhtbUGKOyKwb1i9001","content":[{"type":"text","text":"hi"}],"isError":false}],"role":"user","id":"8e420ea1-aadd-4752-8d7d-4dba6ca414ff"}},"sourceEventSeqs":[72],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"step/end","seq":74,"time":1788408005517,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"step/start","seq":75,"time":1788408005547,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":76,"time":1788408007209,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":77,"time":1788408007211,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"Created"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":78,"time":1788408007227,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" hello"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":79,"time":1788408007228,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":".txt"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":80,"time":1788408007228,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" and"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":81,"time":1788408007260,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" read"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":82,"time":1788408007261,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" it"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":83,"time":1788408007267,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" back"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":84,"time":1788408007268,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":":"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":85,"time":1788408007268,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" the"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":86,"time":1788408007291,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" file"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":87,"time":1788408007292,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" contains"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":88,"time":1788408007293,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" exactly"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":89,"time":1788408007293,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":" `"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":90,"time":1788408007294,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"hi"}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":91,"time":1788408007294,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"`."}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":92,"time":1788408007319,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Created hello.txt and read it back: the file contains exactly `hi`."}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":93,"time":1788408007320,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":173,"outputTokens":16,"cacheReadTokens":7552,"reasoningTokens":0}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/chunk","seq":94,"time":1788408007321,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"assistant/message","seq":95,"time":1788408007323,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"Created hello.txt and read it back: the file contains exactly `hi`."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"591c4154-2210-4d13-ab4a-e456eeac2b31"},"usage":{"inputTokens":173,"outputTokens":16,"cacheReadTokens":7552,"reasoningTokens":0}},"sourceEventSeqs":[76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"step/end","seq":96,"time":1788408007327,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"session-fixture-1","event":{"type":"turn/end","seq":97,"time":1788408007327,"data":{"turn":1,"reason":{"kind":"completed"}}}} diff --git a/runtime/javascript/test/fixtures/providers/gemini.jsonl b/runtime/javascript/test/fixtures/providers/gemini.jsonl new file mode 100644 index 000000000..8005fd698 --- /dev/null +++ b/runtime/javascript/test/fixtures/providers/gemini.jsonl @@ -0,0 +1,7 @@ +{"type":"init","timestamp":"2026-09-03T04:01:40.704Z","session_id":"7525e214-3b13-401f-8152-85d9c075362b","model":"auto"} +{"type":"message","timestamp":"2026-09-03T04:01:40.705Z","role":"user","content":"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short."} +{"type":"message","timestamp":"2026-09-03T04:01:40.738Z","role":"assistant","content":"I'll write the file and read it back.","delta":true} +{"type":"tool_use","timestamp":"2026-09-03T04:01:40.753Z","tool_name":"run_shell_command","tool_id":"run_shell_command__run_shell_command_1788408100739_0","parameters":{"command":"printf 'hi\\n' > hello.txt && cat hello.txt","description":"write and read hello.txt","is_background":false}} +{"type":"tool_result","timestamp":"2026-09-03T04:01:40.846Z","tool_id":"run_shell_command__run_shell_command_1788408100739_0","status":"success","output":"hi"} +{"type":"message","timestamp":"2026-09-03T04:01:40.848Z","role":"assistant","content":"hi","delta":true} +{"type":"result","timestamp":"2026-09-03T04:01:40.849Z","status":"success","stats":{"total_tokens":6328,"input_tokens":6240,"output_tokens":88,"cached":1024,"input":5216,"duration_ms":145,"tool_calls":1,"models":{"gemini-3.1-flash-lite":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0},"gemini-3.1-pro-preview":{"total_tokens":6328,"input_tokens":6240,"output_tokens":88,"cached":1024,"input":5216}}}} diff --git a/runtime/javascript/test/fixtures/providers/opencode.jsonl b/runtime/javascript/test/fixtures/providers/opencode.jsonl new file mode 100644 index 000000000..72bf23522 --- /dev/null +++ b/runtime/javascript/test/fixtures/providers/opencode.jsonl @@ -0,0 +1,7 @@ +{"type":"step_start","timestamp":1788407936638,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"id":"prt_0656b767c0010RTowDukcYNd3d","messageID":"msg_0656b6e63001jIHLidd72tAQqz","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","type":"step-start"}} +{"type":"text","timestamp":1788407937662,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"id":"prt_0656b7984001HcX6jTNFV4ZsAv","messageID":"msg_0656b6e63001jIHLidd72tAQqz","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","type":"text","text":"I need to create the file and then read it back. These are dependent operations so I'll run them sequentially.","time":{"start":1788407937412,"end":1788407937660}}} +{"type":"tool_use","timestamp":1788407937780,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"type":"tool","tool":"bash","callID":"call_00_ET_rbaUvhIIYYL9fyYXXhny8084","state":{"status":"completed","input":{"command":"echo hi > hello.txt && cat hello.txt"},"output":"hi\n","metadata":{"output":"hi\n","exit":0,"truncated":false},"title":"echo hi > hello.txt && cat hello.txt","time":{"start":1788407937756,"end":1788407937762}},"id":"prt_0656b7a3b001iBcWd0EoU9TSse","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","messageID":"msg_0656b6e63001jIHLidd72tAQqz"}} +{"type":"step_finish","timestamp":1788407937780,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"id":"prt_0656b7ae6001748yKkkDhluu0z","reason":"tool-calls","messageID":"msg_0656b6e63001jIHLidd72tAQqz","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","type":"step-finish","tokens":{"total":7716,"input":6978,"output":73,"reasoning":0,"cache":{"write":0,"read":665}},"cost":0}} +{"type":"step_start","timestamp":1788407938326,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"id":"prt_0656b7d14001xtcLNFYxjOHIhR","messageID":"msg_0656b7aef001GZKLjluPtFSKUQ","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","type":"step-start"}} +{"type":"text","timestamp":1788407938901,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"id":"prt_0656b7f290016jfn03r8v1r0nP","messageID":"msg_0656b7aef001GZKLjluPtFSKUQ","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","type":"text","text":"hi","time":{"start":1788407938857,"end":1788407938889}}} +{"type":"step_finish","timestamp":1788407938901,"sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","part":{"id":"prt_0656b7f4b001fsC1uscTBo9gqa","reason":"stop","messageID":"msg_0656b7aef001GZKLjluPtFSKUQ","sessionID":"ses_f9a9492d4ffewr2cnUh8bM6Ti7","type":"step-finish","tokens":{"total":7733,"input":179,"output":2,"reasoning":0,"cache":{"write":0,"read":7552}},"cost":0}} diff --git a/runtime/javascript/test/fixtures/providers/pi.jsonl b/runtime/javascript/test/fixtures/providers/pi.jsonl new file mode 100644 index 000000000..bd8deb26a --- /dev/null +++ b/runtime/javascript/test/fixtures/providers/pi.jsonl @@ -0,0 +1,66 @@ +{"type":"session","version":3,"id":"01a0656b-2d5a-76a6-a933-39afc90df997","timestamp":"2026-09-03T03:58:37.914Z","cwd":"/workspace"} +{"type":"agent_start"} +{"type":"turn_start"} +{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short."}],"timestamp":1788407917964}} +{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short."}],"timestamp":1788407917964}} +{"type":"message_start","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"{","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"path","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"path\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"path\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"path\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"path\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":": ","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"path\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{},"partialArgs":"{\"path\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello"},"partialArgs":"{\"path\": \"hello","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello"},"partialArgs":"{\"path\": \"hello","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"hello","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello"},"partialArgs":"{\"path\": \"hello","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello"},"partialArgs":"{\"path\": \"hello","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":".txt","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":", ","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"content","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", \"content","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", \"content","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", \"content\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt"},"partialArgs":"{\"path\": \"hello.txt\", \"content\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":": ","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":""},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":""},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":""},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":""},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"hi","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"hi\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"hi\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"hi\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"hi\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"}","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"hi\"}","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"},"partialArgs":"{\"path\": \"hello.txt\", \"content\": \"hi\"}","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_end","contentIndex":0,"toolCall":{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"}},"partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":1467,"output":60,"cacheRead":179,"cacheWrite":0,"reasoning":0,"totalTokens":1706,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2","rawStopReason":"tool_calls"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":1467,"output":60,"cacheRead":179,"cacheWrite":0,"reasoning":0,"totalTokens":1706,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2","rawStopReason":"tool_calls"}} +{"type":"message_end","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":1467,"output":60,"cacheRead":179,"cacheWrite":0,"reasoning":0,"totalTokens":1706,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2","rawStopReason":"tool_calls"}} +{"type":"tool_execution_start","toolCallId":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","toolName":"write","args":{"path":"hello.txt","content":"hi"}} +{"type":"tool_execution_end","toolCallId":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","toolName":"write","result":{"content":[{"type":"text","text":"Successfully wrote 2 bytes to hello.txt"}]},"isError":false} +{"type":"message_start","message":{"role":"toolResult","toolCallId":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","toolName":"write","content":[{"type":"text","text":"Successfully wrote 2 bytes to hello.txt"}],"isError":false,"timestamp":1788407919981}} +{"type":"message_end","message":{"role":"toolResult","toolCallId":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","toolName":"write","content":[{"type":"text","text":"Successfully wrote 2 bytes to hello.txt"}],"isError":false,"timestamp":1788407919981}} +{"type":"turn_end","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":1467,"output":60,"cacheRead":179,"cacheWrite":0,"reasoning":0,"totalTokens":1706,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2","rawStopReason":"tool_calls"},"toolResults":[{"role":"toolResult","toolCallId":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","toolName":"write","content":[{"type":"text","text":"Successfully wrote 2 bytes to hello.txt"}],"isError":false,"timestamp":1788407919981}]} +{"type":"turn_start"} +{"type":"message_start","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"{","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"command","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"command\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"command\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"command\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"command\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":": ","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"command\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{},"partialArgs":"{\"command\": ","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":""},"partialArgs":"{\"command\": \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":""},"partialArgs":"{\"command\": \"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"cat","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat"},"partialArgs":"{\"command\": \"cat","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat"},"partialArgs":"{\"command\": \"cat","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":" hello","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello"},"partialArgs":"{\"command\": \"cat hello","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello"},"partialArgs":"{\"command\": \"cat hello","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":".txt","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"},"partialArgs":"{\"command\": \"cat hello.txt","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"},"partialArgs":"{\"command\": \"cat hello.txt","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"\"","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"},"partialArgs":"{\"command\": \"cat hello.txt\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"},"partialArgs":"{\"command\": \"cat hello.txt\"","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_delta","contentIndex":0,"delta":"}","partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"},"partialArgs":"{\"command\": \"cat hello.txt\"}","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"},"partialArgs":"{\"command\": \"cat hello.txt\"}","streamIndex":0}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee"}} +{"type":"message_update","assistantMessageEvent":{"type":"toolcall_end","contentIndex":0,"toolCall":{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"}},"partial":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":192,"output":45,"cacheRead":1536,"cacheWrite":0,"reasoning":0,"totalTokens":1773,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee","rawStopReason":"tool_calls"}},"message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":192,"output":45,"cacheRead":1536,"cacheWrite":0,"reasoning":0,"totalTokens":1773,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee","rawStopReason":"tool_calls"}} +{"type":"message_end","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":192,"output":45,"cacheRead":1536,"cacheWrite":0,"reasoning":0,"totalTokens":1773,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee","rawStopReason":"tool_calls"}} +{"type":"tool_execution_start","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","args":{"command":"cat hello.txt"}} +{"type":"tool_execution_update","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","args":{"command":"cat hello.txt"},"partialResult":{"content":[]}} +{"type":"tool_execution_update","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","args":{"command":"cat hello.txt"},"partialResult":{"content":[{"type":"text","text":"hi"}],"details":{}}} +{"type":"tool_execution_end","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","result":{"content":[{"type":"text","text":"hi"}]},"isError":false} +{"type":"message_start","message":{"role":"toolResult","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","content":[{"type":"text","text":"hi"}],"isError":false,"timestamp":1788407921441}} +{"type":"message_end","message":{"role":"toolResult","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","content":[{"type":"text","text":"hi"}],"isError":false,"timestamp":1788407921441}} +{"type":"turn_end","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":192,"output":45,"cacheRead":1536,"cacheWrite":0,"reasoning":0,"totalTokens":1773,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee","rawStopReason":"tool_calls"},"toolResults":[{"role":"toolResult","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","content":[{"type":"text","text":"hi"}],"isError":false,"timestamp":1788407921441}]} +{"type":"turn_start"} +{"type":"message_start","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516"}} +{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516"}},"message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516"}} +{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hi","partial":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516"}},"message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516"}} +{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"hi","partial":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":123,"output":2,"cacheRead":1664,"cacheWrite":0,"reasoning":0,"totalTokens":1789,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516","rawStopReason":"stop"}},"message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":123,"output":2,"cacheRead":1664,"cacheWrite":0,"reasoning":0,"totalTokens":1789,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516","rawStopReason":"stop"}} +{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":123,"output":2,"cacheRead":1664,"cacheWrite":0,"reasoning":0,"totalTokens":1789,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516","rawStopReason":"stop"}} +{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":123,"output":2,"cacheRead":1664,"cacheWrite":0,"reasoning":0,"totalTokens":1789,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516","rawStopReason":"stop"},"toolResults":[]} +{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Create a file named hello.txt in the current directory whose only content is the word hi. Then read it back with a shell command and reply with exactly what it contains. Keep it short."}],"timestamp":1788407917964},{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","name":"write","arguments":{"path":"hello.txt","content":"hi"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":1467,"output":60,"cacheRead":179,"cacheWrite":0,"reasoning":0,"totalTokens":1706,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407918016,"responseId":"12b89ff4-4fb2-4c22-b239-9083d279c2d2","rawStopReason":"tool_calls"},{"role":"toolResult","toolCallId":"call_00_ET_fvkmlmiTyNMoN1dzEA9O5554","toolName":"write","content":[{"type":"text","text":"Successfully wrote 2 bytes to hello.txt"}],"isError":false,"timestamp":1788407919981},{"role":"assistant","content":[{"type":"toolCall","id":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","name":"bash","arguments":{"command":"cat hello.txt"}}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":192,"output":45,"cacheRead":1536,"cacheWrite":0,"reasoning":0,"totalTokens":1773,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788407919982,"responseId":"b1f9fcc9-83ad-4f9e-836f-5db2103389ee","rawStopReason":"tool_calls"},{"role":"toolResult","toolCallId":"call_00_ET_X0XNzG7xHUnOFAySjpxg6569","toolName":"bash","content":[{"type":"text","text":"hi"}],"isError":false,"timestamp":1788407921441},{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"openai-completions","provider":"agent-compose","model":"deepseek-v4-flash","usage":{"input":123,"output":2,"cacheRead":1664,"cacheWrite":0,"reasoning":0,"totalTokens":1789,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788407921441,"responseId":"a690049d-4306-4737-a656-806afcccc516","rawStopReason":"stop"}],"willRetry":false} +{"type":"agent_settled"} diff --git a/runtime/javascript/test/provider-event-mapping.test.ts b/runtime/javascript/test/provider-event-mapping.test.ts new file mode 100644 index 000000000..3cbd0b59b --- /dev/null +++ b/runtime/javascript/test/provider-event-mapping.test.ts @@ -0,0 +1,171 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { AgentEvent } from "../src/agent-event.js"; +import { ClaudeRunner } from "../src/runners/claude.js"; +import { CodexRunner } from "../src/runners/codex.js"; +import { DshRunner } from "../src/runners/dsh.js"; +import { GeminiRunner } from "../src/runners/gemini.js"; +import { OpenCodeRunner } from "../src/runners/opencode.js"; +import { PiRunner } from "../src/runners/pi.js"; +import type { AgentResult, Provider, RunnerOptions } from "../src/types.js"; + +// Fixtures are real recorded runs of one prompt ("write hello.txt, read it +// back") against each provider. They exist to catch provider field drift — +// codex, for instance, sends a cache_write_input_tokens that its own SDK type +// does not declare. +const fixturesDir = path.join(import.meta.dirname, "fixtures", "providers"); + +function readFixture(provider: Provider): Record[] { + return readFileSync(path.join(fixturesDir, `${provider}.jsonl`), "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as Record); +} + +function options(provider: Provider, onEvent: (event: AgentEvent) => void): RunnerOptions { + return { + provider, + stateRoot: "/state", + sessionRoot: "/state", + workspace: "/workspace", + home: "/home", + runtimeRoot: "/runtime", + systemContext: "", + onEvent, + }; +} + +const silentWriter = { write() {}, line() {}, transcript: () => "" }; + +function blankResult(provider: Provider): AgentResult { + return { provider, threadId: "", stopReason: "", finalText: "", finalTextSource: "none", transcript: "", stderr: "" }; +} + +function replay(provider: Provider): AgentEvent[] { + const events: AgentEvent[] = []; + const push = (event: AgentEvent) => events.push(event); + const result = blankResult(provider); + const lines = readFixture(provider); + if (provider === "claude") { + const runner = new ClaudeRunner(options(provider, push), silentWriter); + for (const message of lines) { + runner.emitTopLevel(message); + if (message.type === "stream_event") { + runner.handleStreamEvent(message); + } + } + return events; + } + if (provider === "gemini") { + const runner = new GeminiRunner(options(provider, push)); + for (const event of lines) { + runner.handleEvent(event, result); + } + return events; + } + if (provider === "dsh") { + const runner = new DshRunner(options(provider, push), silentWriter); + for (const line of lines) { + if (line.type !== "session_event" || typeof line.event !== "object" || line.event === null) { + continue; + } + runner.handleEvent(line.event as Record, result); + } + return events; + } + const runner = provider === "codex" + ? new CodexRunner(options(provider, push), silentWriter) + : provider === "opencode" + ? new OpenCodeRunner(options(provider, push), silentWriter) + : new PiRunner(options(provider, push), silentWriter); + for (const event of lines) { + runner.handleEvent(event, result); + } + return events; +} + +const providers: Provider[] = ["codex", "claude", "gemini", "opencode", "pi", "dsh"]; + +describe("provider event mapping", () => { + // Per-provider coverage without a snapshot: a serialised dump would be + // 12KB a reviewer cannot judge, and updating it after an intentional mapper + // change hides a regression in the diff. These assert what the mapping is + // actually for. + const knownKinds = new Set([ + "step_start", "step_end", "text_delta", "reasoning_delta", "tool_call", + "tool_result", "todo", "usage", "retry", "compaction", "error", + ]); + + for (const provider of providers) { + it(`maps the recorded ${provider} run onto well-formed neutral events`, () => { + const events = replay(provider); + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(knownKinds.has(event.kind), `${provider}: ${event.kind}`).toBe(true); + // An empty delta is noise the mapper should have dropped, not passed on. + if (event.kind === "text_delta" || event.kind === "reasoning_delta") { + expect(event.text, provider).not.toBe(""); + } + if (event.kind === "tool_call") { + expect(event.id, provider).not.toBe(""); + expect(event.name, provider).not.toBe(""); + } + } + // Every provider answered the prompt, so text must have reached the client. + expect(events.some((event) => event.kind === "text_delta"), provider).toBe(true); + }); + } + + it("emits a tool_call and a correlated tool_result for every provider", () => { + for (const provider of providers) { + const events = replay(provider); + const calls = events.filter((event) => event.kind === "tool_call"); + const results = events.filter((event) => event.kind === "tool_result"); + expect(calls.length, provider).toBeGreaterThan(0); + expect(results.length, provider).toBeGreaterThan(0); + // A result must reference a call that was announced, or consumers cannot + // pair a tool's output with its input. + const callIds = new Set(calls.map((event) => event.id)); + for (const result of results) { + expect(callIds.has(result.id), `${provider}: ${result.id}`).toBe(true); + } + } + }); + + it("reports usage without double counting and never as an all-zero record", () => { + // DSH publishes identical usage twice (assistant/chunk and + // assistant/message); only one may be mapped or every count doubles. + const expected: Record = { + codex: 1, claude: 3, gemini: 1, opencode: 2, pi: 3, dsh: 3, + }; + for (const provider of providers) { + const usage = replay(provider).filter((event) => event.kind === "usage"); + expect(usage.length, provider).toBe(expected[provider]); + for (const record of usage) { + expect(record.inputTokens + record.outputTokens, provider).toBeGreaterThan(0); + } + } + }); + + it("keeps inputTokens exclusive of cached tokens", () => { + // codex and gemini report an inclusive prompt count upstream; the mappers + // subtract so the field means the same thing everywhere. + const codexUsage = replay("codex").find((event) => event.kind === "usage"); + expect(codexUsage).toMatchObject({ scope: "turn", inputTokens: 25788, cachedTokens: 2816 }); + const geminiUsage = replay("gemini").find((event) => event.kind === "usage"); + expect(geminiUsage).toMatchObject({ scope: "run", inputTokens: 5216, cachedTokens: 1024 }); + }); + + it("omits kinds the provider cannot produce rather than emitting empty ones", () => { + // No provider reported reasoning in these runs (design doc §7): the kind + // must be absent, not present with an empty payload. + for (const provider of providers) { + expect(replay(provider).some((event) => event.kind === "reasoning_delta"), provider).toBe(false); + } + // codex and gemini expose no per-model-call boundary at all. + for (const provider of ["codex", "gemini"] as Provider[]) { + expect(replay(provider).some((event) => event.kind === "step_start"), provider).toBe(false); + } + }); +}); diff --git a/runtime/javascript/test/runner-execution.test.ts b/runtime/javascript/test/runner-execution.test.ts index 55eb89a59..b02f88c5e 100644 --- a/runtime/javascript/test/runner-execution.test.ts +++ b/runtime/javascript/test/runner-execution.test.ts @@ -947,7 +947,10 @@ describe("runner execution", () => { // Stub host values first so the assertions below are deterministic — // proof the delete branches ran, not that the CI environment happened // not to have these vars set (see docs/design/dsh_agent_provider_design.md §3.5). - vi.stubEnv("DSH_MODEL", "host-leaked-model"); + // DSH_MODEL is the deliberate exception: the daemon's facade config sets it + // to the model it resolved and bound the run's token to, so it is carried + // through rather than cleared (see dsh-runner.test.ts for that contract). + vi.stubEnv("DSH_MODEL", "daemon-resolved-model"); vi.stubEnv("DSH_REASONING_EFFORT", "max"); vi.stubEnv("DSH_SKILL_DIRS", "/host/leaked/skills"); vi.stubEnv("DSH_MCP_SERVERS", JSON.stringify([{ transport: "stdio", serverName: "leaked", command: "evil" }])); @@ -973,7 +976,7 @@ describe("runner execution", () => { const env = call?.options.env as Record; expect(env.DSH_RESUME).toBe("1"); expect(env.DSH_SESSION_ID).toBe("session-existing"); - expect(env).not.toHaveProperty("DSH_MODEL"); + expect(env.DSH_MODEL).toBe("daemon-resolved-model"); expect(env).not.toHaveProperty("DSH_REASONING_EFFORT"); expect(env).not.toHaveProperty("DSH_SKILL_DIRS"); expect(env).not.toHaveProperty("DSH_MCP_SERVERS"); diff --git a/runtime/javascript/test/stream.test.ts b/runtime/javascript/test/stream.test.ts index cd9c75ed3..ba00fb2e8 100644 --- a/runtime/javascript/test/stream.test.ts +++ b/runtime/javascript/test/stream.test.ts @@ -121,16 +121,20 @@ describe("runStreamCommand", () => { }); const frames = parseOutput(stdout.text); + // thread.started carries no agent-observable content, so it maps to no + // neutral event; each turn now emits a single text_delta. expect(frames.map((entry) => entry.type)).toEqual([ "started", "agent_event", - "agent_event", "agent_turn_completed", "agent_event", - "agent_event", "agent_turn_completed", "result", ]); + expect(frames.filter((entry) => entry.type === "agent_event")).toEqual([ + expect.objectContaining({ event: { kind: "text_delta", text: "answer 1" } }), + expect.objectContaining({ event: { kind: "text_delta", text: "answer 2" } }), + ]); expect(frames.every((entry, index) => entry.seq === index)).toBe(true); expect(runInputs).toEqual(["first", "second"]); expect(startThread).toHaveBeenCalledTimes(1); @@ -144,7 +148,7 @@ describe("runStreamCommand", () => { finalTextSource: "provider_message", transcript: "answer 1\nanswer 2", }); - expect(parseOutput(stdout.text)).toHaveLength(8); + expect(parseOutput(stdout.text)).toHaveLength(6); const stored = JSON.parse(await fs.readFile(path.join(root, "state", "agents", "providers", "codex.json"), "utf8")); expect(stored).toMatchObject({ threadId: "thread-1", @@ -235,15 +239,21 @@ describe("runStreamCommand", () => { expect(frames.map((entry) => entry.type)).toEqual([ "started", "agent_event", + "agent_event", "agent_turn_completed", "agent_event", + "agent_event", "agent_turn_completed", "result", ]); expect(frames.every((entry, index) => entry.seq === index)).toBe(true); + // Each turn now yields the answer text plus the terminal step_end that + // the provider's result message carries. expect(frames.filter((entry) => entry.type === "agent_event")).toEqual([ - expect.objectContaining({ event: expect.objectContaining({ provider: "claude", text: "claude answer 1" }) }), - expect.objectContaining({ event: expect.objectContaining({ provider: "claude", text: "claude answer 2" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "text_delta", text: "claude answer 1" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "step_end" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "text_delta", text: "claude answer 2" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "step_end" }) }), ]); expect(claudeState.queryCalls.map((call) => call.prompt)).toEqual(["first", "second"]); expect(claudeState.queryCalls[0]?.options).not.toMatchObject({ resume: expect.anything() }); @@ -268,14 +278,20 @@ describe("runStreamCommand", () => { vi.spyOn(OpenCodeRunner.prototype, "runPrompt").mockImplementation(async function (message) { prompts.push(message); const turn = prompts.length; - const writer = (this as unknown as { writer: { write(text: string): void; transcript(): string } }).writer; - writer.write(`opencode answer ${turn}`); + const self = this as unknown as { + writer: { write(text: string): void; transcript(): string }; + options: { onEvent?: (event: { kind: string; text: string }) => void }; + }; + // A real runner publishes structured events through its onEvent sink; + // writing to the transcript alone no longer produces a frame. + self.options.onEvent?.({ kind: "text_delta", text: `opencode answer ${turn}` }); + self.writer.write(`opencode answer ${turn}`); return { provider: "opencode", threadId: "opencode-session-1", stopReason: "completed", finalText: `opencode answer ${turn}`, - transcript: writer.transcript(), + transcript: self.writer.transcript(), stderr: "", }; }); @@ -302,8 +318,8 @@ describe("runStreamCommand", () => { ]); expect(prompts).toEqual(["first", "second"]); expect(frames.filter((entry) => entry.type === "agent_event")).toEqual([ - expect.objectContaining({ event: expect.objectContaining({ provider: "opencode", text: "opencode answer 1" }) }), - expect.objectContaining({ event: expect.objectContaining({ provider: "opencode", text: "opencode answer 2" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "text_delta", text: "opencode answer 1" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "text_delta", text: "opencode answer 2" }) }), ]); expect(frames.at(-1)).toMatchObject({ type: "result", @@ -325,14 +341,20 @@ describe("runStreamCommand", () => { vi.spyOn(PiRunner.prototype, "runPrompt").mockImplementation(async function (message) { prompts.push(message); const turn = prompts.length; - const writer = (this as unknown as { writer: { write(text: string): void; transcript(): string } }).writer; - writer.write(`pi answer ${turn}`); + const self = this as unknown as { + writer: { write(text: string): void; transcript(): string }; + options: { onEvent?: (event: { kind: string; text: string }) => void }; + }; + // A real runner publishes structured events through its onEvent sink; + // writing to the transcript alone no longer produces a frame. + self.options.onEvent?.({ kind: "text_delta", text: `pi answer ${turn}` }); + self.writer.write(`pi answer ${turn}`); return { provider: "pi", threadId: "pi-session-1", stopReason: "completed", finalText: `pi answer ${turn}`, - transcript: writer.transcript(), + transcript: self.writer.transcript(), stderr: "", }; }); @@ -351,8 +373,8 @@ describe("runStreamCommand", () => { const frames = parseOutput(stdout.text); expect(prompts).toEqual(["first", "second"]); expect(frames.filter((entry) => entry.type === "agent_event")).toEqual([ - expect.objectContaining({ event: expect.objectContaining({ provider: "pi", text: "pi answer 1" }) }), - expect.objectContaining({ event: expect.objectContaining({ provider: "pi", text: "pi answer 2" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "text_delta", text: "pi answer 1" }) }), + expect.objectContaining({ event: expect.objectContaining({ kind: "text_delta", text: "pi answer 2" }) }), ]); expect(frames.at(-1)).toMatchObject({ type: "result", From 6b996c0988e5cb119a14644b15c1de619d2ad80d Mon Sep 17 00:00:00 2001 From: winterfx Date: Fri, 4 Sep 2026 15:17:41 +0800 Subject: [PATCH 2/5] fix(proxy): drop trailing blank line to satisfy golangci-lint fmt Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01R46kvDYupMBjXvJHaHoinr --- pkg/agentcompose/proxy/runtime_llm_coverage_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/agentcompose/proxy/runtime_llm_coverage_test.go b/pkg/agentcompose/proxy/runtime_llm_coverage_test.go index 7fab56366..a87d4aa55 100644 --- a/pkg/agentcompose/proxy/runtime_llm_coverage_test.go +++ b/pkg/agentcompose/proxy/runtime_llm_coverage_test.go @@ -709,4 +709,3 @@ type errRuntimeLLMReader struct{} func (errRuntimeLLMReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } - From 472d6563c09778b63adbd458d672c61081f43760 Mon Sep 17 00:00:00 2001 From: winterfx Date: Mon, 7 Sep 2026 10:43:43 +0800 Subject: [PATCH 3/5] fix(runtime): wire dsh reasoning effort --- assets/.dsh/profiles/agent-compose/cordis.patch.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/.dsh/profiles/agent-compose/cordis.patch.yml b/assets/.dsh/profiles/agent-compose/cordis.patch.yml index 795b230b0..ab878ebd0 100644 --- a/assets/.dsh/profiles/agent-compose/cordis.patch.yml +++ b/assets/.dsh/profiles/agent-compose/cordis.patch.yml @@ -32,6 +32,9 @@ apiKeyEnv: LLM_API_KEY api: !!js process.env.DSH_WIRE_API || 'openai-completions' baseURL: !!js process.env.LLM_API_ENDPOINT + # DSH_REASONING_EFFORT is normalized by dsh.ts from agent-compose's + # effort levels and consumed as the route's default reasoning level. + reasoning: !!js process.env.DSH_REASONING_EFFORT || undefined models: - id: !!js process.env.DSH_MODEL || 'deepseek-v4-flash' reasoningEfforts: From 709bae19ec8b54cd4eb1c95e179fe6770e069ba7 Mon Sep 17 00:00:00 2001 From: winterfx Date: Mon, 7 Sep 2026 11:59:56 +0800 Subject: [PATCH 4/5] fix(runtime): address review findings on the neutral agent events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen findings were raised on this branch. These are the ones that were reproducible against the committed fixtures or the call graph. dsh reached prompt attach without a facade. `dsh` joined promptAttachProviders, but ensurePromptAttachLLMFacadeEnv still switched on four providers, and EnsureDshFacadeConfig's environment is per-exec — never persisted onto the sandbox. An attach run therefore spawned the guest with no endpoint, no run-scoped token and no model, and the profile fell back to its hardcoded default. A test now walks promptAttachProviders itself, so the two lists cannot drift apart again. dsh + an Anthropic provider became a hard failure. Following the resolved provider is right, but dshWireAPI rejected anthropic_messages, and resolveDshFacadeTarget's provider-id branch reaches it with no family preference. That configuration worked before this branch, bridged down to chat completions. Since llm-pi-ai is the same pi-ai adapter, it is now served the way pi serves it: dshFacadeProtocol mirrors piFacadeProtocol, routing the Anthropic family to /llm/anthropic with an anthropic-messages token, and resolveDshFacadeTarget gains the matching family branch. Run-scope usage named the wrong model. Both mappers took `Object.keys(...)[0]`, which is not the run's model: claude's fixture lists the haiku sidecar (931/15) ahead of the opus tokens actually being reported, and gemini's lists an all-zero flash-lite entry ahead of the model that did the work. dominantUsageModel picks the entry that spent the tokens — counting cache reads, without which claude's sidecar still wins. Attach transcripts lost every tool. agentEventText returned text only for text_delta, so the persisted transcript and each AttachAgentEvent.text held assistant prose alone: no commands, no output. It projects tool_call and tool_result again, de-duplicated by id, since claude re-announces a call once its arguments arrive and codex resends a command's cumulative output. Also: gemini published an all-zero usage record when a result carried no stats, the placeholder the module contract forbids; claude, gemini and dsh close a turn with a step-less step_end that made any step count come out one too high, now marked `scope: "run"`; the counting rule in agent-event.ts said to key on `kind === "tool_call"` without saying to de-duplicate by id, which counts double on claude and codex; the profile swapped bash-sandbox and fs-sandbox for their unconfined executors but left pwsh-sandbox advertising the same `sandbox_permissions` escalation argument; and the design doc still claimed DSH_MODEL is deleted in its false branch, had no DSH_WIRE_API row, and credited llm-deepseek for what llm-pi-ai now consumes. Not fixed: `effort` and `skills` on the start frame stay inert. No agent definition carries an effort, and prompt attach never resolves or materialises skills the way AgentRunner.prepareAgentFiles does, so there is nothing for the daemon to send. Both readers are now documented as frame protocol rather than a live path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01A8mXVS9i9vU9osi3pyxc7G --- .../profiles/agent-compose/cordis.patch.yml | 16 +++- docs/design/dsh_agent_provider_design.md | 7 +- pkg/llms/dsh_facade.go | 61 ++++++++---- pkg/llms/dsh_facade_test.go | 37 +++++++ pkg/runs/coverage_shape_workflows_test.go | 66 +++++++++++++ pkg/runs/prompt_attach_facade.go | 4 + pkg/runs/prompt_attach_facade_test.go | 91 ++++++++++++++++++ pkg/runs/prompt_projection.go | 96 ++++++++++++++++--- runtime/javascript/src/agent-event.ts | 42 +++++++- runtime/javascript/src/runners/claude.ts | 9 +- runtime/javascript/src/runners/dsh.ts | 1 + runtime/javascript/src/runners/gemini.ts | 31 +++--- runtime/javascript/src/stream.ts | 17 +++- .../test/provider-event-mapping.test.ts | 37 +++++++ 14 files changed, 452 insertions(+), 63 deletions(-) diff --git a/assets/.dsh/profiles/agent-compose/cordis.patch.yml b/assets/.dsh/profiles/agent-compose/cordis.patch.yml index ab878ebd0..fbcc5bce5 100644 --- a/assets/.dsh/profiles/agent-compose/cordis.patch.yml +++ b/assets/.dsh/profiles/agent-compose/cordis.patch.yml @@ -22,8 +22,10 @@ # A hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. Such a route requires api, baseURL, and a -# non-empty models list. All three come from the spawn environment, and -# DSH_WIRE_API is the protocol the daemon's facade resolved for this run. +# non-empty models list. All three come from the spawn environment. +# DSH_WIRE_API is the protocol the daemon's facade resolved for this run — +# openai-responses, openai-completions or anthropic-messages, following the +# resolved provider's family (see EnsureDshFacadeConfig). - id: llm-pi-ai config: providers: @@ -65,12 +67,13 @@ root: !!js process.env.DSH_SESSION_ROOT # Local credential discovery is disabled — the only LLM credential is the -# run-scoped facade token injected as LLM_API_KEY (see §5.4). llm-deepseek -# still resolves apiKeyEnv from the launch environment when this row is off. +# run-scoped facade token injected as LLM_API_KEY (see §5.4). The llm-pi-ai +# route still resolves its apiKeyEnv from the launch environment when this row +# is off. - id: credentials disabled: true -# $DSH_HOME/settings.yaml can otherwise override llm-deepseek's apiKeyEnv/ +# $DSH_HOME/settings.yaml can otherwise override the LLM route's apiKeyEnv/ # baseURL at runtime; disabling this row keeps the facade the only LLM # credential source (see §5.4). - id: settings @@ -105,6 +108,9 @@ - id: bash-sandbox name: '@deepseek-ai/dsh-bash-local' +- id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-local' + - id: fs-sandbox name: '@deepseek-ai/dsh-fs-local' diff --git a/docs/design/dsh_agent_provider_design.md b/docs/design/dsh_agent_provider_design.md index 63e2dc656..c9ad64d51 100644 --- a/docs/design/dsh_agent_provider_design.md +++ b/docs/design/dsh_agent_provider_design.md @@ -46,9 +46,10 @@ Env vars aren't unbounded: Linux caps a single `argv`/`envp` string at `MAX_ARG_ | `DSH_SYSTEM_CONTEXT_FILE` | `dsh.ts` | Path to the persona text file `runner.js` reads and injects (§7); unset when there's no system context | | `DSH_SKILL_DIRS` | `dsh.ts` | Colon-joined resolved skill directories; consumed by the `skill-filesystem` row's `customSkillDirs` (§5.1) | | `DSH_MCP_SERVERS` | `dsh.ts` | JSON array of per-server `dsh-mcp-client` configs; consumed by `runner.js` (§6) | -| `LLM_API_KEY`, `LLM_API_ENDPOINT` | facade config | Consumed by the `llm-deepseek` row (§4) | +| `DSH_WIRE_API` | facade config | The wire protocol the facade resolved for this run (`openai-completions`, `openai-responses` or `anthropic-messages`); consumed by the `llm-pi-ai` route's `api` (§4.1) | +| `LLM_API_KEY`, `LLM_API_ENDPOINT` | facade config | Consumed by the `llm-pi-ai` route's `apiKeyEnv`/`baseURL` (§4) | -`env` starts from `...process.env`, so a key this run has no value for isn't automatically absent — it's whatever the host process happened to export. Every conditional `DSH_*` var (`DSH_SYSTEM_CONTEXT_FILE`, `DSH_MCP_SERVERS`, `DSH_RESUME`, `DSH_MODEL`, `DSH_REASONING_EFFORT`, `DSH_SKILL_DIRS`) is therefore explicitly `delete`d in its false branch rather than left conditionally-set, so a host-inherited value can't leak through as this run's persona file, MCP server list, resume flag, model, effort, or skill directories. `DSH_SKILL_DIRS` is the sharpest case: an inherited value would have `dsh` load a skill directory `resolveSkillPaths()`'s symlink-escape check never saw, under `danger-full-access` permissions. +`env` starts from `...process.env`, so a key this run has no value for isn't automatically absent — it's whatever the host process happened to export. Every conditional `DSH_*` var (`DSH_SYSTEM_CONTEXT_FILE`, `DSH_MCP_SERVERS`, `DSH_RESUME`, `DSH_REASONING_EFFORT`, `DSH_SKILL_DIRS`) is therefore explicitly `delete`d in its false branch rather than left conditionally-set, so a host-inherited value can't leak through as this run's persona file, MCP server list, resume flag, effort, or skill directories. `DSH_MODEL` is the deliberate exception: the inherited value is the one the daemon's facade config exported for the model it minted the token against, so `dsh.ts` overwrites it only when the invocation names a model of its own and never deletes it. `DSH_SKILL_DIRS` is the sharpest case: an inherited value would have `dsh` load a skill directory `resolveSkillPaths()`'s symlink-escape check never saw, under `danger-full-access` permissions. ## 4. LLM facade routing @@ -72,7 +73,7 @@ The profile declares one hand-declared route, `agent-compose`: pi-ai ships nothi ### 5.2 Model/provider resolution -Resolution mirrors Pi's (`resolveDshFacadeTarget` mirrors `resolvePiFacadeTarget`'s branch structure: configured provider id → family → custom OpenAI), minus an Anthropic-family branch — `llm-deepseek` always speaks chat completions, so there is nothing to mirror there. +Resolution mirrors Pi's: `resolveDshFacadeTarget` mirrors `resolvePiFacadeTarget`'s branch structure (configured provider id → family → custom OpenAI), Anthropic-family branch included. `dshFacadeProtocol` then mirrors `piFacadeProtocol`, routing an Anthropic provider to the `/llm/anthropic` facade endpoint with an `anthropic-messages` token rather than bridging it down to chat completions. ### 5.3 Sandbox policy / permission mode diff --git a/pkg/llms/dsh_facade.go b/pkg/llms/dsh_facade.go index 8ea29abf0..652a6de45 100644 --- a/pkg/llms/dsh_facade.go +++ b/pkg/llms/dsh_facade.go @@ -47,13 +47,14 @@ type DshFacadeConfigRequest struct { // pair; an absent model falls back to the daemon's default catalog entry, the // same way codex and claude behave. // -// The wire protocol follows the resolved provider rather than being fixed. -// The profile's llm-pi-ai route names its protocol per request through -// DSH_WIRE_API, so the guest speaks whatever the provider serves and the -// request stays on the proxy's passthrough path — no conversion, and none of -// the vendor-event leakage a conversion can carry. The previous adapter, -// llm-deepseek, could only speak chat completions, which is why this was -// unconditional before (see docs/design/dsh_agent_provider_design.md §4.1). +// The wire protocol and the facade endpoint follow the resolved provider +// rather than being fixed. The profile's llm-pi-ai route names its protocol +// per request through DSH_WIRE_API and its endpoint through LLM_API_ENDPOINT, +// so the guest speaks whatever the provider serves and the request stays on +// the proxy's passthrough path — no conversion, and none of the vendor-event +// leakage a conversion can carry. The previous adapter, llm-deepseek, could +// only speak chat completions, which is why this was unconditional before +// (see docs/design/dsh_agent_provider_design.md §4.1). func EnsureDshFacadeConfig(ctx context.Context, req DshFacadeConfigRequest) (map[string]string, error) { config, store, sandbox := req.Config, req.Store, req.Sandbox baseURL := GuestRuntimeBaseURL(config, sandbox) @@ -65,11 +66,10 @@ func EnsureDshFacadeConfig(ctx context.Context, req DshFacadeConfigRequest) (map if err != nil { return nil, err } - wireAPI, piAiAPI, err := dshWireAPI(target) + piAiAPI, wireAPI, facadeBaseURL, err := dshFacadeProtocol(target, baseURL, sandbox.Summary.ID) if err != nil { return nil, err } - facadeBaseURL := strings.TrimRight(baseURL, "/") + "/api/runtime/sandboxes/" + sandbox.Summary.ID + "/llm/openai/v1" tokenValue, token, err := NewFacadeToken(NewFacadeTokenRequest{ SandboxID: sandbox.Summary.ID, Model: target.Model.Name, ProviderID: target.Provider.ID, WireAPI: wireAPI, Source: req.Source, RunID: req.RunID, }) @@ -92,16 +92,30 @@ func EnsureDshFacadeConfig(ctx context.Context, req DshFacadeConfigRequest) (map }, nil } -// dshWireAPI maps the resolved target onto the facade token's wire API and the -// spelling llm-pi-ai uses for the same protocol in its route config. -func dshWireAPI(target ResolvedTarget) (string, string, error) { +// dshFacadeProtocol maps the resolved target onto the spelling llm-pi-ai uses +// for the protocol in its route config, the facade token's wire API, and the +// facade endpoint the guest talks to. +// +// It mirrors piFacadeProtocol, because llm-pi-ai is the same pi-ai adapter: +// an Anthropic-family provider is served natively over the /llm/anthropic +// route rather than bridged down to chat completions, so no family is a hard +// error here. Only an OpenAI provider declaring a wire api that is neither +// responses nor chat completions is unroutable. +func dshFacadeProtocol(target ResolvedTarget, runtimeBaseURL, sandboxID string) (piAiAPI, facadeProtocol, facadeBaseURL string, err error) { + runtimeBaseURL = strings.TrimRight(runtimeBaseURL, "/") + if NormalizeProviderType(target.Provider.ProviderType) == ProviderFamilyAnthropic { + // Same base-path rule as pi: the Anthropic client appends /v1/messages + // itself, so the facade base stays at the family root. + return "anthropic-messages", APIProtocolMessages, runtimeBaseURL + "/api/runtime/sandboxes/" + sandboxID + "/llm/anthropic", nil + } + openAIBaseURL := runtimeBaseURL + "/api/runtime/sandboxes/" + sandboxID + "/llm/openai/v1" switch NormalizeWireAPI(target.WireAPI) { case APIProtocolResponses: - return APIProtocolResponses, "openai-responses", nil + return "openai-responses", APIProtocolResponses, openAIBaseURL, nil case APIProtocolChatCompletions: - return APIProtocolChatCompletions, "openai-completions", nil + return "openai-completions", APIProtocolChatCompletions, openAIBaseURL, nil default: - return "", "", domain.ClassifyError(domain.ErrFailedPrecondition, + return "", "", "", domain.ClassifyError(domain.ErrFailedPrecondition, fmt.Sprintf("dsh does not support wire api %q", target.WireAPI), nil) } } @@ -113,8 +127,9 @@ func dshWireAPI(target ResolvedTarget) (string, string, error) { // codex does, rather than going through resolveDshFacadeTarget: that // function dispatches on the provider id, and an empty id falls through to // the custom-OpenAI branch, which needs a concrete provider to resolve. -// OpenAI is the preferred family because the DSH facade always issues a -// chat-completions token and routes the guest to /llm/openai/v1. +// OpenAI is the preferred family for that default, matching codex; an explicit +// / still resolves to whichever family the +// provider belongs to, and dshFacadeProtocol routes it accordingly. func resolveDshTarget(ctx context.Context, req DshFacadeConfigRequest) (ResolvedTarget, error) { config, store, sandbox := req.Config, req.Store, req.Sandbox if strings.TrimSpace(req.Model) == "" { @@ -143,10 +158,10 @@ type dshFacadeTargetInput struct { Model string } -// resolveDshFacadeTarget mirrors resolvePiFacadeTarget's branch structure -// (configured provider id -> family -> custom OpenAI), but DSH has no -// Anthropic-family route: llm-deepseek always speaks chat completions, so -// there is no Anthropic branch to mirror. +// resolveDshFacadeTarget mirrors resolvePiFacadeTarget's branch structure: +// configured provider id -> family -> custom OpenAI. Since the profile now +// drives llm-pi-ai, the Anthropic family is routable here exactly as it is for +// pi, so the family branch covers it. func resolveDshFacadeTarget(ctx context.Context, in dshFacadeTargetInput) (ResolvedTarget, error) { config, store, sandbox, providerID, model := in.Config, in.Store, in.Sandbox, in.ProviderID, in.Model sandboxID := sandbox.Summary.ID @@ -169,6 +184,10 @@ func resolveDshFacadeTarget(ctx context.Context, in dshFacadeTargetInput) (Resol }) } switch providerID { + case ProviderFamilyAnthropic: + return ResolveRuntimeLLMTargetWithEnv(ctx, store, RuntimeLLMTargetQuery{ + Config: config, SessionID: sandboxID, PreferredProviderFamily: ProviderFamilyAnthropic, RequestedModel: model, ProviderID: "", EnvItems: envItems, + }) case ProviderFamilyOpenAI, ProviderIDDefaultOpenAI: return ResolveRuntimeLLMTargetWithEnv(ctx, store, RuntimeLLMTargetQuery{ Config: config, SessionID: sandboxID, PreferredProviderFamily: ProviderFamilyOpenAI, RequestedModel: model, ProviderID: "", EnvItems: envItems, diff --git a/pkg/llms/dsh_facade_test.go b/pkg/llms/dsh_facade_test.go index 75a4f5e70..5e6ca5295 100644 --- a/pkg/llms/dsh_facade_test.go +++ b/pkg/llms/dsh_facade_test.go @@ -187,3 +187,40 @@ func TestEnsureDshFacadeConfigFollowsChatCompletionsProvider(t *testing.T) { t.Fatalf("saved token = %#v", store.savedTokens) } } + +// TestEnsureDshFacadeConfigRoutesAnthropicProviderNatively covers the third +// family. Pinning chat completions meant an Anthropic provider was bridged +// down; following the provider must not turn that configuration into a hard +// failure, so it is served over the /llm/anthropic route the same way pi +// serves it (piFacadeProtocol). +func TestEnsureDshFacadeConfigRoutesAnthropicProviderNatively(t *testing.T) { + isolateLLMEnv(t) + store := newDshFacadeTestStore() + store.providers = []Provider{{ + ID: "anthropic-gateway", ProviderType: ProviderFamilyAnthropic, + DefaultWireAPI: APIProtocolMessages, BaseURL: "https://anthropic.test", APIKey: "secret", Enabled: true, + }} + store.models = []Model{{ID: "model-id", Name: "claude-opus-5", Enabled: true}} + store.wire["anthropic-gateway\x00model-id"] = APIProtocolMessages + + env, err := EnsureDshFacadeConfig(context.Background(), DshFacadeConfigRequest{ + Config: &appconfig.Config{RuntimeBaseURL: "http://runtime.test/base/"}, + Store: store, + Sandbox: &domain.Sandbox{Summary: domain.SandboxSummary{ID: "sandbox-anthropic"}}, + Model: "anthropic-gateway/claude-opus-5", Source: "agent", RunID: "run-anthropic", + }) + if err != nil { + t.Fatalf("EnsureDshFacadeConfig returned error: %v", err) + } + if env["LLM_API_PROTOCOL"] != APIProtocolMessages || env["DSH_WIRE_API"] != "anthropic-messages" { + t.Fatalf("DSH environment = %#v", env) + } + // The Anthropic client appends /v1/messages itself, so the facade base + // stays at the family root — the same rule piFacadeProtocol follows. + if env["LLM_API_ENDPOINT"] != "http://runtime.test/base/api/runtime/sandboxes/sandbox-anthropic/llm/anthropic" { + t.Fatalf("LLM_API_ENDPOINT = %q", env["LLM_API_ENDPOINT"]) + } + if len(store.savedTokens) != 1 || store.savedTokens[0].WireAPI != APIProtocolMessages { + t.Fatalf("saved token = %#v", store.savedTokens) + } +} diff --git a/pkg/runs/coverage_shape_workflows_test.go b/pkg/runs/coverage_shape_workflows_test.go index 71ef14f81..0a96b49c5 100644 --- a/pkg/runs/coverage_shape_workflows_test.go +++ b/pkg/runs/coverage_shape_workflows_test.go @@ -3240,3 +3240,69 @@ func runAttachOutputToTestProto(output RunAttachOutput) *agentcomposev2.AttachAg } return response } + +// TestPromptAttachProjectorKeepsToolActivityInTheTranscript covers what an +// attach transcript would otherwise lose when the runtime moved to neutral +// events: which tool ran, with what command, and what it printed. Only +// text_delta carries assistant prose, so a transcript built from that alone +// reads as if the model answered without doing anything. +func TestPromptAttachProjectorKeepsToolActivityInTheTranscript(t *testing.T) { + logsPath := filepath.Join(t.TempDir(), "transcript.txt") + projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-tools"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-tools"}}, logsPath, nil) + frames := []string{ + `{"type":"agent_event","event":{"kind":"text_delta","text":"listing the workspace"}}`, + // claude and codex announce the same call twice; the second carries the + // arguments the first did not have yet. + `{"type":"agent_event","event":{"kind":"tool_call","id":"call-1","name":"bash","toolKind":"execute","status":"in_progress"}}`, + `{"type":"agent_event","event":{"kind":"tool_call","id":"call-1","name":"bash","toolKind":"execute","status":"completed","command":"ls -1"}}`, + // codex resends a command's aggregated output on every update. + `{"type":"agent_event","event":{"kind":"tool_result","id":"call-1","ok":true,"output":"hello.txt\n"}}`, + `{"type":"agent_event","event":{"kind":"tool_result","id":"call-1","ok":true,"output":"hello.txt\nnotes.md\n"}}`, + // Reasoning stays out of the transcript on purpose. + `{"type":"agent_event","event":{"kind":"reasoning_delta","text":"thinking about it"}}`, + `{"type":"agent_event","event":{"kind":"text_delta","text":"done"}}`, + } + responses, _, err := projector.Project([]byte(strings.Join(frames, "\n") + "\n")) + if err != nil { + t.Fatalf("project agent events: %v", err) + } + if len(responses) != len(frames) { + t.Fatalf("responses = %d, want %d", len(responses), len(frames)) + } + transcript, err := os.ReadFile(logsPath) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + want := "listing the workspace\n[tool:bash]\n$ ls -1\nhello.txt\nnotes.md\ndone" + if string(transcript) != want { + t.Fatalf("transcript = %q, want %q", string(transcript), want) + } +} + +// TestPromptAttachProjectorNamesFramesByEventKind pins the other half of the +// contract: the frame name is the neutral kind, and a kind with no transcript +// text still reaches the client as an addressable frame. +func TestPromptAttachProjectorNamesFramesByEventKind(t *testing.T) { + logsPath := filepath.Join(t.TempDir(), "transcript.txt") + projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-kinds"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-kinds"}}, logsPath, nil) + frames := []string{ + `{"type":"agent_event","event":{"kind":"usage","scope":"run","inputTokens":4,"outputTokens":104}}`, + `{"type":"agent_event","event":{"kind":"step_end","scope":"run","stopReason":"stop"}}`, + // A raw provider event with no neutral kind stays addressable by type. + `{"type":"agent_event","event":{"type":"thread.started","thread_id":"t-1"}}`, + } + responses, _, err := projector.Project([]byte(strings.Join(frames, "\n") + "\n")) + if err != nil { + t.Fatalf("project agent events: %v", err) + } + names := make([]string, 0, len(responses)) + for _, response := range responses { + names = append(names, response.Name) + } + if strings.Join(names, ",") != "usage,step_end,thread.started" { + t.Fatalf("frame names = %v", names) + } + if data, err := os.ReadFile(logsPath); err == nil && len(data) != 0 { + t.Fatalf("transcript = %q, want no text from these kinds", string(data)) + } +} diff --git a/pkg/runs/prompt_attach_facade.go b/pkg/runs/prompt_attach_facade.go index f218adb8b..26a5f4301 100644 --- a/pkg/runs/prompt_attach_facade.go +++ b/pkg/runs/prompt_attach_facade.go @@ -114,6 +114,10 @@ func (c *Controller) ensurePromptAttachLLMFacadeEnv(ctx context.Context, sandbox return llms.EnsureCodexFacadeConfig(ctx, llms.CodexFacadeConfigRequest{ Config: c.config, Store: store, Sandbox: sandbox, Model: agent.Model, Source: "agent", RunID: runID, }) + case "dsh": + return llms.EnsureDshFacadeConfig(ctx, llms.DshFacadeConfigRequest{ + Config: c.config, Store: store, Sandbox: sandbox, Model: agent.Model, Source: "agent", RunID: runID, + }) default: return nil, nil } diff --git a/pkg/runs/prompt_attach_facade_test.go b/pkg/runs/prompt_attach_facade_test.go index 2be4bdee4..77cf1f94e 100644 --- a/pkg/runs/prompt_attach_facade_test.go +++ b/pkg/runs/prompt_attach_facade_test.go @@ -285,3 +285,94 @@ func TestEnsurePromptAttachLLMFacadeEnvPiUsesSharedRuntimeConfig(t *testing.T) { t.Fatalf("Pi runtime config = %s", data) } } + +// TestEnsurePromptAttachLLMFacadeEnvDshMintsRunScopedFacade guards the pairing +// between promptAttachProviders and this switch: dsh is an accepted attach +// provider, so it must also get a facade environment here. The env +// EnsureDshFacadeConfig builds is per-exec and never persisted onto the +// sandbox, so a missing case leaves the guest with no endpoint, no token and +// no model — the profile falls back to its hardcoded default and the turn +// fails. +func TestEnsurePromptAttachLLMFacadeEnvDshMintsRunScopedFacade(t *testing.T) { + isolatePromptAttachLLMEnv(t) + config := &appconfig.Config{ + RuntimeBaseURL: "http://agent-compose.test:7410", + GuestHomePath: "/root", + } + store := &promptAttachFacadeStore{ + providers: []llms.Provider{{ + ID: "openai-test", + ProviderType: llms.ProviderFamilyOpenAI, + DefaultWireAPI: llms.APIProtocolResponses, + BaseURL: "https://openai.example.test/v1", + APIKey: "openai-key", + Enabled: true, + }}, + models: []llms.Model{{ID: "gpt-test", Name: "gpt-test", DefaultModel: true, Enabled: true}}, + } + sandbox := &domain.Sandbox{Summary: domain.SandboxSummary{ID: "sandbox-dsh-attach", Driver: driver.RuntimeDriverDocker}} + controller := &Controller{config: config, configDB: store} + + env, err := controller.ensurePromptAttachLLMFacadeEnv( + context.Background(), + sandbox, + execution.AgentConfig{Provider: "dsh", Model: "openai-test/gpt-test"}, + "run-dsh-attach", + ) + if err != nil { + t.Fatalf("ensurePromptAttachLLMFacadeEnv returned error: %v", err) + } + if env["DSH_MODEL"] != "gpt-test" || env["DSH_WIRE_API"] != "openai-responses" || + env["LLM_API_PROTOCOL"] != llms.APIProtocolResponses { + t.Fatalf("DSH facade env = %#v", env) + } + if env["LLM_API_ENDPOINT"] != "http://agent-compose.test:7410/api/runtime/sandboxes/sandbox-dsh-attach/llm/openai/v1" { + t.Fatalf("LLM_API_ENDPOINT = %q", env["LLM_API_ENDPOINT"]) + } + if env["AGENT_COMPOSE_SANDBOX_TOKEN"] == "" || env["LLM_API_KEY"] != env["AGENT_COMPOSE_SANDBOX_TOKEN"] || len(store.tokens) != 1 { + t.Fatalf("DSH token env = %#v, saved tokens = %#v", env, store.tokens) + } + if token := store.tokens[0]; token.Model != "gpt-test" || token.ProviderID != "openai-test" || + token.Source != "agent" || token.RunID != "run-dsh-attach" { + t.Fatalf("stored token = %#v", token) + } +} + +// TestPromptAttachProvidersAllHaveFacadeCases is the general form of the bug +// above: every provider prompt attach accepts must resolve a facade +// environment, or its guest starts with no LLM credentials at all. +func TestPromptAttachProvidersAllHaveFacadeCases(t *testing.T) { + isolatePromptAttachLLMEnv(t) + store := &promptAttachFacadeStore{ + providers: []llms.Provider{ + {ID: "openai-test", ProviderType: llms.ProviderFamilyOpenAI, DefaultWireAPI: llms.APIProtocolResponses, BaseURL: "https://openai.example.test/v1", APIKey: "openai-key", Enabled: true}, + {ID: "anthropic-test", ProviderType: llms.ProviderFamilyAnthropic, DefaultWireAPI: llms.APIProtocolMessages, BaseURL: "https://anthropic.example.test", APIKey: "anthropic-key", Enabled: true}, + }, + models: []llms.Model{{ID: "gpt-test", Name: "gpt-test", DefaultModel: true, Enabled: true}}, + } + models := map[string]string{"codex": "gpt-test", "claude": "", "opencode": "openai/gpt-test", "pi": "openai-test/gpt-test", "dsh": "openai-test/gpt-test"} + for provider := range promptAttachProviders { + root := t.TempDir() + sandbox := &domain.Sandbox{Summary: domain.SandboxSummary{ + ID: "sandbox-" + provider, + Driver: driver.RuntimeDriverDocker, + WorkspacePath: filepath.Join(root, "sandbox", "workspace"), + }} + controller := &Controller{ + config: &appconfig.Config{RuntimeBaseURL: "http://agent-compose.test:7410", GuestHomePath: "/root"}, + configDB: store, + } + env, err := controller.ensurePromptAttachLLMFacadeEnv( + context.Background(), + sandbox, + execution.AgentConfig{Provider: provider, Model: models[provider]}, + "run-"+provider, + ) + if err != nil { + t.Fatalf("%s: ensurePromptAttachLLMFacadeEnv returned error: %v", provider, err) + } + if env["AGENT_COMPOSE_SANDBOX_TOKEN"] == "" { + t.Fatalf("%s: prompt attach accepts the provider but mints no facade token: %#v", provider, env) + } + } +} diff --git a/pkg/runs/prompt_projection.go b/pkg/runs/prompt_projection.go index fe0fbca8f..ae6f5a51a 100644 --- a/pkg/runs/prompt_projection.go +++ b/pkg/runs/prompt_projection.go @@ -136,28 +136,94 @@ func (p *promptAttachProjector) projectLine(line []byte) ([]RunAttachOutput, *Tr // runtime agent event. // // The runtime publishes provider-neutral events: the frame name is the event -// kind and only text_delta carries transcript text. Reasoning deliberately -// contributes no text, so a consumer reading just that field never splices the -// model's thinking into the answer. +// kind, and the transcript is assembled from the kinds a reader needs to +// follow the run — assistant prose plus the tool activity that produced it. +// Reasoning deliberately contributes no text, so a consumer reading just that +// field never splices the model's thinking into the answer. func (p *promptAttachProjector) agentEventText(raw json.RawMessage) (string, string) { var event struct { - Kind string `json:"kind"` - Text string `json:"text"` - Type string `json:"type"` + Kind string `json:"kind"` + Text string `json:"text"` + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Command string `json:"command"` + Input json.RawMessage `json:"input"` + Output string `json:"output"` + Error string `json:"error"` } if err := json.Unmarshal(raw, &event); err != nil { return "agent_event", "" } - if event.Kind != "" { - name := event.Kind - if event.Kind == "text_delta" { - return name, event.Text - } - return name, "" + switch event.Kind { + case "": + // Legacy shape: a raw provider event with no neutral kind. Keep the + // frame addressable but contribute nothing to the transcript. + return firstNonEmpty(event.Type, "agent_event"), "" + case "text_delta": + return event.Kind, event.Text + case "tool_call": + return event.Kind, p.newToolText(event.ID, promptAttachToolCallText(event.Name, event.Command, event.Input)) + case "tool_result": + return event.Kind, p.newToolText(event.ID+"\x00result", promptAttachToolResultText(event.Output, event.Error)) + default: + return event.Kind, "" + } +} + +// newToolText returns only the part of text not already written under key. +// +// Tool events are announced more than once and carry cumulative payloads: +// claude re-emits a tool call once its arguments finish streaming, and codex +// resends a command's aggregated output on every update. Without this the +// transcript would repeat each tool's header and output. +func (p *promptAttachProjector) newToolText(key, text string) string { + if text == "" { + return "" + } + previous := p.itemTexts[key] + p.itemTexts[key] = text + if previous != "" && strings.HasPrefix(text, previous) { + return text[len(previous):] + } + return text +} + +// promptAttachToolCallText renders a tool call the way the guest runners' +// own transcript writers do: a named header, then the shell command for an +// execute call or the tool's arguments for anything else. +func promptAttachToolCallText(name, command string, input json.RawMessage) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + header := "\n[tool:" + name + "]\n" + if command = strings.TrimSpace(command); command != "" { + return header + "$ " + command + "\n" + } + if arguments := promptAttachToolInputText(input); arguments != "" { + return header + arguments + "\n" + } + return header +} + +func promptAttachToolInputText(input json.RawMessage) string { + switch trimmed := strings.TrimSpace(string(input)); trimmed { + case "", "null", "{}", `""`: + return "" + default: + return trimmed + } +} + +func promptAttachToolResultText(output, failure string) string { + if failure = strings.TrimSpace(failure); failure == "" { + return output + } + if output != "" && !strings.HasSuffix(output, "\n") { + output += "\n" } - // Legacy shape: a raw provider event with no neutral kind. Keep the frame - // addressable but contribute nothing to the transcript. - return firstNonEmpty(event.Type, "agent_event"), "" + return output + "[tool error] " + failure + "\n" } func (p *promptAttachProjector) appendLogText(text string) error { diff --git a/runtime/javascript/src/agent-event.ts b/runtime/javascript/src/agent-event.ts index b16884d84..6cee5de39 100644 --- a/runtime/javascript/src/agent-event.ts +++ b/runtime/javascript/src/agent-event.ts @@ -11,6 +11,10 @@ * distinguish "did not happen" from "this provider never reports it". * - `inputTokens` always EXCLUDES cached tokens. Providers that report an * inclusive count (codex, gemini) subtract before emitting. + * - A tool call may be announced more than once. claude and codex report a + * call twice (`in_progress` then `completed`), the others once, so anything + * counting or listing tool calls must de-duplicate by `id` — the number of + * `tool_call` events is a provider detail, the number of distinct ids is not. */ /** Tool categories, mirroring ACP's ToolKind minus its editor-only `switch_mode`. */ @@ -48,7 +52,12 @@ export interface TodoItem { export type AgentEvent = | { kind: "step_start"; step?: number } - | { kind: "step_end"; step?: number; stopReason?: AgentStopReason; rawStopReason?: string } + /** + * `scope: "run"` marks a turn/run terminator that closes no individual step + * — claude, gemini and dsh emit one after their last step. Consumers pairing + * step boundaries must ignore it, or every turn gains a phantom step. + */ + | { kind: "step_end"; step?: number; scope?: "step" | "run"; stopReason?: AgentStopReason; rawStopReason?: string } | { kind: "text_delta"; step?: number; blockIndex?: number; text: string } | { kind: "reasoning_delta"; step?: number; blockIndex?: number; text: string } | { @@ -126,7 +135,7 @@ const fetchToolNames = new Set(["fetch", "web_fetch", "web_search", "google_web_ * Classify a provider tool name. Only codex separates shell and patch calls at * the protocol level; every other provider reports them as ordinary tools, so * the name is all we have. Cross-provider counting must therefore key on - * `kind === "tool_call"`, never on the resulting ToolKind. + * `kind === "tool_call"` de-duplicated by `id`, never on the resulting ToolKind. */ export function toolKindForName(name: string): ToolKind { const normalized = String(name || "").trim().toLowerCase(); @@ -158,3 +167,32 @@ export function toolOutputText(value: unknown): string | undefined { return String(value); } } + +/** + * Name the model a run-scope usage record belongs to, given the provider's + * per-model breakdown. + * + * The first key is not the run's model: claude lists its sidecar (haiku) ahead + * of the model that answered, and gemini lists an all-zero entry first, so + * indexing position 0 charges one model with another's tokens. Pick the entry + * that actually spent tokens; when none did, name no model rather than one at + * random. + */ +export function dominantUsageModel( + breakdown: Record, + tokensOf: (entry: Record) => number, +): string | undefined { + let name: string | undefined; + let best = 0; + for (const [key, entry] of Object.entries(breakdown)) { + if (!entry || typeof entry !== "object") { + continue; + } + const tokens = tokensOf(entry as Record); + if (tokens > best) { + name = key; + best = tokens; + } + } + return name; +} diff --git a/runtime/javascript/src/runners/claude.ts b/runtime/javascript/src/runners/claude.ts index df67f6e28..38b0bde44 100644 --- a/runtime/javascript/src/runners/claude.ts +++ b/runtime/javascript/src/runners/claude.ts @@ -5,7 +5,7 @@ import { readStoredThread, writeStoredThread } from "../session-state.js"; import { jsonString } from "../text.js"; import { TranscriptWriter, type TranscriptTextWriter } from "../transcript.js"; import type { AgentEvent } from "../agent-event.js"; -import { toolKindForName, toolOutputText } from "../agent-event.js"; +import { dominantUsageModel, toolKindForName, toolOutputText } from "../agent-event.js"; import type { AgentResult, RunnerOptions, StoredThread } from "../types.js"; import { cancellationRequested } from "../shutdown.js"; @@ -248,7 +248,11 @@ export class ClaudeRunner { this.emit({ kind: "usage", scope: "run", - model: Object.keys(modelUsage)[0], + // Cache tokens are part of the comparison: claude's sidecar model + // can out-spend the answering model on uncached tokens alone. + model: dominantUsageModel(modelUsage, (entry) => + Number(entry.inputTokens ?? 0) + Number(entry.outputTokens ?? 0) + + Number(entry.cacheReadInputTokens ?? 0) + Number(entry.cacheCreationInputTokens ?? 0)), inputTokens: Number(usage.input_tokens ?? 0), outputTokens: Number(usage.output_tokens ?? 0), reasoningTokens: typeof details?.thinking_tokens === "number" ? details.thinking_tokens : undefined, @@ -260,6 +264,7 @@ export class ClaudeRunner { const stopReason = typeof message.stop_reason === "string" ? message.stop_reason : undefined; this.emit({ kind: "step_end", + scope: "run", stopReason: stopReason === "end_turn" ? "stop" : stopReason === "max_tokens" ? "max_tokens" : undefined, rawStopReason: stopReason, }); diff --git a/runtime/javascript/src/runners/dsh.ts b/runtime/javascript/src/runners/dsh.ts index e54143298..84589f85b 100644 --- a/runtime/javascript/src/runners/dsh.ts +++ b/runtime/javascript/src/runners/dsh.ts @@ -313,6 +313,7 @@ export class DshRunner { const kind = firstString(reason, "kind") || "completed"; this.emit({ kind: "step_end", + scope: "run", stopReason: kind === "completed" ? "stop" : kind === "cancelled" ? "cancelled" : kind === "error" ? "error" : undefined, rawStopReason: kind, }); diff --git a/runtime/javascript/src/runners/gemini.ts b/runtime/javascript/src/runners/gemini.ts index 4b39c4266..87f03c481 100644 --- a/runtime/javascript/src/runners/gemini.ts +++ b/runtime/javascript/src/runners/gemini.ts @@ -6,7 +6,7 @@ import { flattenEnvMap } from "../mcp-config.js"; import { extractText, jsonString } from "../text.js"; import { TranscriptWriter } from "../transcript.js"; import type { AgentEvent } from "../agent-event.js"; -import { toolKindForName } from "../agent-event.js"; +import { dominantUsageModel, toolKindForName } from "../agent-event.js"; import type { AgentResult, RunnerOptions } from "../types.js"; import { cancellationRequested } from "../shutdown.js"; import { waitForChildExit } from "../child-process.js"; @@ -80,21 +80,26 @@ export class GeminiRunner { return; } if (type === "result") { - const stats = (event.stats || {}) as Record; - const models = (stats.models || {}) as Record; - // `stats.input_tokens` counts cached tokens too; `stats.input` is the - // uncached remainder, which is what inputTokens means here. - this.emit({ - kind: "usage", - scope: "run", - model: Object.keys(models)[0], - inputTokens: Number(stats.input ?? 0), - outputTokens: Number(stats.output_tokens ?? 0), - cachedTokens: typeof stats.cached === "number" ? stats.cached : undefined, - }); + // A result without stats reports no usage at all. Emitting an all-zero + // record would be the empty placeholder the module contract forbids. + const stats = event.stats && typeof event.stats === "object" ? event.stats as Record : undefined; + if (stats) { + const models = (stats.models || {}) as Record; + // `stats.input_tokens` counts cached tokens too; `stats.input` is the + // uncached remainder, which is what inputTokens means here. + this.emit({ + kind: "usage", + scope: "run", + model: dominantUsageModel(models, (entry) => Number(entry.total_tokens ?? 0)), + inputTokens: Number(stats.input ?? 0), + outputTokens: Number(stats.output_tokens ?? 0), + cachedTokens: typeof stats.cached === "number" ? stats.cached : undefined, + }); + } const errorDetail = event.error as Record | undefined; this.emit({ kind: "step_end", + scope: "run", stopReason: errorDetail ? "error" : "stop", rawStopReason: String(event.status || ""), }); diff --git a/runtime/javascript/src/stream.ts b/runtime/javascript/src/stream.ts index dc9c31eb7..fdd2d95b4 100644 --- a/runtime/javascript/src/stream.ts +++ b/runtime/javascript/src/stream.ts @@ -173,13 +173,26 @@ function emitOutputFrame( const efforts = new Set(["low", "medium", "high", "xhigh", "max"]); -/** Read the optional reasoning effort from a start frame. */ +/** + * Read the optional reasoning effort from a start frame. + * + * The daemon does not send it yet: prompt-attach builds its start frame from + * execution.AgentConfig, which carries no effort, and no agent definition + * field feeds one. `agent-compose-runtime prompt --effort` remains the only + * producer, so this reader exists for the frame protocol, not for a live path. + */ function effortField(frame: StreamFrame): "low" | "medium" | "high" | "xhigh" | "max" | undefined { const value = stringField(frame, "effort") ?? ""; return efforts.has(value) ? value as "low" | "medium" | "high" | "xhigh" | "max" : undefined; } -/** Read the optional skill names from a start frame. */ +/** + * Read the optional skill names from a start frame. + * + * Also unset by the daemon today: prompt-attach never resolves or materialises + * an agent's skills the way AgentRunner.prepareAgentFiles does for a one-shot + * run, so there are no names to send. + */ function skillsField(frame: StreamFrame): string[] | undefined { const value = frame.skills; if (!Array.isArray(value)) { diff --git a/runtime/javascript/test/provider-event-mapping.test.ts b/runtime/javascript/test/provider-event-mapping.test.ts index 3cbd0b59b..9e7ddaa39 100644 --- a/runtime/javascript/test/provider-event-mapping.test.ts +++ b/runtime/javascript/test/provider-event-mapping.test.ts @@ -148,6 +148,43 @@ describe("provider event mapping", () => { } }); + it("attributes run-scope usage to the model that spent the tokens", () => { + // Neither provider lists that model first: claude's fixture leads with the + // haiku sidecar (931/15) and gemini's with an all-zero flash-lite entry, + // so indexing the breakdown by position charges the wrong model. + const claudeUsage = replay("claude").filter((event) => event.kind === "usage"); + expect(claudeUsage.at(-1)).toMatchObject({ scope: "run", model: "claude-opus-5", inputTokens: 4, outputTokens: 104 }); + const geminiUsage = replay("gemini").find((event) => event.kind === "usage"); + expect(geminiUsage).toMatchObject({ model: "gemini-3.1-pro-preview" }); + }); + + it("marks a turn terminator so it is not counted as a step boundary", () => { + // claude, gemini and dsh close the turn with a step-less step_end after + // their last step. Without the scope marker, pairing or counting step + // boundaries invents one phantom step per turn. + for (const provider of ["claude", "gemini", "dsh"] as Provider[]) { + const ends = replay(provider).filter((event) => event.kind === "step_end"); + const terminators = ends.filter((event) => event.scope === "run"); + expect(terminators.length, provider).toBe(1); + expect(terminators[0]?.step, provider).toBeUndefined(); + const starts = replay(provider).filter((event) => event.kind === "step_start"); + expect(ends.length - terminators.length, provider).toBe(starts.length); + } + }); + + it("announces a tool call once per id even when a provider repeats it", () => { + // claude re-emits a call once its arguments finish streaming and codex + // sends both item.started and item.completed, so the event count is a + // provider detail — only the distinct ids are portable. + const perProvider: Record = { + codex: 2, claude: 1, gemini: 1, opencode: 1, pi: 2, dsh: 2, + }; + for (const provider of providers) { + const calls = replay(provider).filter((event) => event.kind === "tool_call"); + expect(new Set(calls.map((event) => event.id)).size, provider).toBe(perProvider[provider]); + } + }); + it("keeps inputTokens exclusive of cached tokens", () => { // codex and gemini report an inclusive prompt count upstream; the mappers // subtract so the field means the same thing everywhere. From 1bd57296f332ce69160f11cd53f8c54dd2076a12 Mon Sep 17 00:00:00 2001 From: winterfx Date: Mon, 7 Sep 2026 15:32:24 +0800 Subject: [PATCH 5/5] fix(runtime): restore dsh reasoning default and the final-text tail Three review findings from the follow-up pass on this PR. The projector reconciled a turn's final text by requiring it to be prefixed by everything written to the transcript. That only holds for the first turn of a run that never used a tool: tool headers, commands and output are interleaved into the same log but never appear in FinalText, and every turn after the first is preceded by another turn's prose. When the prefix check failed the branch returned silently, so a final text whose tail never streamed as a text_delta was dropped from the log. Track assistant prose separately from tool activity and reconcile against the overlap between the two, computed with the KMP prefix function so the cost stays linear. The llm-deepseek row this PR disables carried a static `thinking: enabled` and `reasoningEffort: 'max'`, so every run got them. Its replacement read DSH_REASONING_EFFORT with no fallback, but nothing on a daemon-driven path sets an effort -- neither BuildAgentExecSpec's prompt command nor the prompt-attach start frame carries one -- which moved every dsh run to whatever the route defaults to. Restore 'max' as the fallback. The normalizer feeding it also still collapsed low onto high, which matched llm-deepseek's three-value domain but not the route that consumes it now; low maps through, and medium still rounds up because the route declares no medium and pi-ai throws rather than clamps for a level it was not offered. dshFacadeProtocol also let a family that is neither Anthropic nor OpenAI fall through to the wire-api switch and be pointed at /llm/openai/v1. provider_type has no CHECK constraint, so that is representable; refuse it while minting the config rather than at request time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01A8mXVS9i9vU9osi3pyxc7G --- .../profiles/agent-compose/cordis.patch.yml | 8 +- docs/design/dsh_agent_provider_design.md | 2 +- pkg/llms/dsh_facade.go | 18 +++- pkg/llms/dsh_facade_test.go | 31 +++++++ pkg/runs/coverage_shape_workflows_test.go | 53 +++++++++++ pkg/runs/prompt_projection.go | 87 ++++++++++++++----- runtime/javascript/src/runners/dsh.ts | 9 ++ runtime/javascript/test/dsh-runner.test.ts | 2 +- 8 files changed, 183 insertions(+), 27 deletions(-) diff --git a/assets/.dsh/profiles/agent-compose/cordis.patch.yml b/assets/.dsh/profiles/agent-compose/cordis.patch.yml index fbcc5bce5..c968e078f 100644 --- a/assets/.dsh/profiles/agent-compose/cordis.patch.yml +++ b/assets/.dsh/profiles/agent-compose/cordis.patch.yml @@ -36,7 +36,13 @@ baseURL: !!js process.env.LLM_API_ENDPOINT # DSH_REASONING_EFFORT is normalized by dsh.ts from agent-compose's # effort levels and consumed as the route's default reasoning level. - reasoning: !!js process.env.DSH_REASONING_EFFORT || undefined + # The 'max' fallback is load-bearing rather than a taste call: the + # llm-deepseek row this route replaces carried a static + # `thinking: enabled` + `reasoningEffort: 'max'`, and no daemon-driven + # path sets DSH_REASONING_EFFORT today (neither the prompt exec spec + # nor the prompt-attach start frame carries an effort). Leaving this + # undefined would silently downgrade every dsh run. + reasoning: !!js process.env.DSH_REASONING_EFFORT || 'max' models: - id: !!js process.env.DSH_MODEL || 'deepseek-v4-flash' reasoningEfforts: diff --git a/docs/design/dsh_agent_provider_design.md b/docs/design/dsh_agent_provider_design.md index c9ad64d51..918f1da42 100644 --- a/docs/design/dsh_agent_provider_design.md +++ b/docs/design/dsh_agent_provider_design.md @@ -39,7 +39,7 @@ Env vars aren't unbounded: Linux caps a single `argv`/`envp` string at `MAX_ARG_ | Variable | Set by | Purpose | | --- | --- | --- | | `DSH_MODEL` | `dsh.ts` | Model name (provider routing is resolved host-side; only the model literal crosses) | -| `DSH_REASONING_EFFORT` | `dsh.ts` | agent-compose's 5-level `effort` collapsed to DSH's 2-level `high`/`max` (§6 has no equivalent collapse — this is the reasoning-effort case) | +| `DSH_REASONING_EFFORT` | `dsh.ts` | agent-compose's 5-level `effort` collapsed onto the `low`/`high`/`max` the `llm-pi-ai` route declares (§6 has no equivalent collapse — this is the reasoning-effort case). No daemon-driven path sets an effort today, so the route's `'max'` fallback is what every run actually gets; it preserves the static `thinking: enabled` + `reasoningEffort: 'max'` the replaced `llm-deepseek` row carried | | `DSH_PERMISSION_MODE` | facade config + `dsh.ts` | Always `danger-full-access`; guest sandboxing is the agent-compose sandbox, not a nested DSH one (§5.3/§5.5) | | `DSH_SESSION_ROOT`, `DSH_SESSION_ID`, `DSH_RESUME` | `dsh.ts` | Session persistence and resume (§3.3) | | `DSH_PROMPT_FILE` | `dsh.ts` | Path to the prompt text file `runner.js` reads | diff --git a/pkg/llms/dsh_facade.go b/pkg/llms/dsh_facade.go index 652a6de45..f429d0459 100644 --- a/pkg/llms/dsh_facade.go +++ b/pkg/llms/dsh_facade.go @@ -98,16 +98,26 @@ func EnsureDshFacadeConfig(ctx context.Context, req DshFacadeConfigRequest) (map // // It mirrors piFacadeProtocol, because llm-pi-ai is the same pi-ai adapter: // an Anthropic-family provider is served natively over the /llm/anthropic -// route rather than bridged down to chat completions, so no family is a hard -// error here. Only an OpenAI provider declaring a wire api that is neither -// responses nor chat completions is unroutable. +// route rather than bridged down to chat completions. Two things are +// unroutable: a family that is neither Anthropic nor OpenAI, and an OpenAI +// provider declaring a wire api that is neither responses nor chat +// completions. The family check is not redundant with the wire-api switch — +// provider_type has no CHECK constraint, so an unrecognised family would +// otherwise fall through to the OpenAI branch and be pointed at +// /llm/openai/v1, turning a configuration error into a request-time failure +// further downstream in UpstreamProtocolAndEndpoint. func dshFacadeProtocol(target ResolvedTarget, runtimeBaseURL, sandboxID string) (piAiAPI, facadeProtocol, facadeBaseURL string, err error) { runtimeBaseURL = strings.TrimRight(runtimeBaseURL, "/") - if NormalizeProviderType(target.Provider.ProviderType) == ProviderFamilyAnthropic { + family := NormalizeProviderType(target.Provider.ProviderType) + if family == ProviderFamilyAnthropic { // Same base-path rule as pi: the Anthropic client appends /v1/messages // itself, so the facade base stays at the family root. return "anthropic-messages", APIProtocolMessages, runtimeBaseURL + "/api/runtime/sandboxes/" + sandboxID + "/llm/anthropic", nil } + if family != ProviderFamilyOpenAI { + return "", "", "", domain.ClassifyError(domain.ErrFailedPrecondition, + fmt.Sprintf("dsh does not support llm provider family %q", target.Provider.ProviderType), nil) + } openAIBaseURL := runtimeBaseURL + "/api/runtime/sandboxes/" + sandboxID + "/llm/openai/v1" switch NormalizeWireAPI(target.WireAPI) { case APIProtocolResponses: diff --git a/pkg/llms/dsh_facade_test.go b/pkg/llms/dsh_facade_test.go index 5e6ca5295..c341caed2 100644 --- a/pkg/llms/dsh_facade_test.go +++ b/pkg/llms/dsh_facade_test.go @@ -224,3 +224,34 @@ func TestEnsureDshFacadeConfigRoutesAnthropicProviderNatively(t *testing.T) { t.Fatalf("saved token = %#v", store.savedTokens) } } + +// provider_type is stored without a CHECK constraint, so a family that is +// neither Anthropic nor OpenAI is representable. It has to be refused while +// minting the facade config rather than routed to /llm/openai/v1 and refused +// later, at request time, by UpstreamProtocolAndEndpoint. +func TestEnsureDshFacadeConfigRejectsUnsupportedProviderFamily(t *testing.T) { + isolateLLMEnv(t) + store := newDshFacadeTestStore() + store.providers = []Provider{{ + ID: "gemini-gateway", ProviderType: "gemini", + DefaultWireAPI: APIProtocolChatCompletions, BaseURL: "https://gemini.test", APIKey: "secret", Enabled: true, + }} + store.models = []Model{{ID: "model-id", Name: "gemini-3.1-pro", Enabled: true}} + store.wire["gemini-gateway\x00model-id"] = APIProtocolChatCompletions + + env, err := EnsureDshFacadeConfig(context.Background(), DshFacadeConfigRequest{ + Config: &appconfig.Config{RuntimeBaseURL: "http://runtime.test/base/"}, + Store: store, + Sandbox: &domain.Sandbox{Summary: domain.SandboxSummary{ID: "sandbox-gemini"}}, + Model: "gemini-gateway/gemini-3.1-pro", Source: "agent", RunID: "run-gemini", + }) + if err == nil { + t.Fatalf("EnsureDshFacadeConfig accepted an unsupported family: %#v", env) + } + if !strings.Contains(err.Error(), `llm provider family "gemini"`) { + t.Fatalf("error = %v", err) + } + if len(store.savedTokens) != 0 { + t.Fatalf("minted a token for an unroutable target: %#v", store.savedTokens) + } +} diff --git a/pkg/runs/coverage_shape_workflows_test.go b/pkg/runs/coverage_shape_workflows_test.go index 0a96b49c5..f31bab572 100644 --- a/pkg/runs/coverage_shape_workflows_test.go +++ b/pkg/runs/coverage_shape_workflows_test.go @@ -3306,3 +3306,56 @@ func TestPromptAttachProjectorNamesFramesByEventKind(t *testing.T) { t.Fatalf("transcript = %q, want no text from these kinds", string(data)) } } + +// A turn that used tools splits the assistant's prose around the tool +// activity, so FinalText is not prefixed by everything in the transcript. The +// tail that never streamed as a text_delta still has to land in the log. +func TestPromptAttachProjectorLogsFinalTextTailAfterToolActivity(t *testing.T) { + logsPath := filepath.Join(t.TempDir(), "transcript.txt") + projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-tool-final"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-tool-final"}}, logsPath, nil) + frames := []string{ + `{"type":"agent_event","event":{"kind":"text_delta","text":"looking now."}}`, + `{"type":"agent_event","event":{"kind":"tool_call","id":"call-1","name":"shell","command":"ls"}}`, + `{"type":"agent_event","event":{"kind":"tool_result","id":"call-1","output":"README.md\n"}}`, + `{"type":"agent_turn_completed","finalText":"looking now. found it.","finalTextSource":"provider_message"}`, + } + for _, frame := range frames { + if _, _, err := projector.Project([]byte(frame + "\n")); err != nil { + t.Fatalf("project %s: %v", frame, err) + } + } + transcript, err := os.ReadFile(logsPath) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + if want := "looking now.\n[tool:shell]\n$ ls\nREADME.md\n found it."; string(transcript) != want { + t.Fatalf("transcript = %q, want %q", string(transcript), want) + } +} + +// A second turn's final text is reconciled against its own prose. Keying on +// the whole run's prose would leave every turn after the first unable to match +// its prefix, dropping its unstreamed tail. +func TestPromptAttachProjectorLogsFinalTextTailOnLaterTurn(t *testing.T) { + logsPath := filepath.Join(t.TempDir(), "transcript.txt") + projector := newPromptAttachProjector(domain.ProjectRunRecord{RunID: "run-turn-two"}, &domain.Sandbox{Summary: domain.SandboxSummary{ID: "session-turn-two"}}, logsPath, nil) + if _, _, err := projector.Project([]byte(`{"type":"agent_event","event":{"kind":"text_delta","text":"first answer\n"}}` + "\n")); err != nil { + t.Fatalf("project first answer: %v", err) + } + if _, _, err := projector.Project([]byte(`{"type":"agent_turn_completed","finalText":"first answer\n","finalTextSource":"provider_message"}` + "\n")); err != nil { + t.Fatalf("project first turn completion: %v", err) + } + if err := projector.AppendHumanMessage("next question"); err != nil { + t.Fatalf("append human message: %v", err) + } + if _, _, err := projector.Project([]byte(`{"type":"agent_turn_completed","finalText":"second answer\n","finalTextSource":"provider_message"}` + "\n")); err != nil { + t.Fatalf("project second turn completion: %v", err) + } + transcript, err := os.ReadFile(logsPath) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + if want := "first answer\nnext question\nsecond answer\n"; string(transcript) != want { + t.Fatalf("transcript = %q, want %q", string(transcript), want) + } +} diff --git a/pkg/runs/prompt_projection.go b/pkg/runs/prompt_projection.go index ae6f5a51a..83fc4b33d 100644 --- a/pkg/runs/prompt_projection.go +++ b/pkg/runs/prompt_projection.go @@ -17,7 +17,7 @@ type promptAttachProjector struct { mu sync.Mutex buffer []byte itemTexts map[string]string - loggedText string + loggedProse string turnText string hasLoggedText bool logEndsWithNewline bool @@ -104,7 +104,7 @@ func (p *promptAttachProjector) projectLine(line []byte) ([]RunAttachOutput, *Tr return []RunAttachOutput{runAttachAgentEventResponse("started", "", string(line))}, nil, nil case "agent_event": name, text := p.agentEventText(frame.Event) - if err := p.appendLogText(text); err != nil { + if err := p.appendLogText(text, name == "text_delta"); err != nil { return nil, nil, err } return []RunAttachOutput{runAttachAgentEventResponse(firstNonEmpty(name, "agent_event"), text, string(frame.Event))}, nil, nil @@ -226,7 +226,15 @@ func promptAttachToolResultText(output, failure string) string { return output + "[tool error] " + failure + "\n" } -func (p *promptAttachProjector) appendLogText(text string) error { +// appendLogText writes text to the transcript, tracking assistant prose +// separately from tool activity. +// +// appendLogFinalText reconciles the turn's final text against what the run +// already streamed, and it does that by prefix. Only assistant prose can serve +// as that prefix: tool headers, commands and output are interleaved into the +// same transcript but never appear in FinalText, so counting them would break +// the comparison and silently drop the final text's tail. +func (p *promptAttachProjector) appendLogText(text string, prose bool) error { if text == "" { return nil } @@ -235,39 +243,78 @@ func (p *promptAttachProjector) appendLogText(text string) error { if err := p.appendLogChunkLocked(domain.ExecChunk{Text: text}); err != nil { return err } - p.loggedText += text + if prose { + p.loggedProse += text + } p.turnText += text return nil } +// appendLogFinalText appends the part of the turn's final text that never +// reached the transcript as a text_delta. +// +// Providers that deliver the answer only on the terminal frame land here whole; +// providers that streamed it land here with nothing left to write. Both the +// turn-completed and the result frame carry the same final text, so the second +// one finds the first's work already done and appends nothing. func (p *promptAttachProjector) appendLogFinalText(finalText string) error { if finalText == "" { return nil } p.mu.Lock() defer p.mu.Unlock() - if strings.HasPrefix(finalText, p.loggedText) { - text := finalText[len(p.loggedText):] - if text == "" { - return nil - } - if err := p.appendLogChunkLocked(domain.ExecChunk{Text: text}); err != nil { - return err - } - p.loggedText += text - p.turnText += text + text := finalText[streamedFinalTextOverlap(p.loggedProse, finalText):] + if text == "" { return nil } - if p.loggedText == "" { - if err := p.appendLogChunkLocked(domain.ExecChunk{Text: finalText}); err != nil { - return err - } - p.loggedText = finalText - p.turnText += finalText + if err := p.appendLogChunkLocked(domain.ExecChunk{Text: text}); err != nil { + return err } + p.loggedProse += text + p.turnText += text return nil } +// streamedFinalTextOverlap returns the length of the longest prefix of +// finalText that the prose logged so far ends with. +// +// Requiring finalText to be prefixed by the whole prose log would only hold +// for the first turn of a run that never used a tool: tool activity splits a +// turn's prose in the transcript, and every turn after the first is preceded +// by another turn's prose. Matching the overlap instead keeps the reconciliation +// anchored on what this turn actually streamed, so an unstreamed tail still +// lands in the log. +// +// The overlap is computed with the KMP prefix function over +// finalText + sentinel + the tail of logged, keeping the cost linear in +// len(finalText). The sentinel is only assumed to be rare, not absent, so the +// result is verified before it is trusted. +func streamedFinalTextOverlap(logged, finalText string) int { + if finalText == "" || logged == "" { + return 0 + } + if len(logged) > len(finalText) { + logged = logged[len(logged)-len(finalText):] + } + combined := finalText + "\x00" + logged + failure := make([]int, len(combined)) + for i := 1; i < len(combined); i++ { + length := failure[i-1] + for length > 0 && combined[i] != combined[length] { + length = failure[length-1] + } + if combined[i] == combined[length] { + length++ + } + failure[i] = length + } + overlap := failure[len(combined)-1] + if overlap > len(finalText) || !strings.HasSuffix(logged, finalText[:overlap]) { + return 0 + } + return overlap +} + func (p *promptAttachProjector) AppendHumanMessage(message string) error { return p.AppendHumanMessageFrame(message, "") } diff --git a/runtime/javascript/src/runners/dsh.ts b/runtime/javascript/src/runners/dsh.ts index 84589f85b..ca75ff467 100644 --- a/runtime/javascript/src/runners/dsh.ts +++ b/runtime/javascript/src/runners/dsh.ts @@ -415,9 +415,18 @@ function dshModelName(model: string | undefined): string { return separator >= 0 ? trimmed.slice(separator + 1) : trimmed; } +// Collapses agent-compose's five effort levels onto the ones the profile's +// llm-pi-ai route declares. pi-ai's own domain is wider (off, minimal, low, +// medium, high, xhigh, max), but resolveReasoningLevel throws rather than +// clamps for a level the model does not offer, so the target here is the +// route's reasoningEfforts dict — off, low, high, max — not pi-ai's full range. +// medium therefore still rounds up to high, while low maps straight through: +// the route declares it, and the old llm-deepseek adapter's three-value +// domain, which is what forced low to collapse, is no longer the consumer. function dshReasoningEffort(effort: RunnerOptions["effort"]): string { switch (effort) { case "low": + return "low"; case "medium": case "high": return "high"; diff --git a/runtime/javascript/test/dsh-runner.test.ts b/runtime/javascript/test/dsh-runner.test.ts index 5cfd19fb5..9a6fc6b15 100644 --- a/runtime/javascript/test/dsh-runner.test.ts +++ b/runtime/javascript/test/dsh-runner.test.ts @@ -152,7 +152,7 @@ describe("DshRunner", () => { }); it.each([ - ["low", "high"], + ["low", "low"], ["medium", "high"], ["high", "high"], ["xhigh", "max"],