refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -27,7 +27,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
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.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 `user/message` is the durable evidence) — see the hooks Agent Note.
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
|
||||
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` (a later listener can still block/rewrite) |
|
||||
| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` |
|
||||
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
|
||||
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering |
|
||||
| `Stop` | `agent/stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step |
|
||||
| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into a live in-process child; a remote child has no local injection target |
|
||||
| `SubagentStop` | `subagent/end` (emit) | observe-only |
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
@@ -122,11 +123,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
|
||||
* Writes a `hook/invoked`/`hook/result` pair per hook into the session when one
|
||||
* is available (the mid-turn points always have an open turn). Returns the
|
||||
* merged outcome (a neutral, already-most-restrictive view) for the caller to
|
||||
* map onto its seam decision. `matchQuery` is the event's matcher subject
|
||||
* (tool name, session source, …); `''` for events that ignore matchers.
|
||||
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names
|
||||
* an open turn. Pre-turn `UserPromptSubmit` and detached lifecycle points
|
||||
* omit the pair. Returns the merged outcome (a neutral,
|
||||
* already-most-restrictive view) for the caller to map onto its seam
|
||||
* decision. `matchQuery` is the event's matcher subject (tool name, session
|
||||
* source, …); `''` for events that ignore matchers.
|
||||
*/
|
||||
async function runPoint(
|
||||
point: string,
|
||||
@@ -183,14 +185,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
|
||||
|
||||
/** Build additional model context from hook output, or return undefined when empty. */
|
||||
function contextFrom(merged: MergedHookOutcome): AdditionalContext | undefined {
|
||||
function contextFrom(merged: MergedHookOutcome): UserMessageData | undefined {
|
||||
if (merged.additionalContext.length === 0) return undefined
|
||||
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/** Prepend one context without flattening downstream provenance or metadata. */
|
||||
function prependContext(ours: AdditionalContext, theirs: AdditionalContext[] | undefined): AdditionalContext[] {
|
||||
function prependContext(ours: UserMessageData, theirs: UserMessageData[] | undefined): UserMessageData[] {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
@@ -201,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context) agent.inject(context.content, { source: context.source })
|
||||
if (context) agent.inject({ content: context.content, source: context.source })
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
|
||||
@@ -211,8 +213,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
|
||||
// matcher subject (CC ignores matchers for this event). ---
|
||||
ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise<PromptDecision> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal })
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
}
|
||||
@@ -266,7 +267,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
|
||||
agent.steer({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -277,7 +278,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context && child) child.inject(context.content, { source: context.source })
|
||||
if (context && child) child.inject({ content: context.content, source: context.source })
|
||||
})
|
||||
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
|
||||
})
|
||||
@@ -301,13 +302,11 @@ const SUBAGENT_TYPE = 'general-purpose'
|
||||
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
|
||||
// hook input schema; this is the part a bridge owns. ---
|
||||
|
||||
/** The last (open or just-closed) turn number in the agent's log, or 0. */
|
||||
/** The last open turn number in the agent's log, or 0 without an agent. */
|
||||
function lastTurn(agent: Agent | undefined): number {
|
||||
if (!agent) return 0
|
||||
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
|
||||
/* v8 ignore next -- the `: 0` arm is a defensive fallback: lastTurn is only
|
||||
called from the mid-turn seams (prompt-submit/pre-/post-execute/continuation),
|
||||
which always run inside an open turn, so `last` is always a turn/start here. */
|
||||
/* v8 ignore next -- agent-present callers are tool/stop seams inside an open turn. */
|
||||
return last?.type === 'turn/start' ? last.data.turn : 0
|
||||
}
|
||||
|
||||
|
||||
@@ -93,15 +93,14 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'do something' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt was blocked before the model and before a turn opened.
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
// The hook ran and was recorded.
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true)
|
||||
// Admission has no open turn in which turn-scoped hook provenance could live.
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' || e.type === 'hook/result')).toBe(false)
|
||||
})
|
||||
|
||||
it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => {
|
||||
@@ -115,7 +114,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The injected context reached the model and is recorded with the plugin source.
|
||||
@@ -140,7 +139,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'use danger' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'use danger' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(false)
|
||||
@@ -163,7 +162,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'use safe' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'use safe' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(true)
|
||||
@@ -185,7 +184,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
@@ -206,7 +205,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
@@ -230,7 +229,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError.
|
||||
@@ -255,11 +254,11 @@ describe('hooks-claude bridge — SessionStart', () => {
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// session-start fires async (detached .then → agent.inject); wait for the
|
||||
// injected context/message to actually land before sending, rather than a
|
||||
// injected user/message to actually land before sending, rather than a
|
||||
// fixed sleep that flakes under load.
|
||||
await waitFor(() => events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
|
||||
@@ -352,7 +351,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// The turn ran normally — no hooks, no crash.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
@@ -374,7 +373,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
await fiber.dispose()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
|
||||
|
||||
@@ -74,7 +74,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
return {
|
||||
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
|
||||
@@ -104,7 +104,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true) // substituted command ran
|
||||
})
|
||||
@@ -120,7 +120,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
let sawArgs: unknown
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
|
||||
expect((sawArgs as { command?: string }).command).toBe('original')
|
||||
@@ -136,7 +136,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// The prompt proceeded unchanged; no injected context.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
@@ -166,7 +166,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
@@ -191,7 +191,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
@@ -207,7 +207,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
|
||||
@@ -223,7 +223,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// A second model request ran → the empty-reason block forced continuation.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
@@ -238,7 +238,13 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
// Register a fake child agent under the id the event carries.
|
||||
const injected: string[] = []
|
||||
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
const child = {
|
||||
id: SessionId('child-x'),
|
||||
inject: (input: { content: Array<{ type: string; text?: string }> }) => {
|
||||
injected.push(input.content.map(block => block.text ?? '').join(''))
|
||||
},
|
||||
session: { id: SessionId('child-x'), header: { id: 'child-x' } },
|
||||
} as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
@@ -271,7 +277,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
|
||||
@@ -285,7 +291,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
@@ -314,7 +320,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const adapter = new MockAdapter([textResponse('no')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
@@ -328,7 +334,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// ask (no reason) → degrades to deny with the registry's generic message.
|
||||
expect(ran).toBe(false)
|
||||
@@ -343,7 +349,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
|
||||
@@ -368,7 +374,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
@@ -383,7 +389,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
@@ -398,7 +404,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
@@ -417,7 +423,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
|
||||
@@ -434,7 +440,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
@@ -454,7 +460,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
|
||||
})
|
||||
@@ -472,7 +478,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.followup([{ type: 'text', text: 'go' }])
|
||||
handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(events(handle.agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
|
||||
@@ -490,7 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// A later listener that blocks every prompt (registered AFTER the bridge).
|
||||
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// the downstream block won: the model was never called, no user/message was
|
||||
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
|
||||
@@ -516,7 +522,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
}],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const req = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(req).toContain('from-bridge')
|
||||
@@ -543,7 +549,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
|
||||
@@ -565,7 +571,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
}],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
@@ -587,7 +593,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
@@ -611,7 +617,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
bash.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
|
||||
@@ -634,7 +640,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await waitFor(() => threw)
|
||||
expect(threw).toBe(true)
|
||||
agent.inject = original
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
|
||||
})
|
||||
@@ -661,7 +667,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.followup([{ type: 'text', text: 'go' }])
|
||||
handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
|
||||
@@ -711,7 +717,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
||||
// Not surfaced: the systemMessage text never reaches the model request.
|
||||
@@ -730,7 +736,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// Send immediately — do NOT wait for the session-start inject.
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
|
||||
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` |
|
||||
| `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) |
|
||||
| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
|
||||
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
|
||||
| `Stop` | `agent/stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step |
|
||||
|
||||
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
@@ -102,6 +103,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const detached = createDetachedRuns()
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs')
|
||||
|
||||
/**
|
||||
* Run and fold one configured Codex hook point.
|
||||
*
|
||||
* A supplied turn records the hook provenance pair inside that open turn.
|
||||
* Pre-turn `UserPromptSubmit` and detached lifecycle points omit it.
|
||||
*/
|
||||
async function runPoint(
|
||||
point: string,
|
||||
matchQuery: string,
|
||||
@@ -163,14 +170,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
|
||||
|
||||
function contextFrom(merged: MergedHookOutcome): AdditionalContext | undefined {
|
||||
function contextFrom(merged: MergedHookOutcome): UserMessageData | undefined {
|
||||
if (merged.additionalContext.length === 0) return undefined
|
||||
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/** Prepend one context without flattening downstream provenance or metadata. */
|
||||
function prependContext(ours: AdditionalContext, theirs: AdditionalContext[] | undefined): AdditionalContext[] {
|
||||
function prependContext(ours: UserMessageData, theirs: UserMessageData[] | undefined): UserMessageData[] {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
@@ -181,7 +188,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context) agent.inject(context.content, { source: context.source })
|
||||
if (context) agent.inject({ content: context.content, source: context.source })
|
||||
})
|
||||
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -189,8 +196,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
|
||||
ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise<PromptDecision> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true, signal })
|
||||
const payload = {
|
||||
...base(ctx, agent, 'UserPromptSubmit', model),
|
||||
turn_id: String(lastTurn(agent) + 1),
|
||||
prompt: blocksToText(content),
|
||||
}
|
||||
const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal })
|
||||
/* jscpd:ignore-start */
|
||||
if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
// Context alone is not a veto: DELEGATE so a later prompt-submit listener can
|
||||
@@ -249,7 +260,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// empty stderr) still forces it — fall back to a generic steering line
|
||||
// rather than letting the turn stop.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
|
||||
agent.steer({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -263,9 +274,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
function lastTurn(agent: Agent | undefined): number {
|
||||
if (!agent) return 0
|
||||
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
|
||||
/* v8 ignore next -- the `: 0` arm is a defensive fallback: when an agent is
|
||||
present, lastTurn is only called from the mid-turn seams, which always run
|
||||
inside an open turn, so `last` is always a turn/start here. */
|
||||
/* v8 ignore next -- agent-present turnBase callers are tool/stop seams inside an open turn. */
|
||||
return last?.type === 'turn/start' ? last.data.turn : 0
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('hooks-codex bridge', () => {
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'run ls' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'run ls' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(false)
|
||||
@@ -94,7 +94,7 @@ describe('hooks-codex bridge', () => {
|
||||
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'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
@@ -111,7 +111,7 @@ describe('hooks-codex bridge', () => {
|
||||
const adapter = new MockAdapter([textResponse('must not run')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'cancel the hook' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'cancel the hook' }], source: { kind: 'user' } })
|
||||
await waitFor(() => existsSync(marker))
|
||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('hooks-codex bridge', () => {
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(events(agent).some(event => event.type === 'hook/result' && event.data.point === 'UserPromptSubmit')).toBe(true)
|
||||
expect(events(agent).some(event => event.type === 'hook/invoked' || event.type === 'hook/result')).toBe(false)
|
||||
})
|
||||
|
||||
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
@@ -133,7 +133,7 @@ describe('hooks-codex bridge', () => {
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
@@ -143,7 +143,7 @@ describe('hooks-codex bridge', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
@@ -163,7 +163,7 @@ describe('hooks-codex bridge', () => {
|
||||
await fiber.dispose()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
|
||||
|
||||
@@ -65,7 +65,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
return {
|
||||
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
|
||||
@@ -84,7 +84,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('no')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
@@ -95,7 +95,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
@@ -128,7 +128,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
}],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const req = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(req).toContain('from-bridge')
|
||||
expect(req).toContain('from-downstream')
|
||||
@@ -150,7 +150,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
@@ -170,7 +170,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
}],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
@@ -187,7 +187,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
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('downstream-block'))).toBe(true)
|
||||
@@ -202,7 +202,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
|
||||
})
|
||||
|
||||
@@ -213,7 +213,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const r = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
@@ -226,7 +226,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -240,7 +240,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
|
||||
})
|
||||
|
||||
@@ -251,7 +251,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
|
||||
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
|
||||
@@ -264,7 +264,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
@@ -287,7 +287,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
@@ -310,7 +310,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
|
||||
})
|
||||
@@ -323,7 +323,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
})
|
||||
|
||||
@@ -338,7 +338,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -364,7 +364,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
})
|
||||
|
||||
@@ -377,7 +377,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
|
||||
})
|
||||
@@ -393,7 +393,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
|
||||
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
|
||||
@@ -406,7 +406,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const r = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
|
||||
})
|
||||
@@ -418,7 +418,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const r = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
@@ -435,7 +435,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
|
||||
expect(payload.tool_input.command).toBe('')
|
||||
})
|
||||
@@ -471,7 +471,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
|
||||
})
|
||||
@@ -487,7 +487,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
|
||||
})
|
||||
@@ -500,7 +500,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
|
||||
})
|
||||
|
||||
@@ -528,7 +528,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
|
||||
})
|
||||
@@ -541,7 +541,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
})
|
||||
|
||||
@@ -553,7 +553,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
|
||||
})
|
||||
|
||||
@@ -568,7 +568,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
|
||||
expect(payload.tool_name).toBe('shell')
|
||||
expect(payload.tool_input.command).toBe('ls')
|
||||
@@ -584,7 +584,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(false) // the matcher fired → the hook denied the tool
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
|
||||
})
|
||||
@@ -596,7 +596,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
||||
})
|
||||
@@ -619,7 +619,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.followup([{ type: 'text', text: 'go' }])
|
||||
handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
|
||||
Reference in New Issue
Block a user