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:
@@ -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'))
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }] }])
|
||||
})
|
||||
|
||||
@@ -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`) }] }] })
|
||||
|
||||
Reference in New Issue
Block a user