The plugin no longer carries .mcp.json; the instance or the project mounts
fleet. The mod asks fleet_whoami once and skips the fleet_inbox poll for a
worker or an architect, so members keep paste delivery. The mod reads
FLEETD_MCP_URL with $.env.get (documented in the mods API), defaulting to
http://127.0.0.1:8765/mcp.
Lead verification on 87f7c03: claude plugin validate plugin passed;
claude plugin test plugin 21 pass, 0 fail. With the role gate removed, the
worker and architect tests fail (19 pass, 2 fail), so they are not vacuous.
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
{
|
||||
"name": "fleet",
|
||||
"source": "./plugin",
|
||||
"description": "Mount the fleetd MCP gateway and apply standard Claude Code settings so a session can orchestrate delegated workers. Ships no credentials.",
|
||||
"version": "0.2.0",
|
||||
"description": "Apply standard Claude Code settings so a session can orchestrate delegated workers, and run the fleet mod for cross-session messaging. Mounting the fleetd MCP gateway is the instance's or the project's job, not this plugin's. Ships no credentials.",
|
||||
"version": "0.3.0",
|
||||
"author": {
|
||||
"name": "LTMS"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fleet",
|
||||
"description": "Make a project fleet-ready: mount the fleetd MCP gateway and set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers. Lead-side only — member skills and agents travel in the worktree. Ships no credentials.",
|
||||
"version": "0.2.0",
|
||||
"description": "Set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers, and run the fleet mod for cross-session messaging. Lead-side only — member skills and agents travel in the worktree, and mounting the fleetd MCP gateway is now the instance's or the project's job, not this plugin's. Ships no credentials.",
|
||||
"version": "0.3.0",
|
||||
"author": {
|
||||
"name": "LTMS"
|
||||
},
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"fleet": {
|
||||
"type": "http",
|
||||
"url": "${FLEETD_MCP_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-19
@@ -1,7 +1,7 @@
|
||||
# fleet (Claude Code plugin)
|
||||
|
||||
Makes a project **fleet-ready**: mounts the `fleetd` MCP gateway and applies standard Claude Code
|
||||
settings, so the session can orchestrate a fleet of delegated workers.
|
||||
Makes a project **fleet-ready**: applies standard Claude Code settings and runs the fleet mod, so
|
||||
the session can orchestrate a fleet of delegated workers.
|
||||
|
||||
**This plugin ships no credentials.** Every secret is referenced by environment-variable *name*;
|
||||
the values stay with the user. Nothing the plugin writes is unsafe to commit.
|
||||
@@ -22,9 +22,10 @@ Member-facing assets travel in the worktree, not in this plugin. See fleetd #362
|
||||
## What it is not
|
||||
|
||||
The plugin is the **client-side setup**, not the bridge. `fleetd` is a separate daemon and `herdr`
|
||||
is a separate PTY multiplexer, each with its own lifecycle and install. The plugin mounts an
|
||||
already-running daemon and tells you what is missing when one isn't there — it deliberately does
|
||||
not try to install system services on your behalf.
|
||||
is a separate PTY multiplexer, each with its own lifecycle and install, and the plugin does not try
|
||||
to install either on your behalf. It also does not mount the daemon for you — mounting is the
|
||||
instance's or the project's own `.mcp.json`, and `/fleet:setup` is the one thing in this plugin that
|
||||
still helps with that (it writes the project-level entry).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -33,11 +34,20 @@ not try to install system services on your behalf.
|
||||
/plugin install fleet@fleetd
|
||||
```
|
||||
|
||||
Export the gateway URL — the plugin mounts `${FLEETD_MCP_URL}`, not a hardcoded address, so one
|
||||
plugin serves hosts that run the daemon on different ports:
|
||||
Mount the daemon yourself — this plugin carries no mount of its own. Either add the entry below to
|
||||
your Claude Code instance's own `.claude.json`, so every project you open there gets it, or run
|
||||
`/fleet:setup` in the project you want to onboard, which writes the same entry into that project's
|
||||
`.mcp.json`:
|
||||
|
||||
```shell
|
||||
export FLEETD_MCP_URL=http://127.0.0.1:8765/mcp
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fleet": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8765/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, in the project you want to onboard:
|
||||
@@ -50,18 +60,23 @@ Then, in the project you want to onboard:
|
||||
|
||||
| Component | Effect |
|
||||
|---|---|
|
||||
| `.mcp.json` | mounts `fleet` at `${FLEETD_MCP_URL}` for any session with the plugin enabled |
|
||||
| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification |
|
||||
| `skills/setup` | `/fleet:setup` — preflight, project settings, credential guidance, and verification. Also the only thing in this plugin that helps mount `fleet`: it writes the project `.mcp.json` entry shown above. |
|
||||
| `hooks/register.js` (the fleet mod) | Cross-account session messaging while the plugin is enabled: `/fleet-peers`, `/fleet-mail`, `/fleet-whoami`, and a background poll that delivers mail fleetd queued for this pane. A spawned worker or architect skips that poll, because it already gets its brief pasted into its pane. |
|
||||
|
||||
The server is named **`fleet`** on purpose: that is `PeerLauncher.MCP_MOUNT_NAME` in the daemon and
|
||||
the name a spawned member's own mount carries. Version 0.1.0 named it `fleetd`, which produced two
|
||||
mounts of one daemon for anyone who also had a project-level `.mcp.json`. Upgrading from 0.1.0 is a
|
||||
**breaking change** — a project that pre-allowed `mcp__fleetd__fleet_whoami` in
|
||||
`.claude/settings.json` must be updated to `mcp__fleet__*`.
|
||||
Whichever file mounts the daemon, name the server **`fleet`**. That is `PeerLauncher.MCP_MOUNT_NAME`
|
||||
in the daemon, the name a spawned member's own mount carries, and the name the `mcp__fleet__*`
|
||||
role heuristic in `CLAUDE.md` keys on.
|
||||
|
||||
Because the plugin carries its own `.mcp.json`, an installed plugin needs no project-level MCP
|
||||
file at all. The setup skill writes one only when you want the mount to work *without* the plugin —
|
||||
for teammates who haven't installed it, or for CI.
|
||||
## Upgrading from 0.2.0 — breaking
|
||||
|
||||
The plugin no longer mounts the daemon. It used to carry its own `.mcp.json`, pointed at
|
||||
`${FLEETD_MCP_URL}`, and that file is gone along with the environment variable. Mount `fleet`
|
||||
yourself: add the entry under **Install** above to your instance's `.claude.json` or to the
|
||||
project's own `.mcp.json`, by hand or with `/fleet:setup`.
|
||||
|
||||
Version 0.1.0 named the mounted server `fleetd`, which produced two mounts of one daemon for
|
||||
anyone who also had a project-level `.mcp.json`. A project that pre-allowed
|
||||
`mcp__fleetd__fleet_whoami` in `.claude/settings.json` must be updated to `mcp__fleet__*`.
|
||||
|
||||
## Verifying a setup
|
||||
|
||||
|
||||
+30
-10
@@ -67,23 +67,28 @@ async function livePeers($, now) {
|
||||
return rows
|
||||
}
|
||||
|
||||
const FLEETD_MCP = 'http://127.0.0.1:8765/mcp'
|
||||
const FLEETD_MCP_DEFAULT = 'http://127.0.0.1:8765/mcp'
|
||||
const MCP_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }
|
||||
// The status fleetd answers, with "Session not found", for an Mcp-Session-Id it no longer holds.
|
||||
const MCP_SESSION_GONE = 404
|
||||
|
||||
// The MCP session every call below shares. fleetd keeps a server-side session per initialize and
|
||||
// drops it only on a DELETE, so one initialize per call would leave a session behind every time.
|
||||
// The MCP session every call below shares, and the URL it was opened against. fleetd keeps a
|
||||
// server-side session per initialize and drops it only on a DELETE, so one initialize per call
|
||||
// would leave a session behind every time.
|
||||
let mcpSessionId = null
|
||||
let fleetdUrl = FLEETD_MCP_DEFAULT
|
||||
|
||||
/**
|
||||
* Open an MCP session on the local fleetd and hold it for later calls.
|
||||
*
|
||||
* Cleared first, so a failure here leaves no dead id behind for the next call to reuse.
|
||||
* Cleared first, so a failure here leaves no dead id behind for the next call to reuse. Reads
|
||||
* FLEETD_MCP_URL fresh on every open, so a session opened after the daemon moves uses the new
|
||||
* address.
|
||||
*/
|
||||
async function openFleetSession($) {
|
||||
mcpSessionId = null
|
||||
const init = await $.http.fetch(FLEETD_MCP, {
|
||||
fleetdUrl = (await $.env.get('FLEETD_MCP_URL')) || FLEETD_MCP_DEFAULT
|
||||
const init = await $.http.fetch(fleetdUrl, {
|
||||
method: 'POST',
|
||||
headers: MCP_HEADERS,
|
||||
body: JSON.stringify({
|
||||
@@ -93,7 +98,7 @@ async function openFleetSession($) {
|
||||
})
|
||||
if (!init.ok) throw new Error('fleetd initialize failed with status ' + init.status)
|
||||
const opened = init.headers['mcp-session-id']
|
||||
await $.http.fetch(FLEETD_MCP, {
|
||||
await $.http.fetch(fleetdUrl, {
|
||||
method: 'POST',
|
||||
headers: { ...MCP_HEADERS, 'Mcp-Session-Id': opened },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||
@@ -103,7 +108,7 @@ async function openFleetSession($) {
|
||||
|
||||
/** Send one tools/call on the session this mod holds, and return the raw HTTP answer. */
|
||||
function sendFleetToolCall($, tool, args) {
|
||||
return $.http.fetch(FLEETD_MCP, {
|
||||
return $.http.fetch(fleetdUrl, {
|
||||
method: 'POST',
|
||||
headers: { ...MCP_HEADERS, 'Mcp-Session-Id': mcpSessionId },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: tool, arguments: args } }),
|
||||
@@ -170,17 +175,32 @@ export function register(on) {
|
||||
}
|
||||
})
|
||||
|
||||
// A spawned worker or architect already gets its brief pasted into its pane, so this
|
||||
// poll would be a second, redundant delivery path for it. Every other role collects
|
||||
// its own mail through this poll.
|
||||
let role = null
|
||||
|
||||
// Collect what the fleet queued for this pane and hand each message to
|
||||
// Claude. Every call also renews fleetd's record that this pane collects its
|
||||
// own mail, so an empty answer still has to be asked for.
|
||||
$.clock.every(FLEETD_INBOX_POLL_MS, async () => {
|
||||
if (role === null) {
|
||||
try {
|
||||
role = JSON.parse(await fleetTool($, 'fleet_whoami', {})).role
|
||||
} catch {
|
||||
// A daemon that is down, or a pane fleetd cannot place, is the ordinary
|
||||
// case on a host with no fleet running. Retry on the next tick.
|
||||
return
|
||||
}
|
||||
}
|
||||
if (role === 'worker' || role === 'architect') return
|
||||
|
||||
let collected
|
||||
try {
|
||||
collected = JSON.parse(await fleetTool($, 'fleet_inbox', {}))
|
||||
} catch {
|
||||
// A daemon that is down, or a pane fleetd cannot place, is the ordinary
|
||||
// case on a host with no fleet running. The timer survives a throw, so
|
||||
// this only keeps every tick from writing an error to the debug log.
|
||||
// The timer survives a throw, so this only keeps every tick from
|
||||
// writing an error to the debug log.
|
||||
return
|
||||
}
|
||||
for (const text of collected.messages || []) {
|
||||
|
||||
@@ -36,16 +36,11 @@ a time.
|
||||
command -v herdr && herdr --version 2>&1 | head -1 || echo "MISSING: herdr"
|
||||
command -v ccs && ccs version 2>&1 | head -1 || echo "MISSING: ccs (needed for worker profiles)"
|
||||
command -v codex && codex --version 2>&1 | head -1 || echo "absent: codex (optional)"
|
||||
curl -s -m 5 "${FLEETD_MCP_URL%/mcp}/healthz" 2>/dev/null \
|
||||
|| curl -s -m 5 http://127.0.0.1:8765/healthz \
|
||||
|| echo "MISSING: fleetd daemon is not reachable"
|
||||
[ -n "$FLEETD_MCP_URL" ] && echo "FLEETD_MCP_URL is set" || echo "MISSING: FLEETD_MCP_URL"
|
||||
curl -s -m 5 http://127.0.0.1:8765/healthz || echo "MISSING: fleetd daemon is not reachable"
|
||||
```
|
||||
|
||||
**`FLEETD_MCP_URL` is required.** The plugin's own `.mcp.json` mounts `${FLEETD_MCP_URL}` rather
|
||||
than a hardcoded address, so one plugin can serve hosts that run the daemon on different ports. If
|
||||
it is unset the mount does not resolve. The usual value is `http://127.0.0.1:8765/mcp`; tell the
|
||||
user to export it, do not write it into a file for them.
|
||||
The usual address is `http://127.0.0.1:8765`. If this daemon runs elsewhere, use that address
|
||||
instead wherever this skill writes `http://127.0.0.1:8765/mcp` below.
|
||||
|
||||
A healthy daemon answers with its status **and the herdr protocol it negotiated**:
|
||||
|
||||
@@ -95,10 +90,9 @@ If `.mcp.json` already exists, add only the `fleet` key and leave every other se
|
||||
If a `fleet` entry is already there with a different URL, **ask** rather than assuming yours is
|
||||
right — a non-default port usually means a deliberate second daemon.
|
||||
|
||||
> **If this plugin is installed, you can skip this step entirely.** The plugin ships its own
|
||||
> `.mcp.json`, so `fleetd` is already mounted for any session with the plugin enabled. Write the
|
||||
> project-level file only when the user wants the mount to work *without* the plugin — for
|
||||
> teammates who have not installed it, or for CI.
|
||||
This write is the only way this plugin helps mount `fleet` — the plugin carries no mount of its
|
||||
own. A session that wants the mount without running this skill can instead add the same entry to
|
||||
its own Claude Code instance's `.claude.json`.
|
||||
|
||||
**Before writing it, settle whether `.mcp.json` is committed here:**
|
||||
|
||||
|
||||
+151
-10
@@ -32,6 +32,7 @@ function stubEngine(on: any): Map<string, any> {
|
||||
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
|
||||
@@ -181,6 +182,7 @@ function stubSessionStart(on: any, submitted: string[]): Map<string, any> {
|
||||
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) => {
|
||||
@@ -194,10 +196,12 @@ function stubSessionStart(on: any, submitted: string[]): Map<string, any> {
|
||||
/**
|
||||
* Answer fleetd's MCP requests, with the tool result read fresh on every call.
|
||||
*
|
||||
* `fetches` collects every method sent, 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.
|
||||
* `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,
|
||||
@@ -205,24 +209,28 @@ function stubFleetdDynamic(
|
||||
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)
|
||||
fetches.push(body.method)
|
||||
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 result = { jsonrpc: '2.0', id: 2, result: { content: [{ type: 'text', text: toolText() }] } }
|
||||
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: '' } }
|
||||
})
|
||||
}
|
||||
@@ -311,11 +319,11 @@ test('a second inbox poll reuses the first MCP session', async ($, on) => {
|
||||
await clock.advance(3_000)
|
||||
await clock.advance(3_000)
|
||||
|
||||
// fleetd holds a session per initialize and drops it only on a DELETE, so one initialize per
|
||||
// poll would leave one behind every three seconds.
|
||||
expect(sessions.length).toBe(2) // control: both polls really reached fleetd
|
||||
// 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[1]).toBe(sessions[0])
|
||||
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) => {
|
||||
@@ -347,3 +355,136 @@ test('a session fleetd no longer holds is opened again and the call retried', as
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user