87f7c03535
- Delete plugin/.mcp.json; mounting fleet is now the instance's or the project's job, not the plugin's. The setup skill still writes the project .mcp.json entry. - register.js: read FLEETD_MCP_URL via $.env.get, defaulting to http://127.0.0.1:8765/mcp, documented at https://code.claude.com/docs/en/plugins/mods/api.md ("$.env — get and set environment variables"). - Gate the fleet_inbox poll by role: a worker or architect learns its role once from fleet_whoami and then never polls fleet_inbox; every other role polls as before. An unreachable daemon retries whoami on a later tick rather than deciding. - Bump 0.2.0 -> 0.3.0 in plugin.json and marketplace.json, and update README/SKILL.md to drop every claim that the plugin mounts fleetd. - Add 6 tests for the role gating; all 21 tests and `claude plugin validate` pass.
491 lines
20 KiB
TypeScript
491 lines
20 KiB
TypeScript
import { expect, mock, test } from 'claude-code/testing'
|
|
|
|
// The store-backed tests write the row another session would write, because a
|
|
// test cannot start a second session.
|
|
|
|
const PEER = 'peer-session'
|
|
const SELF = 'this-session'
|
|
// A fixed clock keeps the staleness arithmetic exact.
|
|
const NOW = 1_700_000_000_000
|
|
|
|
/**
|
|
* Answer the mods API calls the harness has no implementation for, and stand in
|
|
* for Claude Code's own behaviour beneath the mod's gating hooks.
|
|
*
|
|
* Returns the map backing $.store. The harness puts no `store` namespace on the
|
|
* test's own `$`, so a test seeds and inspects the carrier through this map.
|
|
*/
|
|
function stubEngine(on: any): Map<string, any> {
|
|
const store = new Map<string, any>()
|
|
on('store.get', (_$: any, e: any) => ({ value: store.get(e.key) }))
|
|
on('store.set', (_$: any, e: any) => {
|
|
store.set(e.key, e.value)
|
|
return { value: undefined }
|
|
})
|
|
on('store.delete', (_$: any, e: any) => {
|
|
store.delete(e.key)
|
|
return { value: undefined }
|
|
})
|
|
on('store.keys', () => ({ value: [...store.keys()] }))
|
|
on('clock.now', () => ({ value: NOW }))
|
|
on('session.id', () => ({ value: SELF }))
|
|
on('session.cwd', () => ({ value: '/Users/x/claude-bridge' }))
|
|
on('ui.log', () => ({ value: undefined }))
|
|
on('prompt.submit', () => ({ value: undefined }))
|
|
on('env.get', () => ({ value: undefined }))
|
|
// Claude Code's own delivery, which the mod's receive hook must reach.
|
|
on('session.receive', (_$: any, e: any) => e)
|
|
return store
|
|
}
|
|
|
|
/** The presence row a session on the other account would write. */
|
|
function announce(store: Map<string, any>, sessionId: string, cwd: string, at: number) {
|
|
store.set('presence:' + sessionId, { sessionId, cwd, at })
|
|
}
|
|
|
|
test('/fleet-peers lists a session that announced itself', async ($, on) => {
|
|
const store = stubEngine(on)
|
|
announce(store, PEER, '/Users/x/work-repo', NOW)
|
|
|
|
const answer = await $.command.run({ command: 'fleet-peers', args: '' })
|
|
expect(answer.text).toContain(PEER)
|
|
expect(answer.text).toContain('/Users/x/work-repo')
|
|
})
|
|
|
|
test('/fleet-peers hides a session whose presence row went stale', async ($, on) => {
|
|
const store = stubEngine(on)
|
|
// One second past the 60s staleness cut.
|
|
announce(store, PEER, '/Users/x/work-repo', NOW - 61_000)
|
|
|
|
const answer = await $.command.run({ command: 'fleet-peers', args: '' })
|
|
expect(answer.text).not.toContain(PEER)
|
|
})
|
|
|
|
test('/fleet-peers keeps a row one second inside the staleness cut', async ($, on) => {
|
|
// The positive control for the test above: without this, a bug that hid
|
|
// every row would still satisfy that assertion.
|
|
const store = stubEngine(on)
|
|
announce(store, PEER, '/Users/x/work-repo', NOW - 59_000)
|
|
|
|
const answer = await $.command.run({ command: 'fleet-peers', args: '' })
|
|
expect(answer.text).toContain(PEER)
|
|
})
|
|
|
|
test('/fleet-mail queues a message in the target session inbox', async ($, on) => {
|
|
const store = stubEngine(on)
|
|
announce(store, PEER, '/Users/x/work-repo', NOW)
|
|
|
|
const answer = await $.command.run({
|
|
command: 'fleet-mail',
|
|
args: PEER + ' rebasing on main is safe now',
|
|
})
|
|
expect(answer.text).toContain('Queued for ' + PEER)
|
|
|
|
const inbox = store.get('inbox:' + PEER)
|
|
expect(inbox.length).toBe(1)
|
|
expect(inbox[0].text).toBe('rebasing on main is safe now')
|
|
expect(inbox[0].from).toBe(SELF)
|
|
})
|
|
|
|
test('a second message appends rather than replacing the first', async ($, on) => {
|
|
const store = stubEngine(on)
|
|
announce(store, PEER, '/Users/x/work-repo', NOW)
|
|
|
|
await $.command.run({ command: 'fleet-mail', args: PEER + ' first' })
|
|
await $.command.run({ command: 'fleet-mail', args: PEER + ' second' })
|
|
|
|
const inbox = store.get('inbox:' + PEER)
|
|
expect(inbox.length).toBe(2)
|
|
expect(inbox[0].text).toBe('first')
|
|
expect(inbox[1].text).toBe('second')
|
|
})
|
|
|
|
test('/fleet-mail refuses a target that never announced itself', async ($, on) => {
|
|
const store = stubEngine(on)
|
|
const answer = await $.command.run({ command: 'fleet-mail', args: 'ghost-session hello' })
|
|
expect(answer.text).toContain('No live fleet session ghost-session')
|
|
// Nothing may be queued for a session we could not confirm.
|
|
expect(store.get('inbox:ghost-session')).toBe(undefined)
|
|
})
|
|
|
|
test('/fleet-mail rejects input with no message text', async ($, on) => {
|
|
const store = stubEngine(on)
|
|
announce(store, PEER, '/Users/x/work-repo', NOW)
|
|
|
|
for (const args of ['', PEER, PEER + ' ']) {
|
|
const answer = await $.command.run({ command: 'fleet-mail', args })
|
|
expect(answer.text).toContain('Usage: /fleet-mail')
|
|
}
|
|
expect(store.get('inbox:' + PEER)).toBe(undefined)
|
|
})
|
|
|
|
test('an inbound peer message is passed on, not consumed', async ($, on) => {
|
|
// A receive hook that withheld a message would silently break Claude Code's
|
|
// own channel, so assert this mod stays transparent.
|
|
stubEngine(on)
|
|
const result = await $.session.receive({
|
|
text: 'from the other session',
|
|
origin: { kind: 'peer' },
|
|
})
|
|
expect(result?.consumed).toBe(undefined)
|
|
})
|
|
|
|
/** Answer fleetd's three MCP requests the way the live daemon does. */
|
|
function stubFleetd(on: any, toolText: string, seen: any[]) {
|
|
on('http.fetch', (_$: any, e: any) => {
|
|
const body = JSON.parse(e.init.body)
|
|
seen.push({ method: body.method, session: e.init.headers['Mcp-Session-Id'] })
|
|
if (body.method === 'initialize') {
|
|
return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-1' }, text: '{}' } }
|
|
}
|
|
if (body.method === 'tools/call') {
|
|
const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text: toolText }] } }
|
|
return { value: { ok: true, status: 200, headers: {}, text: 'event: message\ndata: ' + JSON.stringify(result) + '\n' } }
|
|
}
|
|
return { value: { ok: true, status: 202, headers: {}, text: '' } }
|
|
})
|
|
}
|
|
|
|
test('/fleet-whoami reads the tool result out of the event stream', async ($, on) => {
|
|
stubEngine(on)
|
|
const seen: any[] = []
|
|
stubFleetd(on, '{"role":"observer","sessionId":"term_x"}', seen)
|
|
|
|
const answer = await $.command.run({ command: 'fleet-whoami', args: '' })
|
|
expect(answer.text).toBe('fleetd says: {"role":"observer","sessionId":"term_x"}')
|
|
// The session id from initialize must ride on every later request.
|
|
expect(seen.map((r) => r.method)).toEqual(['initialize', 'notifications/initialized', 'tools/call'])
|
|
expect(seen[2].session).toBe('sid-1')
|
|
})
|
|
|
|
test('/fleet-whoami reports a daemon that is down instead of throwing', async ($, on) => {
|
|
stubEngine(on)
|
|
on('http.fetch', () => ({ value: { ok: false, status: 503, headers: {}, text: '' } }))
|
|
|
|
const answer = await $.command.run({ command: 'fleet-whoami', args: '' })
|
|
expect(answer.text).toBe('fleetd unreachable: fleetd initialize failed with status 503')
|
|
})
|
|
|
|
/**
|
|
* The session.start hook's own calls, for a test that fires it. Kept apart from stubEngine
|
|
* because a timer test drives $.clock through mock.clock(on) instead of a fixed clock.now.
|
|
*/
|
|
function stubSessionStart(on: any, submitted: string[]): Map<string, any> {
|
|
const store = new Map<string, any>()
|
|
on('store.get', (_$: any, e: any) => ({ value: store.get(e.key) }))
|
|
on('store.set', (_$: any, e: any) => {
|
|
store.set(e.key, e.value)
|
|
return { value: undefined }
|
|
})
|
|
on('store.keys', () => ({ value: [...store.keys()] }))
|
|
on('session.id', () => ({ value: SELF }))
|
|
on('session.cwd', () => ({ value: '/Users/x/claude-bridge' }))
|
|
on('command.register', () => ({ value: undefined }))
|
|
on('ui.log', () => ({ value: undefined }))
|
|
on('env.get', () => ({ value: undefined }))
|
|
// The engine skips a prompt.submit hook that answers anything but { text } or { drop }, and
|
|
// the mod's callback then throws, so this must hand the text straight back.
|
|
on('prompt.submit', (_$: any, e: any) => {
|
|
submitted.push(e.text)
|
|
return { text: e.text }
|
|
})
|
|
on('session.start', (_$: any, e: any) => e)
|
|
return store
|
|
}
|
|
|
|
/**
|
|
* Answer fleetd's MCP requests, with the tool result read fresh on every call.
|
|
*
|
|
* `fetches` collects every method sent, with a tools/call entry naming its tool (such as
|
|
* `tools/call:fleet_inbox`), and `sessions` the Mcp-Session-Id of each tools/call. Each
|
|
* initialize hands out the next id, so a reused session and a reopened one differ.
|
|
* `sessionGone` makes a tools/call answer the way fleetd answers for a session id it no
|
|
* longer holds. `whoamiText` answers a `fleet_whoami` call apart from `toolText`, which
|
|
* answers every other tool.
|
|
*/
|
|
function stubFleetdDynamic(
|
|
on: any,
|
|
toolText: () => string,
|
|
fetches: string[],
|
|
sessions: string[] = [],
|
|
sessionGone: () => boolean = () => false,
|
|
whoamiText: () => string = () => '{"role":"primary"}',
|
|
) {
|
|
let opened = 0
|
|
on('http.fetch', (_$: any, e: any) => {
|
|
const body = JSON.parse(e.init.body)
|
|
if (body.method === 'initialize') {
|
|
fetches.push(body.method)
|
|
opened += 1
|
|
return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-' + opened }, text: '{}' } }
|
|
}
|
|
if (body.method === 'tools/call') {
|
|
fetches.push(body.method + ':' + body.params.name)
|
|
sessions.push(e.init.headers['Mcp-Session-Id'])
|
|
if (sessionGone()) {
|
|
const gone = '{"jsonRpcError":{"code":-32603,"message":"Session not found"}}'
|
|
return { value: { ok: false, status: 404, headers: {}, text: gone } }
|
|
}
|
|
const text = body.params.name === 'fleet_whoami' ? whoamiText() : toolText()
|
|
const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text }] } }
|
|
return { value: { ok: true, status: 200, headers: {}, text: 'data: ' + JSON.stringify(result) + '\n' } }
|
|
}
|
|
fetches.push(body.method)
|
|
return { value: { ok: true, status: 202, headers: {}, text: '' } }
|
|
})
|
|
}
|
|
|
|
/** How many of `fetches` were an initialize. */
|
|
function initializes(fetches: string[]): number {
|
|
return fetches.filter((m) => m === 'initialize').length
|
|
}
|
|
|
|
test('the fleetd inbox poll submits each collected message and names the sender', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
let inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), [])
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
// An empty inbox is the ordinary answer and must submit nothing.
|
|
await clock.advance(3_000)
|
|
expect(submitted.length).toBe(0)
|
|
|
|
// The control for the line above: the same timer, the same stubs, with mail waiting.
|
|
inbox = { sessionId: 'term_self', count: 2, messages: ['rebase is safe now', 'build is green'] }
|
|
await clock.advance(3_000)
|
|
|
|
expect(submitted.length).toBe(2)
|
|
expect(submitted[0]).toContain('rebase is safe now')
|
|
expect(submitted[0]).toContain('fleetd')
|
|
expect(submitted[1]).toContain('build is green')
|
|
})
|
|
|
|
test('a collected message is not submitted a second time', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
// fleetd removes a message when it hands it over, so the next poll answers empty.
|
|
let inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), [])
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
expect(submitted.length).toBe(1) // control: the first poll really did deliver it
|
|
|
|
inbox = { sessionId: 'term_self', count: 0, messages: [] }
|
|
await clock.advance(3_000)
|
|
expect(submitted.length).toBe(1)
|
|
})
|
|
|
|
test('a fleetd that is down leaves the poll timer running', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
on('http.fetch', (_$: any, e: any) => {
|
|
fetches.push(JSON.parse(e.init.body).method)
|
|
return { value: { ok: false, status: 503, headers: {}, text: '' } }
|
|
})
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
const afterFirst = fetches.length
|
|
expect(afterFirst).toBeGreaterThan(0)
|
|
expect(submitted.length).toBe(0)
|
|
|
|
// A throw out of the timer would stop it, so a second tick that still reaches fleetd is the
|
|
// positive control for the assertion above: the timer survived the failure.
|
|
await clock.advance(3_000)
|
|
expect(fetches.length).toBeGreaterThan(afterFirst)
|
|
expect(submitted.length).toBe(0)
|
|
})
|
|
|
|
test('a second inbox poll reuses the first MCP session', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const sessions: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, sessions)
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
await clock.advance(3_000)
|
|
|
|
// The first poll also reads the role once, so it makes two tools/call (whoami, then
|
|
// inbox); the second poll already knows the role and makes only one (inbox).
|
|
expect(sessions.length).toBe(3) // control: all three calls really reached fleetd
|
|
expect(initializes(fetches)).toBe(1)
|
|
expect(sessions.every((s) => s === sessions[0])).toBe(true)
|
|
})
|
|
|
|
test('a session fleetd no longer holds is opened again and the call retried', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const sessions: string[] = []
|
|
let gone = false
|
|
const inbox = { sessionId: 'term_self', count: 1, messages: ['the daemon restarted'] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, sessions, () => {
|
|
// Only the first call after the flag is set is refused; the retry succeeds.
|
|
const refuse = gone
|
|
gone = false
|
|
return refuse
|
|
})
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
// The control: an ordinary poll opens one session and needs no second one.
|
|
await clock.advance(3_000)
|
|
expect(initializes(fetches)).toBe(1)
|
|
expect(submitted.length).toBe(1)
|
|
|
|
gone = true
|
|
await clock.advance(3_000)
|
|
|
|
expect(initializes(fetches)).toBe(2)
|
|
expect(sessions[sessions.length - 1]).toBe('sid-2')
|
|
expect(submitted.length).toBe(2)
|
|
})
|
|
|
|
/** How many of `fetches` were a tools/call for the named tool. */
|
|
function toolCalls(fetches: string[], tool: string): number {
|
|
return fetches.filter((m) => m === 'tools/call:' + tool).length
|
|
}
|
|
|
|
test('a worker role stops the poll from calling fleet_inbox', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"worker"}')
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
await clock.advance(3_000)
|
|
|
|
expect(toolCalls(fetches, 'fleet_whoami')).toBe(1)
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(0)
|
|
expect(submitted.length).toBe(0)
|
|
})
|
|
|
|
test('an architect role stops the poll from calling fleet_inbox', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"architect"}')
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
await clock.advance(3_000)
|
|
|
|
expect(toolCalls(fetches, 'fleet_whoami')).toBe(1)
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(0)
|
|
expect(submitted.length).toBe(0)
|
|
})
|
|
|
|
test('a primary role keeps the poll calling fleet_inbox', async ($, on) => {
|
|
// The positive control for the two tests above: the same stubs, a role neither
|
|
// gates, so a bug that silenced every role would still pass them.
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"primary"}')
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(1)
|
|
expect(submitted.length).toBe(1)
|
|
})
|
|
|
|
test('an observer role keeps the poll calling fleet_inbox', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"observer"}')
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(1)
|
|
expect(submitted.length).toBe(1)
|
|
})
|
|
|
|
test('a whoami that fails on the first tick is retried and the poll resumes', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 1, messages: ['do the task'] }
|
|
let whoamiFails = true
|
|
on('http.fetch', (_$: any, e: any) => {
|
|
const body = JSON.parse(e.init.body)
|
|
if (body.method === 'tools/call' && body.params.name === 'fleet_whoami' && whoamiFails) {
|
|
fetches.push(body.method + ':' + body.params.name)
|
|
return { value: { ok: false, status: 503, headers: {}, text: '' } }
|
|
}
|
|
if (body.method === 'initialize') {
|
|
fetches.push(body.method)
|
|
return { value: { ok: true, status: 200, headers: { 'mcp-session-id': 'sid-1' }, text: '{}' } }
|
|
}
|
|
if (body.method === 'tools/call') {
|
|
fetches.push(body.method + ':' + body.params.name)
|
|
const text = body.params.name === 'fleet_whoami' ? '{"role":"observer"}' : JSON.stringify(inbox)
|
|
const result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text }] } }
|
|
return { value: { ok: true, status: 200, headers: {}, text: 'data: ' + JSON.stringify(result) + '\n' } }
|
|
}
|
|
fetches.push(body.method)
|
|
return { value: { ok: true, status: 202, headers: {}, text: '' } }
|
|
})
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(0) // control: a failed whoami calls no fleet_inbox
|
|
expect(submitted.length).toBe(0)
|
|
|
|
whoamiFails = false
|
|
await clock.advance(3_000)
|
|
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(1)
|
|
expect(submitted.length).toBe(1)
|
|
})
|
|
|
|
test('whoami is called once, not on every tick, once the role is known', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const submitted: string[] = []
|
|
stubSessionStart(on, submitted)
|
|
const fetches: string[] = []
|
|
const inbox = { sessionId: 'term_self', count: 0, messages: [] as string[] }
|
|
stubFleetdDynamic(on, () => JSON.stringify(inbox), fetches, [], () => false, () => '{"role":"primary"}')
|
|
|
|
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/Users/x/claude-bridge' })
|
|
|
|
await clock.advance(3_000)
|
|
await clock.advance(3_000)
|
|
await clock.advance(3_000)
|
|
|
|
expect(toolCalls(fetches, 'fleet_whoami')).toBe(1)
|
|
expect(toolCalls(fetches, 'fleet_inbox')).toBe(3)
|
|
})
|