Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
#	docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
#	docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
#	docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md
#	docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/agent/README.md
#	packages/core/agent/src/index.ts
#	packages/guard/repeat-tool-guard/src/index.ts
#	packages/hooks/hooks-claude/tests/bridge.spec.ts
#	packages/subagent/subagent-acp/tests/mock-acp-server.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/stdio-agent/src/index.ts
#	packages/ui/stdio-agent/src/stdio-chat.ts
#	packages/ui/stdio-agent/tests/stdio-chat.spec.ts
#	packages/util/brand/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-14 18:51:24 +08:00
513 changed files with 3607 additions and 9725 deletions

View File

@@ -16,10 +16,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[] = []
@@ -91,28 +90,23 @@ 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(SessionId('a1'), { 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')
})
@@ -120,7 +114,6 @@ describe('hooks-codex bridge', () => {
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 a current Codex event that this bridge drops (no crash, no effect).
writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('fine')])
@@ -128,7 +121,6 @@ describe('hooks-codex bridge', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
})
@@ -144,10 +136,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')])
@@ -173,10 +163,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()
@@ -195,14 +183,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

@@ -71,9 +71,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')])
@@ -439,11 +438,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`) }] }] })