Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-14 22:17:50 +08:00
672 changed files with 10295 additions and 14200 deletions

View File

@@ -1,16 +1,16 @@
# @deepseek-ai/dsh-hooks-codex
A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping.
A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping.
Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape):
This bridge implements a deliberate subset of Codex's current hook protocol:
- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks.
- **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`.
- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex).
- **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim).
- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve.
- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell).
- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
## Config
@@ -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; 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 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 bridge-supported 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.
@@ -54,8 +54,27 @@ A tool call's payload carries the real `tool_name` (the same value the matcher t
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`).
## Deferred
## Model Experience
**Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands.
### Hook-provided context
**`systemMessage`**: a hook's user-facing warning is logged + warned, not surfaced — there is no user-message channel on these seams yet (only model-facing `additionalContext`).
**What the model sees**: `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction.
### Blocked prompt or tool outcome
**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced.
**Token effect**: Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
## Known Limitations and Deferred Work
- **Unsupported hook events (5 of Codex's current 10):** `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, and `SubagentStop`. Config for these events is silently dropped during parsing. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks).
- **`SessionStart` is partial:** plain stdout and JSON `additionalContext` work, but the hook runs detached, so context can miss the first request (`TODO(session-start-gating)`).
- **`UserPromptSubmit` is partial:** blocking plus plain-stdout or JSON context work, but the common `systemMessage` and `{"continue": false}` controls are not enforced.
- **`PreToolUse` is partial:** blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook.
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`.
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits (`TODO(stop-loop-guard)`).
- **Common payload and output fields are partial:** every mapped event reports `transcript_path: null`, the statically configured `model`, and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior (`TODO(hook-continue-false)`).
- **Config loading and execution are partial:** one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented (`TODO(per-session-hook-config)`). Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics.

View File

@@ -1,17 +1,13 @@
/**
* Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. Codex's
* config format is a SUBSET of Claude Code's: the same event-name → matcher-group
* structure and the same `{ type: 'command', command, timeout?/timeoutSec? }`
* hook shape, but only five events and NO command-string substitution (Codex sets
* no hook env vars and does not expand `${…}`). Non-command hooks (and Codex's
* `async: true` commands) are parsed-and-skipped with a warning.
*
* Parse Codex's five-event hook subset into shared {@link MatcherGroup}s. Only synchronous command
* hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no
* command substitution.
* @module @deepseek-ai/dsh-hooks-codex/config
*/
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
/** The five hook points Codex's engine supports. */
/** The five Codex hook points this bridge supports. */
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
/** A parsed Codex config: event name → its matcher groups (command hooks only). */
@@ -36,11 +32,8 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
}
/**
* Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s.
* Only the five {@link CODEX_EVENTS} are honored; an unknown event is dropped.
* `type !== 'command'` and `async: true` command hooks are skipped (recorded in
* `skipped`). Malformed entries are ignored rather than thrown — a bad config
* must not crash boot. No command substitution (Codex does none).
* Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`.
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
*/

View File

@@ -1,17 +1,11 @@
/**
* `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex
* `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT
* half of the hooks subsystem.
*
* Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points
* (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no
* subagent/notification/compaction), regex-only matchers, snake_case stdin
* payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and
* no command substitution, and a block-only decision model (allow/ask are not
* honored — a hook can only block, never pre-approve). The dialect-agnostic
* primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
* Codex-specific payloads + matcher mode + decision mapping.
*
* Bridge for unmodified Codex command hooks on harness interception seams. It
* supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only
* matchers, snake_case payloads without a trailing newline, no hook environment
* or command substitution, and no pre-tool approval or rewrite path; only
* blocking decisions are honored. Shared execution and parsing live in
* `dsh-hook-protocol`; see the
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
* @module @deepseek-ai/dsh-hooks-codex
*/
@@ -46,7 +40,7 @@ export const inject = ['bash']
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
export interface Config {
/**
* Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
* Path to a Codex `hooks.json`. Process-level: read once at load, a relative
* path resolves against the process launch cwd.
* TODO(per-session-hook-config): per-session project-local discovery from each
* `session/new.cwd` is not yet implemented.
@@ -82,8 +76,7 @@ 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.
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
@@ -116,12 +109,11 @@ export function apply(ctx: Context, config: Config): void {
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the agent's session workspace (the `session/new` cwd), not
// the executor default (the server launch dir) — a hook reading a relative
// file or `pwd` must see the user's project tree. Absent for a no-agent run.
// Run hooks in the agent's session workspace so relative paths address the
// user's project rather than the server launch directory.
const workdir = opts.agent?.session.header.cwd
for (const group of groups) {
// Codex matches with PURE regex (no literal fast path).
// Codex always interprets matchers as regexes; it has no literal fast path.
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
for (const hook of group.hooks) {
const handlerId = nextHandlerId(point)
@@ -137,20 +129,12 @@ export function apply(ctx: Context, config: Config): void {
defaultTimeoutMs,
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
trailingNewline: false, // Codex writes stdin without a trailing newline.
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
}, () => performance.now())
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
// `output.stdout` but only sets `additionalContext` from a JSON
// `hookSpecificOutput`, so fold plain stdout in here and let the shared
// merge + contextFrom path carry it. Gated exactly like the codec's own
// structured-stdout parse: only on a clean `exitCode === 0` (a non-zero
// exit is an error, not context — an `echo x; exit 2` must not inject
// `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured
// hook's raw JSON is never dumped as prose), and never clobbering an
// explicit additionalContext from a JSON block.
// Clean plain stdout becomes context only when no structured context
// exists; nonzero output and raw JSON never leak as prose.
if (opts.plainStdoutAsContext === true && output.exitCode === 0
&& output.additionalContext === undefined
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
@@ -171,11 +155,7 @@ export function apply(ctx: Context, config: Config): void {
return mergeHookOutputs(outputs)
}
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
// a hook's `continue:false`, but no seam below honors it — there is no
// "hard-halt the whole agent" primitive on the interception seams yet. Deferred
// with the loop-guard work; until then a `continue:false` hook keeps its
// per-point effect and the halt request is recorded in `hook/result`, not acted on.
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
if (merged.additionalContext.length === 0) return undefined
@@ -183,25 +163,15 @@ export function apply(ctx: Context, config: Config): void {
return { content, source: PLUGIN_SOURCE }
}
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither. The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
* downstream plugin's text is still correctly framed as plugin context.
*/
/** Merge hook context while retaining this bridge's plugin-level source. */
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
// the model (a slow hook can miss the first request). Gating is a deferred
// loop-level change; the contract is "injected as soon as the hook resolves".
// SessionStart injects plain stdout when its detached hook resolves; a slow
// hook may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
ctx.on('agent/session-start', (agent, source) => {
detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
.then((merged) => {
@@ -212,7 +182,7 @@ export function apply(ctx: Context, config: Config): void {
/* jscpd:ignore-end */
})
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
@@ -263,9 +233,9 @@ export function apply(ctx: Context, config: Config): void {
})
// Stop → ContinuationDecision. A blocking Stop hook forces continuation.
// TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would
// force-continue every step (`stop_hook_active` is always false here); the
// loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
// avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
/* jscpd:ignore-end */

View File

@@ -15,10 +15,9 @@ import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash +
* REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`.
* Codex dialect specifics exercised here: regex matcher (substring), block-only
* decisions, the five-event subset.
* Full-loop Codex bridge tests with a mock model, the real loop and bash
* executor, and shell hooks from a temporary config. Covers regex matching,
* block-only decisions, and the five-event subset.
*/
const dirs: string[] = []
@@ -90,36 +89,30 @@ describe('hooks-codex bridge', () => {
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true)
// recorded under the codex dialect
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
})
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
const dir = configDir()
// Block exactly ONCE (a marker file), then allow — without a one-shot guard a
// hook that always exits 2 would force-continue forever (the deferred
// stop_hook_active loop-guard is the real fix; here we self-limit so the test
// exercises the continue path without looping).
// Block once with a marker; until the loop guard lands, an always-blocking
// hook would never let this test finish.
const marker = join(dir, 'fired')
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
// Step 1 has no tool calls → would stop; the Stop hook forces step 2.
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The Stop hook's reason became next-step steering → a second model request ran.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
})
it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => {
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
const dir = configDir()
const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
// SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect).
writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('fine')])
@@ -127,7 +120,6 @@ describe('hooks-codex bridge', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// Ran normally; the unknown event was dropped at parse.
expect(adapter.requests).toHaveLength(1)
})
@@ -143,10 +135,8 @@ describe('hooks-codex bridge', () => {
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
const dir = configDir()
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it
// would veto the prompt (0 model requests) and log a hook/invoked. After a
// clean dispose the turn must proceed untouched — this fails loudly on a leak
// (a no-op `true` hook would pass even with a leaked listener).
// A leaked listener would let this blocking hook veto the prompt and log an invocation; a
// no-op hook would pass even when leaked.
const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n')
writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
const adapter = new MockAdapter([textResponse('ok')])
@@ -172,10 +162,8 @@ describe('hooks-codex bridge', () => {
const dir = configDir()
const pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
// Record the hook shell's PID and touch the marker FIRST so the test can
// tell "the hook is genuinely mid-run", then sleep far past the suite
// timeout. Dispose must KILL the process (the tracker's abort signal wired
// through this bridge's runPoint), not await its exit.
// Record the PID and marker before sleeping past the suite timeout. Disposal must abort the
// tracked process through `runPoint`, not await its natural exit.
const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
const ctx = new Context()
@@ -194,14 +182,11 @@ describe('hooks-codex bridge', () => {
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await fiber.dispose()
// Quiescence, not just promptness: the drain resolves only after the run
// settled, and the run settles only after the killed process was reaped —
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
// throws ESRCH). An untracked fire-and-forget regression would leave the
// process alive (or unreaped) and fail this deterministically.
// Disposal reaches quiescence only after the aborted run settles and the process is reaped, so
// `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain.
expect(() => process.kill(pid, 0)).toThrow()
// The aborted run resolves as a non-blocking error (runHook never rejects),
// so the drained continuation must NOT have logged a failure.
// runHook resolves an aborted run as a non-blocking error, so draining must
// not log a rejected continuation.
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
})

View File

@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts'
describe('parseCodexConfig', () => {
it('honors only the five Codex events, dropping unknown ones', () => {
it('honors only the five bridge-supported Codex events, dropping the rest', () => {
const { config } = parseCodexConfig({
PreToolUse: [{ hooks: [{ type: 'command', command: 'a.sh' }] }],
SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // not a Codex event
Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // not a Codex event
SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // current Codex event, unsupported by this bridge
Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // unknown to current Codex
})
expect(Object.keys(config)).toEqual(['PreToolUse'])
expect(CODEX_EVENTS).toContain('PreToolUse')
@@ -18,7 +18,7 @@ describe('parseCodexConfig', () => {
Stop: [{ hooks: [{ type: 'command', command: '${NOT_SUBSTITUTED}/s.sh', timeout: 10 }] }],
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'u.sh', timeoutSec: 20 }] }],
})
// Codex does NO substitution — the literal ${…} survives.
// The parser performs no config-time substitution; shell expansion happens later.
expect(config.Stop).toEqual([{ hooks: [{ command: '${NOT_SUBSTITUTED}/s.sh', timeoutSec: 10 }] }])
expect(config.UserPromptSubmit).toEqual([{ hooks: [{ command: 'u.sh', timeoutSec: 20 }] }])
})

View File

@@ -70,9 +70,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
})
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
// Context alone is not a veto: a downstream agent/prompt-submit listener (a
// policy plugin registered after the bridge) must still get to block. The
// bridge delegates via next() and folds its context onto the decision.
// Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
// downstream policy listener can still block.
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('should not run')])
@@ -438,11 +437,9 @@ describe('hooks-codex coverage — decision mapping paths', () => {
})
it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
// The plain-stdout→context fold is gated on exitCode === 0, matching the
// codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so
// an `echo stale; exit 2` here is the exact case the gate guards: without it,
// the non-clean hook's stdout would wrongly inject "stale". A marker lets us
// wait for the detached hook to finish before asserting absence.
// SessionStart cannot block, but non-clean stdout still must not become context. The marker
// waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
// the codec's structured-stdout rule.
const d = dir()
const marker = join(d, 'ran')
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })