Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .ralph/agent/tasks.jsonl.lock
Empty file.
2 changes: 2 additions & 0 deletions .ralph/events.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"payload":"All Phase 1 gaps complete. Task 0001 (overarching build task) remains open but no subtasks defined.","topic":"queue.empty","ts":"2026-03-02T12:30:48.404180+00:00"}
{"payload":null,"topic":"release.ready","ts":"2026-03-02T12:45:41.631204+00:00"}
1,085 changes: 1,085 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions src/__tests__/interrupt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { Interrupt, InterruptException, _InterruptState, generateInterruptId } from '../interrupt.js'

describe('Interrupt', () => {
it('creates with required fields', () => {
const interrupt = new Interrupt({ id: 'test-id', name: 'approval' })
expect(interrupt.id).toBe('test-id')
expect(interrupt.name).toBe('approval')
expect(interrupt.reason).toBeUndefined()
expect(interrupt.response).toBeUndefined()
})

it('creates with all fields', () => {
const interrupt = new Interrupt({ id: 'test-id', name: 'approval', reason: 'needs review', response: 'approved' })
expect(interrupt.reason).toBe('needs review')
expect(interrupt.response).toBe('approved')
})

it('serializes to JSON and back', () => {
const interrupt = new Interrupt({ id: 'test-id', name: 'approval', reason: 'needs review' })
const json = interrupt.toJSON()
const restored = Interrupt.fromJSON(json)
expect(restored.id).toBe('test-id')
expect(restored.name).toBe('approval')
expect(restored.reason).toBe('needs review')
})
})

describe('InterruptException', () => {
it('carries the interrupt', () => {
const interrupt = new Interrupt({ id: 'test-id', name: 'approval' })
const error = new InterruptException(interrupt)
expect(error.interrupt).toBe(interrupt)
expect(error.name).toBe('InterruptException')
expect(error.message).toBe('Interrupt: approval')
})
})

describe('_InterruptState', () => {
it('starts deactivated', () => {
const state = new _InterruptState()
expect(state.activated).toBe(false)
expect(state.interrupts.size).toBe(0)
})

it('activates and deactivates', () => {
const state = new _InterruptState()
state.activate()
expect(state.activated).toBe(true)
state.deactivate()
expect(state.activated).toBe(false)
expect(state.interrupts.size).toBe(0)
expect(state.context).toEqual({})
})

it('resumes with responses', () => {
const state = new _InterruptState()
const interrupt = new Interrupt({ id: 'int-1', name: 'approval' })
state.interrupts.set('int-1', interrupt)
state.activate()

state.resume([{ interruptResponse: { interruptId: 'int-1', response: 'yes' } }])
expect(interrupt.response).toBe('yes')
})

it('throws on resume with unknown interrupt id', () => {
const state = new _InterruptState()
state.activate()
expect(() => state.resume([{ interruptResponse: { interruptId: 'unknown', response: 'yes' } }])).toThrow(
'No interrupt found for id: unknown'
)
})

it('does nothing on resume when not activated', () => {
const state = new _InterruptState()
// Should not throw
state.resume([{ interruptResponse: { interruptId: 'int-1', response: 'yes' } }])
})

it('serializes to JSON and back', () => {
const state = new _InterruptState()
const interrupt = new Interrupt({ id: 'int-1', name: 'approval', reason: 'review' })
state.interrupts.set('int-1', interrupt)
state.context = { key: 'value' }
state.activate()

const json = state.toJSON()
const restored = _InterruptState.fromJSON(json as Record<string, unknown>)
expect(restored.activated).toBe(true)
expect(restored.interrupts.get('int-1')?.name).toBe('approval')
expect(restored.context).toEqual({ key: 'value' })
})
})

describe('generateInterruptId', () => {
it('generates deterministic IDs', async () => {
const id1 = await generateInterruptId('tool-1', 'approval')
const id2 = await generateInterruptId('tool-1', 'approval')
expect(id1).toBe(id2)
})

it('generates different IDs for different names', async () => {
const id1 = await generateInterruptId('tool-1', 'approval')
const id2 = await generateInterruptId('tool-1', 'rejection')
expect(id1).not.toBe(id2)
})

it('generates different IDs for different tool use IDs', async () => {
const id1 = await generateInterruptId('tool-1', 'approval')
const id2 = await generateInterruptId('tool-2', 'approval')
expect(id1).not.toBe(id2)
})

it('includes version prefix', async () => {
const id = await generateInterruptId('tool-1', 'approval')
expect(id).toMatch(/^v1:before_tool_call:tool-1:/)
})
})
139 changes: 139 additions & 0 deletions src/agent/__tests__/agent.interrupt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import { Agent } from '../agent.js'
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
import { createMockTool } from '../../__fixtures__/tool-helpers.js'
import { collectGenerator } from '../../__fixtures__/model-test-helpers.js'
import { ToolResultBlock } from '../../types/messages.js'
import { BeforeToolCallEvent, InterruptEvent } from '../../hooks/events.js'

describe('Agent Interrupt Integration', () => {
it('stops the agent loop when a hook raises an interrupt', async () => {
const model = new MockMessageModel().addTurn({
type: 'toolUseBlock',
name: 'deleteTool',
toolUseId: 'tool-1',
input: { key: 'X' },
})

const tool = createMockTool(
'deleteTool',
() => new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [] })
)

const agent = new Agent({ model, tools: [tool], printer: false })
agent.addHook(BeforeToolCallEvent, async (event) => {
if (event.toolUse.name === 'deleteTool') {
await event.interrupt('approval', 'Confirm deletion')
}
})

const result = await agent.invoke('Delete key X')

expect(result.stopReason).toBe('interrupt')
expect(result.interrupts).toBeDefined()
expect(result.interrupts!.length).toBe(1)
expect(result.interrupts![0]!.name).toBe('approval')
expect(result.interrupts![0]!.reason).toBe('Confirm deletion')
})

it('yields InterruptEvent in the stream', async () => {
const model = new MockMessageModel().addTurn({
type: 'toolUseBlock',
name: 'deleteTool',
toolUseId: 'tool-1',
input: {},
})

const tool = createMockTool(
'deleteTool',
() => new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [] })
)

const agent = new Agent({ model, tools: [tool], printer: false })
agent.addHook(BeforeToolCallEvent, async (event) => {
if (event.toolUse.name === 'deleteTool') {
await event.interrupt('approval', 'Confirm')
}
})

const { items } = await collectGenerator(agent.stream('Delete'))
const interruptEvents = items.filter((e): e is InterruptEvent => e instanceof InterruptEvent)
expect(interruptEvents.length).toBe(1)
expect(interruptEvents[0]!.interrupts[0]!.name).toBe('approval')
})

it('resumes from interrupt with response', async () => {
const model = new MockMessageModel()
.addTurn({ type: 'toolUseBlock', name: 'deleteTool', toolUseId: 'tool-1', input: { key: 'X' } })
.addTurn({ type: 'textBlock', text: 'Deleted successfully' })

const tool = createMockTool(
'deleteTool',
() => new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [] })
)

const agent = new Agent({ model, tools: [tool], printer: false })
agent.addHook(BeforeToolCallEvent, async (event) => {
if (event.toolUse.name === 'deleteTool') {
const response = await event.interrupt('approval', 'Confirm deletion')
if (response !== 'approved') {
event.cancel = 'Denied'
}
}
})

const result1 = await agent.invoke('Delete key X')
expect(result1.stopReason).toBe('interrupt')
expect(result1.interrupts!.length).toBe(1)

const interruptId = result1.interrupts![0]!.id

const result2 = await agent.resumeFromInterrupt([{ interruptResponse: { interruptId, response: 'approved' } }])

expect(result2.stopReason).toBe('endTurn')
expect(result2.lastMessage.content[0]!.type).toBe('textBlock')
})

it('cancels tool when cancel is set', async () => {
const model = new MockMessageModel()
.addTurn({ type: 'toolUseBlock', name: 'dangerousTool', toolUseId: 'tool-1', input: {} })
.addTurn({ type: 'textBlock', text: 'Tool was cancelled' })

const tool = createMockTool(
'dangerousTool',
() => new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [] })
)

const agent = new Agent({ model, tools: [tool], printer: false })
agent.addHook(BeforeToolCallEvent, (event) => {
if (event.toolUse.name === 'dangerousTool') {
event.cancel = 'Not allowed'
}
})

const result = await agent.invoke('Do something dangerous')
expect(result.stopReason).toBe('endTurn')
})

it('does not interrupt tools that are not targeted', async () => {
const model = new MockMessageModel()
.addTurn({ type: 'toolUseBlock', name: 'safeTool', toolUseId: 'tool-1', input: {} })
.addTurn({ type: 'textBlock', text: 'Done' })

const tool = createMockTool(
'safeTool',
() => new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [] })
)

const agent = new Agent({ model, tools: [tool], printer: false })
agent.addHook(BeforeToolCallEvent, async (event) => {
if (event.toolUse.name === 'deleteTool') {
await event.interrupt('approval', 'Confirm')
}
})

const result = await agent.invoke('Do safe thing')
expect(result.stopReason).toBe('endTurn')
expect(result.interrupts).toBeUndefined()
})
})
Loading