Merge branch 'master' into worktree-singleexe

This commit is contained in:
Tianyi Cui
2026-07-14 00:07:22 +08:00
committed by GitHub
11 changed files with 244 additions and 71 deletions

View File

@@ -102,16 +102,7 @@ async function callUntilText(
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
}
class LossyReadBashExecutor extends BashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
abstract class TestBashExecutor extends BashExecutor {
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
@@ -122,6 +113,17 @@ class LossyReadBashExecutor extends BashExecutor {
sandboxMode: request.sandboxMode,
}
}
}
class LossyReadBashExecutor extends TestBashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
run(): Promise<BashRunResult> {
return Promise.reject(new Error('not used'))
@@ -1057,7 +1059,7 @@ describe('sandbox rendering', () => {
// it anyway: an executor that reports no sandboxMode (fields never
// advertised) whose task nonetheless carries denial facts must render
// the marker without suggesting a lever the schema does not offer.
class FactsOnlyExecutor extends BashExecutor {
class FactsOnlyExecutor extends TestBashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-facts'),
command: 'fake',
@@ -1068,17 +1070,6 @@ describe('sandbox rendering', () => {
sandbox: { mode: 'read-only', denied: true },
}
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
sandboxMode: request.sandboxMode,
}
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('not used')) }
start(): BashTask { return this.task }
get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined }

View File

@@ -53,6 +53,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
for (const event of CODEX_EVENTS) {
const rawGroups = hooksMap[event]
// Matcher-group parsing remains dialect-local because the supported hook
// shapes and skip reasons differ from Claude Code's.
/* jscpd:ignore-start */
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
@@ -64,6 +67,7 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue }
/* jscpd:ignore-end */
if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue }
if (typeof hook.command !== 'string') continue
// Codex accepts `timeout` or the `timeoutSec` alias.

View File

@@ -153,6 +153,9 @@ export function apply(ctx: Context, config: Config): void {
output.additionalContext = output.stdout
}
outputs.push(output)
// Execution and decision mapping remain in each bridge so dialect
// differences stay explicit at their owning seam.
/* jscpd:ignore-start */
if (output.systemMessage !== undefined) {
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
@@ -202,12 +205,14 @@ export function apply(ctx: Context, config: Config): void {
if (context) agent.inject(context.content, { source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
/* jscpd:ignore-end */
})
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
/* 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
// still block/rewrite, then fold our context onto its decision.
@@ -225,6 +230,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
/* jscpd:ignore-end */
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
})
@@ -232,6 +238,7 @@ export function apply(ctx: Context, config: Config): void {
// PostToolUse → PostToolDecision (block with feedback, or attach context).
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
@@ -257,6 +264,7 @@ export function apply(ctx: Context, config: Config): void {
// loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
/* jscpd:ignore-end */
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
// empty stderr) still forces it — fall back to a generic steering line
@@ -271,6 +279,9 @@ export function apply(ctx: Context, config: Config): void {
// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
// turn-scoped events. ---
// These small payload helpers intentionally remain next to the dialect shape;
// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.
/* jscpd:ignore-start */
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
@@ -283,6 +294,7 @@ function lastTurn(agent: Agent | undefined): number {
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
/* jscpd:ignore-end */
/** Base fields on every Codex payload (no turn_id). */
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {

View File

@@ -88,6 +88,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
}
// Each backend keeps the typed service surface beside its storage hooks;
// extracting these trivial forwards would add an inheritance seam.
/* jscpd:ignore-start */
// --- SessionPersistence service surface (delegated to the coordinator) ---
create(meta: SessionHeader): Promise<void> {
@@ -115,6 +118,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
get inits(): Map<Session, Promise<void>> {
return this.coordinator.inits
}
/* jscpd:ignore-end */
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---

View File

@@ -54,6 +54,16 @@ function chunkEvent(seq: number, turn: number, step: number, chunk: StreamChunk)
let dir: string
let file: string
/** Write a session log file and return its path. */
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
let seq = 1
const events: SessionEvent[] = []
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
const path = join(dir, filename)
writeFileSync(path, sessionJsonl(events, header), 'utf8')
return path
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'llm-replay-spec-'))
file = join(dir, 'session.jsonl')
@@ -391,16 +401,6 @@ describe('parseSessionHeader', () => {
})
describe('loadSessionScripts', () => {
/** Write a session log file and return its path. */
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
let seq = 1
const events: SessionEvent[] = []
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
const path = join(dir, filename)
writeFileSync(path, sessionJsonl(events, header), 'utf8')
return path
}
it('returns one primary script for a single-session scenario', () => {
const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS])
const scripts: SessionScript[] = loadSessionScripts({ file: f })
@@ -508,16 +508,6 @@ describe('installLlmReplay (per-session keying)', () => {
{ type: 'finish', reason: { kind: 'stop' } },
]
/** Write a session log file and return its path. */
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
let seq = 1
const events: SessionEvent[] = []
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
const path = join(dir, filename)
writeFileSync(path, sessionJsonl(events, header), 'utf8')
return path
}
const live = (id: string): GenerateOptions =>
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })

View File

@@ -30,6 +30,21 @@ function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
return { get: name => map.get(name) }
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
return { type, seq: 0, time: 0, data } as SessionEvent
}
@@ -181,12 +196,6 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
}),
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
const [update] = updatesWith(presenter, evt('tool/call', {
@@ -627,21 +636,6 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
// the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
// call-side diff test above.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
@@ -739,14 +733,6 @@ describe('relative-path display titles (bridge relativizes the title against the
// diff paths RAW. Drive it with the REAL fs tools so the title/locations come
// from the shipping presentCall, and pass an ABSOLUTE file path (which a real
// editor forwards). The presenter is pure/args-only; the cwd is known only here.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
const presenter = new ToolPresenter(ctx.tools)
const out: SessionNotification['update'][] = []