Merge remote-tracking branch 'origin/master' into persistence-log-catalog
# Conflicts: # packages/hooks/hook-protocol/README.md # packages/hooks/hook-protocol/src/types.ts
This commit is contained in:
@@ -89,7 +89,7 @@ Source: [`packages/core/session/src/types.ts:235`](../../packages/core/session/s
|
||||
|
||||
#### `hook/invoked` — log-only
|
||||
|
||||
A hook command was invoked at a hook point — log-only provenance (like `compact/*`; NOT a SurfaceEventType, carries no `surfaceOp`). `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point` the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group pattern that selected it (absent for match-all), `handlerId` a stable id for the command (so an invoked/result pair correlates). `turn` is the open turn the invocation lives inside.
|
||||
A hook command was invoked at a hook point — log-only provenance (like `compact/*`; NOT a SurfaceEventType, carries no `surfaceOp`). `dialect` is the bridge that ran it (`claude`/`codex`), `point` the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group pattern that selected it (absent for match-all), `handlerId` a stable id for the command (so an invoked/result pair correlates). `turn` is the open turn the invocation lives inside.
|
||||
|
||||
```ts persistence-catalog
|
||||
'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string }
|
||||
@@ -99,13 +99,13 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:27`](../../packages/hooks/ho
|
||||
|
||||
#### `hook/result` — log-only
|
||||
|
||||
A hook command's outcome — log-only, paired with a prior `hook/invoked` (same `handlerId`). `decision` is the resolved dialect-neutral outcome the bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`), `exitCode` the process exit (absent if it never ran), `stderrSummary` a truncated stderr (the block reason source on exit 2), `durationMs` the wall time. `turn` matches the `hook/invoked`.
|
||||
A hook command's outcome — log-only, paired with a prior `hook/invoked` (same `handlerId`). `decision` is the dialect-neutral outcome derived by `appendHookResult` (which owns the rule): the hook's parsed decision (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to halt via `continue:false`, else `'pass'`. `exitCode` is the process exit (absent if it never ran), `stderrSummary` the trimmed stderr truncated to the bridge's configured cap (the block reason source on exit 2), `durationMs` the wall-clock runtime (audit timing; snapshot replay normalizes it). `turn` matches the `hook/invoked`.
|
||||
|
||||
```ts persistence-catalog
|
||||
'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number }
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hook-protocol/src/types.ts:42`](../../packages/hooks/hook-protocol/src/types.ts)
|
||||
Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../../packages/hooks/hook-protocol/src/types.ts)
|
||||
|
||||
### `prompt/*`
|
||||
|
||||
|
||||
+2
-2
@@ -53,8 +53,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
|---|---|
|
||||
| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
|
||||
| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
|
||||
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
|
||||
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -119,6 +117,8 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Prune write-only fields and a dead routing knob from the fs seam](implemented/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
|
||||
| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
|
||||
| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
|
||||
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
|
||||
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
|
||||
|
||||
### Architecture
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
|
||||
|
||||
**Shared (here):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
|
||||
- **Execution** — `runHook(bash, hook, options, now)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec`, and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
|
||||
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the full CC superset (`continue`/`stopReason`/`suppressOutput`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect.
|
||||
- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
|
||||
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
|
||||
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
|
||||
- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges.
|
||||
- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge.
|
||||
|
||||
**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`).
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics
|
||||
|
||||
Status: implemented (proposed and accepted 2026-07-04)
|
||||
|
||||
## Problem
|
||||
|
||||
Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test:
|
||||
|
||||
1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) had zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere was the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all).
|
||||
2. **`HookOutput.suppressOutput`** (same file) was parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` got silent nothing with no warn.
|
||||
3. **`defaultTimeoutMs` was double-defaulted in both bridge configs with a floating literal** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`), two homes per bridge for one protocol-level constant, so the bridges could silently drift apart on the shared default. *The proposal's original remedy — delete the knob outright — was overtaken by the no-hardcoded-tunables audit, which kept the knob as the explicit bridge-owned config (and added `stderrSummaryMaxChars` beside it); what remained to fix was the literal's home.*
|
||||
4. **The `hook/result` semantics lived in the bridges, twice, not in the lib that owns the event.** `summarize()` — the stderr truncation rule — was byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so was the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declared `hook/result`, documented `stderrSummary` as "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently.
|
||||
|
||||
## What shipped
|
||||
|
||||
`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `hook/result.durationMs` stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so `runHook` keeps its injected `now` clock and `RunHookResult` wrapper, the bridges keep passing the measured duration through `HookResultRecord`, and the snapshot normalizer keeps scrubbing the one nondeterministic field to `0` for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: `defaultTimeoutMs` and `stderrSummaryMaxChars` stay explicit bridge configs, and `RunHookOptions.defaultTimeoutMs` stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms, exported from the runner) and `DEFAULT_STDERR_SUMMARY_MAX_CHARS` (500, exported from the events module), and both bridges' schema defaults and `??` fallbacks read those constants instead of restating the numbers. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput` plus the bridge's `stderrSummaryMaxChars`, and `appendHookResult` derives `stderrSummary` (via the exported `summarizeStderr(stderr, maxChars)`) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`).
|
||||
|
||||
## Why not keep them?
|
||||
|
||||
The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns nothing.
|
||||
- `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer; `durationMs` stays on `hook/result` (and in the fixtures), with the normalizer's replay scrub intact.
|
||||
- Both bridge configs keep `defaultTimeoutMs`/`stderrSummaryMaxChars` (the audit's explicit-tunables shape), but the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`; per-hook `timeoutSec` still overrides the timeout.
|
||||
- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites.
|
||||
|
||||
## Risks
|
||||
|
||||
The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart.
|
||||
@@ -0,0 +1,22 @@
|
||||
# RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback
|
||||
|
||||
Status: implemented (accepted 2026-07-04)
|
||||
|
||||
## Problem
|
||||
|
||||
Two pieces of `dsh-acp` surface were unreachable from any shipped configuration:
|
||||
|
||||
1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model, systemPrompt }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home.
|
||||
2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names".
|
||||
|
||||
## Decision
|
||||
|
||||
`agentInfo` is hardcoded at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`); the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` (whose subject vanished with them) are gone, along with the knob half of the direct-mount config test, the two config rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cells that described the knobs and the name inference. The emitted handshake wire value is unchanged — zero golden churn on the branding half. `toolKindFor` is replaced by the constant `'other'` at both fallback sites (the presenter fallback and `nullToolPresenter`), and the heuristic is deleted with its test rows. The fixed handshake identity stays pinned by the bridge's initialize unit test and by every snapshot golden. On the fallback half the transcript delta shows up in exactly one committed golden: `hook-codex-posttool-block`, whose recorded model omits the required `description` on three `bash` calls, so those cards take the declined-to-present fallback and carry `kind: 'other'` — the honest neutral card for a call the tool would not vouch for.
|
||||
|
||||
## Why not keep them?
|
||||
|
||||
`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO was its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` loses an inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The only shipped paths the heuristic reached were the declined-to-present fallbacks (a throwing `presentCall`, or schema-invalid model args); rendering kind `other` there makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter or a malformed call.
|
||||
|
||||
## Risks
|
||||
|
||||
None beyond the fallback rendering trade described above — degenerate paths whose neutral card is more diagnosable than an inferred first-party one.
|
||||
@@ -1,32 +0,0 @@
|
||||
# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Five pieces of the `dsh-hook-protocol`/bridge contract miss the discipline the [subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these fail the same test:
|
||||
|
||||
1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) has zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere is the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all).
|
||||
2. **`HookOutput.suppressOutput`** (same file) is parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` gets silent nothing with no warn.
|
||||
3. **`hook/result.durationMs`** is durable timing telemetry with no reader. Both bridges write it, and the ACP snapshot normalizer scrubs it to `0` because wall-clock hook runtime is replay noise (`examples/acp-agent/tests/snapshot-normalize.ts`); the remaining consumers are tests and the goldens that exist because the field exists. Deterministic provenance fields (`point`, `matcher`, `turn`, `handlerId`) earn their durability as audit facts; a nondeterministic field that replay must erase and nothing reads earns neither its bytes nor its special-case scrub.
|
||||
4. **`defaultTimeoutMs` is double-defaulted in both bridge configs** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`) — the same two-homes-for-one-literal shape the ACP bridge's `TODO(double-default)` flags, for a knob no shipped config sets; the per-hook `timeoutSec` is the real timeout surface.
|
||||
5. **The `hook/result` semantics live in the bridges, twice, not in the lib that owns the event.** `summarize()` — the 500-character stderr truncation rule — is byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so is the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declares `hook/result`, documents `stderrSummary` as "truncated" without owning the truncation, and documents the decision values without owning the mapping. If one bridge drifts (a different cap, a different fallback), the shared durable event's semantics fork silently.
|
||||
|
||||
## Proposal
|
||||
|
||||
Narrow `HookDialect` to `'claude' | 'codex'` and fix its JSDoc; retarget the lib's one `'native'` test. Drop `suppressOutput` from `HookOutput`, the codec's parse lines, its codec-test assertions, and the parsed-superset lists in the lib README and [hook-protocol-lib RFC](../../implemented/feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../../implemented/AGENTS.md)). Drop `durationMs` from `HookResultRecord`, `RunHookResult`, the `hook/result` event, the bridge appends, the docs/catalog, and the snapshot normalizer's special-case scrub (retiring `runHook`'s injected clock if nothing else needs it); the hook goldens refresh mechanically as the scrubbed field disappears. Replace the bridges' `defaultTimeoutMs` config knob with one shared reference-default constant in `dsh-hook-protocol` (per-hook `timeoutSec` stays the override surface). Move the `hook/result` semantics into the lib: `appendHookResult` (or a helper it exposes) derives `stderrSummary` and the decision string from the `HookOutput` + exit outcome, and both bridges delete their private copies. Rider: un-export `BLOCKING_EXIT_CODE` (zero importers; even the codec tests spell the literal `2`).
|
||||
|
||||
## Why not keep them?
|
||||
|
||||
The [hook-protocol-lib RFC](../../implemented/feature/2026-06-30-hook-protocol-lib.md) deliberately records "parses the full CC superset" — the strongest counterargument is that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; and durable telemetry that replay must scrub is a cost with no buyer. Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events; a trace viewer that reads timings — as live diagnostics or a deliberately durable telemetry event designed for it). On item 5, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns only this RFC's amended references.
|
||||
- `suppressOutput` and `durationMs` appear nowhere in source, parsed-field doc lists, the catalog, or the normalizer; the hook goldens are re-recorded or refreshed without the field.
|
||||
- Both bridge configs lose `defaultTimeoutMs`; the reference default lives once, in the lib; per-hook `timeoutSec` still overrides it.
|
||||
- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`, exercised by both bridges' suites.
|
||||
|
||||
## Risks
|
||||
|
||||
The `dialect`, `suppressOutput`, `defaultTimeoutMs`, and semantics changes are invisible on the wire and in the goldens; the `durationMs` removal churns the hook goldens once (a mechanical refresh — the field was already normalized to a constant). The cost is churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart.
|
||||
@@ -1,27 +0,0 @@
|
||||
# RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Two pieces of `dsh-acp` surface are unreachable from any shipped configuration:
|
||||
|
||||
1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model, systemPrompt }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — can set the knobs at all; they are settable solely by direct-mounting the bridge, which only a unit test does. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carries a live `TODO(double-default)`: the literals exist twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home.
|
||||
2. **The `toolKindFor` name heuristic** (same file) special-cases `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms match ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fall through to `other` anyway. The arms are production-reachable only when a tool's `presentCall` THROWS (the containment fallback) — and the bridge's own module doc states the design rule the heuristic violates: "the bridge never special-cases tool names".
|
||||
|
||||
## Proposal
|
||||
|
||||
Hardcode `agentInfo` at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`), deleting the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` whose subject vanishes; drop the knob half of the direct-mount config test, the two rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cell that cites the knobs. Zero golden churn — the emitted wire value is unchanged. Replace `toolKindFor` with the constant `'other'` in both fallback sites (the presenter fallback and `nullToolPresenter`) and delete the heuristic with its test rows.
|
||||
|
||||
## Why not keep them?
|
||||
|
||||
`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO is its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist today either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` would lose its inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The behavior delta on shipped paths is confined to the presenter-throw fallback, where rendering kind `other` makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `agentName`/`agentVersion` and `toolKindFor` appear only in this RFC; snapshot goldens are byte-identical; bridge tests are green with the constant fallback.
|
||||
- The `initialize` handshake continues to report `deepseek-harness-acp`/`0.0.1` (pinned by the handshake snapshot).
|
||||
|
||||
## Risks
|
||||
|
||||
None beyond the presenter-throw rendering delta described above — an error path whose new behavior is more diagnosable than the old.
|
||||
@@ -79,7 +79,7 @@
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","title":"bash","kind":"execute","status":"in_progress","rawInput":{"command":"echo HELLO"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
|
||||
@@ -99,7 +99,7 @@
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" invoke"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","title":"bash","kind":"execute","status":"in_progress","rawInput":{"command":"printf 'HELLO\\n'"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf 'HELLO\\n'"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Every"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempt"}}}}
|
||||
@@ -139,7 +139,7 @@
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" working"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","title":"bash","kind":"execute","status":"in_progress","rawInput":{"command":"pwd"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"pwd"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"It"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}}
|
||||
|
||||
@@ -12,18 +12,18 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
|
||||
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events) | calls them around each invocation |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
|
||||
|
||||
## Primitives
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
|
||||
## `hook/*` session events
|
||||
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import type { HookOutput } from './types.ts'
|
||||
|
||||
/** The exit code a hook uses to signal a blocking error (stderr → model). */
|
||||
export const BLOCKING_EXIT_CODE = 2
|
||||
const BLOCKING_EXIT_CODE = 2
|
||||
|
||||
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
|
||||
function str(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
@@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
|
||||
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
|
||||
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
|
||||
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
|
||||
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
|
||||
* block as-is — a caller that doesn't key by event opts out of the check.
|
||||
*/
|
||||
@@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
|
||||
if (cont !== undefined) output.continue = cont
|
||||
const stopReason = str(parsed, 'stopReason')
|
||||
if (stopReason !== undefined) output.stopReason = stopReason
|
||||
const suppress = bool(parsed, 'suppressOutput')
|
||||
if (suppress !== undefined) output.suppressOutput = suppress
|
||||
const sysMsg = str(parsed, 'systemMessage')
|
||||
if (sysMsg !== undefined) output.systemMessage = sysMsg
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { HookDialect } from './types.ts'
|
||||
import type { HookDialect, HookOutput } from './types.ts'
|
||||
|
||||
/** What identifies a hook invocation across its invoked/result pair. */
|
||||
export interface HookInvocation {
|
||||
@@ -37,16 +37,42 @@ export interface HookResultRecord {
|
||||
turn: number
|
||||
point: string
|
||||
handlerId: string
|
||||
/** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
|
||||
decision: string
|
||||
/** The process exit code (absent when the hook could not run). */
|
||||
exitCode?: number
|
||||
/** A truncated stderr summary (the block-reason source on exit 2). */
|
||||
stderrSummary?: string
|
||||
/** Wall-clock duration of the run. */
|
||||
/**
|
||||
* The decoded outcome the run produced. {@link appendHookResult} derives the
|
||||
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
|
||||
* event's semantics live here, in the lib that declares it, not per-bridge.
|
||||
*/
|
||||
output: HookOutput
|
||||
/**
|
||||
* Character cap for the derived `stderrSummary`. The bound is the bridge's
|
||||
* to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
|
||||
* {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
|
||||
*/
|
||||
stderrSummaryMaxChars: number
|
||||
/** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
|
||||
* (both bridges' config default). It lives here, once, next to the truncation
|
||||
* rule it bounds, so the bridges cannot drift apart on the shared event's
|
||||
* default cap.
|
||||
*/
|
||||
export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/invoked` provenance event to `session`. */
|
||||
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
|
||||
session.append('hook/invoked', {
|
||||
@@ -59,26 +85,23 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
* Append a `hook/result` outcome event to `session` (pairs with a prior
|
||||
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
|
||||
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
|
||||
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
|
||||
* `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
|
||||
* is omitted when the hook never ran.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
|
||||
export function appendHookResult(session: Session, record: HookResultRecord): void {
|
||||
const { output } = record
|
||||
const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)
|
||||
session.append('hook/result', {
|
||||
turn: record.turn,
|
||||
point: record.point,
|
||||
handlerId: record.handlerId,
|
||||
decision: record.decision,
|
||||
...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
|
||||
...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs: record.durationMs,
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
|
||||
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
|
||||
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`).
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`);
|
||||
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
|
||||
* {@link HookOutput} so the shared event's semantics live in one place.
|
||||
*
|
||||
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
|
||||
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
|
||||
@@ -29,10 +31,10 @@ export type {
|
||||
MatcherMode,
|
||||
} from './types.ts'
|
||||
export { matchesMatcher } from './matcher.ts'
|
||||
export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
|
||||
export { runHook } from './runner.ts'
|
||||
export { parseHookOutput } from './codec.ts'
|
||||
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
export { mergeHookOutputs } from './merge.ts'
|
||||
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
|
||||
export { appendHookInvoked, appendHookResult, summarizeStderr } from './events.ts'
|
||||
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
|
||||
export type { HookInvocation, HookResultRecord } from './events.ts'
|
||||
@@ -17,6 +17,15 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { parseHookOutput } from './codec.ts'
|
||||
import type { CommandHook, HookOutput } from './types.ts'
|
||||
|
||||
/**
|
||||
* The reference default per-hook timeout, in ms (10 minutes) — the value both
|
||||
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
|
||||
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
|
||||
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
|
||||
* override surface.
|
||||
*/
|
||||
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
|
||||
|
||||
/** Everything a single hook invocation needs beyond its command line. */
|
||||
export interface RunHookOptions {
|
||||
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
|
||||
@@ -27,10 +36,14 @@ export interface RunHookOptions {
|
||||
cwd?: string
|
||||
/** Abort signal — cancels the hook run when fired (the parent step aborts). */
|
||||
signal?: AbortSignal
|
||||
/** Default timeout (ms) when the hook config sets none. */
|
||||
defaultTimeoutMs: number
|
||||
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
|
||||
trailingNewline: boolean
|
||||
/**
|
||||
* Timeout applied when the hook's config sets no `timeout` of its own. The
|
||||
* bridge owns the default (its `defaultTimeoutMs` config, reference default
|
||||
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
|
||||
*/
|
||||
defaultTimeoutMs: number
|
||||
/**
|
||||
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
|
||||
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
|
||||
@@ -43,18 +56,20 @@ export interface RunHookOptions {
|
||||
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
|
||||
export interface RunHookResult {
|
||||
output: HookOutput
|
||||
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
|
||||
* decode the result. `now` is injected (a monotonic-ms source) so the duration
|
||||
* is testable without a real clock. The hook's configured `timeoutSec` (wire
|
||||
* unit: seconds) overrides `defaultTimeoutMs`. The command runs with the
|
||||
* dialect's `env` merged after the executor's credential scrub (the trusted-
|
||||
* plugin path). NEVER throws: an infrastructure failure (the executor rejecting)
|
||||
* is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's
|
||||
* merge logic treats it as a non-blocking error rather than crashing the turn.
|
||||
* decode the result into a {@link HookOutput}. The hook's configured
|
||||
* `timeoutSec` (wire unit: seconds) overrides `options.defaultTimeoutMs`.
|
||||
* The command runs with the dialect's `env` merged after the executor's
|
||||
* credential scrub (the trusted-plugin path). NEVER throws: an infrastructure
|
||||
* failure (the executor rejecting) is surfaced as a {@link HookOutput} with
|
||||
* `exitCode: undefined`, so the caller's merge logic treats it as a
|
||||
* non-blocking error rather than crashing the turn. `now` is injected for
|
||||
* testable durations.
|
||||
*/
|
||||
export async function runHook(
|
||||
bash: BashExecutor,
|
||||
|
||||
@@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* A hook command was invoked at a hook point — log-only provenance (like
|
||||
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
|
||||
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
|
||||
* pattern that selected it (absent for match-all), `handlerId` a stable id
|
||||
* for the command (so an invoked/result pair correlates). `turn` is the open
|
||||
@@ -33,11 +33,14 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
/**
|
||||
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
|
||||
* (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
|
||||
* bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
|
||||
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
|
||||
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
|
||||
* time. `turn` matches the `hook/invoked`.
|
||||
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
|
||||
* `appendHookResult` (which owns the rule): the hook's parsed decision
|
||||
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
|
||||
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
|
||||
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
|
||||
* the bridge's configured cap (the block reason source on exit 2),
|
||||
* `durationMs` the wall-clock runtime (audit timing; snapshot replay
|
||||
* normalizes it). `turn` matches the `hook/invoked`.
|
||||
*/
|
||||
'hook/result': {
|
||||
turn: number
|
||||
@@ -51,8 +54,12 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which protocol dialect a hook config / invocation belongs to. */
|
||||
export type HookDialect = 'claude' | 'codex' | 'native'
|
||||
/**
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
|
||||
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
|
||||
* and writes no `hook/*` provenance (see the interception-seams RFC).
|
||||
*/
|
||||
export type HookDialect = 'claude' | 'codex'
|
||||
|
||||
/**
|
||||
* One configured command hook (the `{ type: 'command', command, timeout? }`
|
||||
@@ -113,8 +120,6 @@ export interface HookOutput {
|
||||
continue?: boolean
|
||||
/** Human-readable reason shown when {@link continue} is `false`. */
|
||||
stopReason?: string
|
||||
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
|
||||
suppressOutput?: boolean
|
||||
/**
|
||||
* The neutral blocking decision a hook expressed, folded from the two channels
|
||||
* the reference protocols keep DISTINCT: the legacy top-level `decision`
|
||||
|
||||
@@ -38,13 +38,12 @@ describe('parseHookOutput — exit code semantics', () => {
|
||||
})
|
||||
|
||||
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
|
||||
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
|
||||
it('parses top-level continue/stopReason/systemMessage', () => {
|
||||
const out = parseHookOutput(0, JSON.stringify({
|
||||
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
|
||||
continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up',
|
||||
}), '')
|
||||
expect(out.continue).toBe(false)
|
||||
expect(out.stopReason).toBe('budget exceeded')
|
||||
expect(out.suppressOutput).toBe(true)
|
||||
expect(out.systemMessage).toBe('heads up')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr, type HookOutput } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** A {@link HookOutput} with the required stream fields defaulted. */
|
||||
function output(over: Partial<HookOutput> = {}): HookOutput {
|
||||
return { exitCode: 0, stderr: '', stdout: '', ...over }
|
||||
}
|
||||
|
||||
describe('hook/* session events', () => {
|
||||
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
|
||||
@@ -18,7 +23,7 @@ describe('hook/* session events', () => {
|
||||
|
||||
it('omits matcher when absent (match-all hook)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
|
||||
|
||||
const ev = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
if (ev?.type === 'hook/invoked') {
|
||||
@@ -26,32 +31,72 @@ describe('hook/* session events', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('appendHookResult records the decided outcome, omitting absent optionals', () => {
|
||||
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
|
||||
exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
|
||||
})
|
||||
const full = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (full?.type === 'hook/result') {
|
||||
expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 })
|
||||
expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 })
|
||||
}
|
||||
|
||||
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
|
||||
const session2 = new Session(SessionId('s2'))
|
||||
appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 })
|
||||
appendHookResult(session2, {
|
||||
turn: 1, point: 'Stop', handlerId: 'h3',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
|
||||
})
|
||||
const sparse = [...session2.events].find(e => e.type === 'hook/result')
|
||||
if (sparse?.type === 'hook/result') {
|
||||
expect('exitCode' in sparse.data).toBe(false)
|
||||
expect('stderrSummary' in sparse.data).toBe(false)
|
||||
expect(sparse.data.durationMs).toBe(3)
|
||||
expect(sparse.data.decision).toBe('allow')
|
||||
}
|
||||
})
|
||||
|
||||
it('the decision falls back to stop on continue:false, else pass', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
|
||||
// An explicit decision wins over the continue:false fallback.
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) })
|
||||
|
||||
const decisions = [...session.events]
|
||||
.filter(e => e.type === 'hook/result')
|
||||
.map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : [])
|
||||
expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']])
|
||||
})
|
||||
|
||||
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'long',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
|
||||
})
|
||||
const ev = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (ev?.type === 'hook/result') {
|
||||
expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…')
|
||||
}
|
||||
})
|
||||
|
||||
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'edge',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
|
||||
})
|
||||
const ev = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (ev?.type === 'hook/result') {
|
||||
expect(ev.data.stderrSummary).toBe('y'.repeat(500))
|
||||
}
|
||||
})
|
||||
|
||||
it('an invoked/result pair correlates by handlerId', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
|
||||
|
||||
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
const result = [...session.events].find(e => e.type === 'hook/result')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
|
||||
@@ -89,6 +89,7 @@ describe('runHook — payload + env + stdin plumbing', () => {
|
||||
const { bash, specs } = recordingBash(async () => result())
|
||||
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
|
||||
expect(specs[0]!.timeoutMs).toBe(60000)
|
||||
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
|
||||
})
|
||||
|
||||
it('passes the abort signal through', async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ In a `cordis.yml`:
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
|
||||
|
||||
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -31,10 +31,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -82,8 +83,8 @@ export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
pluginRoot: z.string(),
|
||||
projectDir: z.string(),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
@@ -105,8 +106,9 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
// --- Parse the config ONCE at load. A read/parse failure is contained: the
|
||||
// bridge logs and registers nothing rather than crashing boot (a typo'd path
|
||||
// must not take the agent down). ---
|
||||
@@ -126,8 +128,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
|
||||
@@ -173,10 +173,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...hookEnv ? { env: hookEnv } : {},
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: true,
|
||||
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
|
||||
// different event than the one firing (the schemas key it by event).
|
||||
@@ -190,14 +190,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs,
|
||||
})
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,8 +322,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) defaults the timeout and runs', async () => {
|
||||
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) with only configPath runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
@@ -337,8 +337,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so the
|
||||
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
// the protocol lib's reference default, not a config knob).
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
@@ -32,7 +32,7 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
|
||||
|
||||
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -57,8 +58,8 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
model: z.string().default(''),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
let handlerCounter = 0
|
||||
@@ -78,8 +79,9 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
let parsed: CodexHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
@@ -93,7 +95,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
const model = config.model ?? ''
|
||||
|
||||
async function runPoint(
|
||||
@@ -122,9 +123,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
@@ -149,14 +150,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs,
|
||||
})
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
|
||||
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [
|
||||
@@ -243,7 +243,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
|
||||
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
@@ -16,8 +16,8 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
|---|---|---|
|
||||
| `model` | — | Model name for created agents (must have a registered adapter). |
|
||||
| `systemPrompt` | — | Per-agent system prompt. |
|
||||
| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. |
|
||||
| `agentVersion` | `0.0.1` | Server version reported in `initialize`. |
|
||||
|
||||
The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.
|
||||
|
||||
## ACP method mapping
|
||||
|
||||
@@ -48,7 +48,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card).
|
||||
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview.
|
||||
|
||||
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
|
||||
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
|
||||
|
||||
The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). |
|
||||
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
|
||||
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | From `agentName` / `agentVersion` config. |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
|
||||
| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. |
|
||||
|
||||
### 3b. `clientCapabilities` (consumed by the bridge)
|
||||
@@ -96,7 +96,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
| Feature | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. |
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. |
|
||||
| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. |
|
||||
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
|
||||
| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). |
|
||||
|
||||
@@ -67,7 +67,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -117,10 +117,6 @@ export interface AcpConfig {
|
||||
model?: string
|
||||
/** Per-agent system prompt. */
|
||||
systemPrompt?: string
|
||||
/** Agent/server name reported to the client in `initialize`. */
|
||||
agentName?: string
|
||||
/** Agent/server version reported to the client in `initialize`. */
|
||||
agentVersion?: string
|
||||
/**
|
||||
* Transport stream override. Production omits this (the plugin wires
|
||||
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
|
||||
@@ -134,8 +130,6 @@ export interface AcpConfig {
|
||||
export const Config: Schema<AcpConfig> = Schema.object({
|
||||
model: Schema.string(),
|
||||
systemPrompt: Schema.string(),
|
||||
agentName: Schema.string().default('deepseek-harness-acp'),
|
||||
agentVersion: Schema.string().default('0.0.1'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -209,13 +203,6 @@ interface SessionRecord {
|
||||
* (settle-exactly-once).
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// TODO(double-default): these literals duplicate the Config schema defaults
|
||||
// (`agentName`/`agentVersion` `.default(...)` above). The Loader applies the
|
||||
// schema before apply() runs, so the `??` only fires for direct-apply unit
|
||||
// tests. Pick one home for the default to avoid drift.
|
||||
const agentName = config.agentName ?? 'deepseek-harness-acp'
|
||||
const agentVersion = config.agentVersion ?? '0.0.1'
|
||||
|
||||
// Capture the injected services NOW, during apply(), while we are inside this
|
||||
// plugin's fiber (where `inject` grants access). The ACP method handlers run
|
||||
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
|
||||
@@ -430,7 +417,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
|
||||
return Promise.resolve({
|
||||
protocolVersion,
|
||||
agentInfo: { name: agentName, version: agentVersion },
|
||||
// Fixed server identity: this bridge IS the harness ACP server, so the
|
||||
// branding is a literal, not config (no shipped surface sets it).
|
||||
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
// Baseline prompt blocks only: text plus resource_link rendered as
|
||||
@@ -911,9 +900,11 @@ export class ToolPresenter {
|
||||
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
|
||||
present = undefined
|
||||
}
|
||||
// No tool-owned presentation: fall back to the tool name as the title and the
|
||||
// full parsed args as the raw input (the generic card).
|
||||
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: toolKindFor(name), rawInput: args }
|
||||
// No tool-owned presentation: fall back to the tool name as the title, the
|
||||
// full parsed args as the raw input, and kind `other` (the generic card).
|
||||
// The kind is never sniffed from the name — the bridge does not special-case
|
||||
// tool names; a tool that wants a richer kind declares `presentCall`.
|
||||
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
|
||||
this.pending.set(callId, { name, args, card: view.card })
|
||||
return view
|
||||
}
|
||||
@@ -951,18 +942,10 @@ export class ToolPresenter {
|
||||
* results pass their raw content through unchanged.
|
||||
*/
|
||||
export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
|
||||
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
|
||||
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: 'other', rawInput: parseToolArguments(argsJson) }),
|
||||
result: (_callId, content) => ({ card: 'generic', content }),
|
||||
}
|
||||
|
||||
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
|
||||
function toolKindFor(name: string): ToolCallKind {
|
||||
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
|
||||
if (name === 'read' || name.startsWith('read')) return 'read'
|
||||
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */
|
||||
function parseToolArguments(args: string): unknown {
|
||||
try {
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('acp bridge', () => {
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false })
|
||||
expect(res.agentInfo?.name).toBe('deepseek-harness-acp')
|
||||
expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' })
|
||||
})
|
||||
|
||||
it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => {
|
||||
@@ -148,14 +148,13 @@ describe('acp bridge', () => {
|
||||
await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('honors agentName/agentVersion/systemPrompt config', async () => {
|
||||
it('honors systemPrompt config', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' },
|
||||
config: { systemPrompt: 'be terse' },
|
||||
})
|
||||
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// Create + prompt so the systemPrompt config flows through agentOptions and
|
||||
// reaches the model request.
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
@@ -50,32 +50,34 @@ describe('streamSessionEventUpdate', () => {
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
|
||||
expect(updates).toEqual([{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'bash',
|
||||
kind: 'execute',
|
||||
// The fallback never sniffs a kind from the tool name — even a name a
|
||||
// first-party tool uses (`bash`) renders `other`; kinds are tool-owned
|
||||
// via presentCall.
|
||||
kind: 'other',
|
||||
status: 'in_progress',
|
||||
rawInput: { command: 'ls' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('infers tool kinds: read*/write*/edit*/other', () => {
|
||||
const kind = (name: string): unknown =>
|
||||
updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0]
|
||||
expect((kind('read_file') as { kind: string }).kind).toBe('read')
|
||||
expect((kind('write') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('edit_file') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('frobnicate') as { kind: string }).kind).toBe('other')
|
||||
})
|
||||
|
||||
it('falls back to the raw argument string when tool arguments are not JSON', () => {
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toBe('not json')
|
||||
})
|
||||
|
||||
it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => {
|
||||
// `JSON.parse('')` throws, so without the empty-string guard a zero-arg
|
||||
// call would render `rawInput: ''` via the non-JSON fallback; the guard
|
||||
// normalizes it to `{}`.
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toEqual({})
|
||||
})
|
||||
|
||||
it('maps tool/result to completed/failed tool_call_update with text content', () => {
|
||||
const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }))
|
||||
expect(ok).toEqual([{
|
||||
|
||||
@@ -66,7 +66,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call')
|
||||
const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' })
|
||||
// The inline stand-in declares no presentCall, so the generic fallback
|
||||
// renders kind `other` (kinds are tool-owned; the bridge never sniffs the
|
||||
// name — the REAL dsh-tool-bash test below covers the execute card).
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'other', status: 'in_progress' })
|
||||
expect(toolUpdates).toHaveLength(1)
|
||||
expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user