Skip to content
Merged
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
42 changes: 41 additions & 1 deletion packages/mcp/server.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
decodeMessageBodySync,
decodeMessageIdSync,
decodeTimestampSync,
IdentityError,
InboxError,
MessagePermalinkSchema,
userMentions,
Expand Down Expand Up @@ -173,6 +174,11 @@ interface AdapterOverrides {
* replay already covered the backlog — onAcquire must then SKIP catch-up.
*/
readonly resumeQueueReplayed?: boolean
/**
* Whether the realm answers a sign-in, asked on every acquire. While it
* returns false the acquire fails as a realm that cannot be reached.
*/
readonly realmReachable?: () => boolean
/**
* Capture stderr-shaped log output (default: route to the runner's
* STDERR via the production logger). Pass an array to collect the
Expand Down Expand Up @@ -272,7 +278,19 @@ const buildHarness = async (overrides: AdapterOverrides = {}): Promise<Harness>
acquire: (name) =>
Effect.sync(() => {
acquires.push(name)
}).pipe(Effect.flatMap(() => base.identity.acquire(name))),
}).pipe(
Effect.flatMap(() =>
(overrides.realmReachable ?? (() => true))()
? base.identity.acquire(name)
: Effect.fail(
new IdentityError({
operation: 'acquire',
cause: new Error('Transport error (GET https://zulip.example.com/api/v1/users)'),
transient: true,
}),
),
),
),
release: () =>
Effect.sync(() => {
releases += 1
Expand Down Expand Up @@ -2470,6 +2488,28 @@ test('boot feeder is a no-op when CLAUDE_CODE_SESSION_ID is absent — deferred
* first `listTools()` and the test would pass without ever seeing the tool
* present.
*/
test('a seat with a bot name completes the handshake while the realm is down, then signs in once it answers', async () => {
let reachable = false
const cap = captureSubscribes()
const h = await buildHarness({
env: { COMMY_PROJECT: 'myproject' },
seedChannels: ['myproject'],
inboxOverrides: cap.inboxOverrides,
realmReachable: () => reachable,
})
try {
await waitFor(() => h.identityCalls.acquires.length >= 2, 2000)
expect((await h.client.listTools()).tools.map((t) => t.name)).toContain('post')
expect(cap.tokens).toEqual([])

reachable = true
await waitFor(() => cap.tokens.length === 2, 5000)
expect(new Set(cap.tokens)).toEqual(new Set(['new-topics:myproject', 'myproject/general']))
} finally {
await h.cleanup()
}
})

test('an administrator turning message editing off withdraws edit_message from a connected seat', async () => {
const flip = Deferred.unsafeMake<RealmSettings>(FiberId.none)
const h = await buildHarness({
Expand Down
39 changes: 25 additions & 14 deletions packages/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ const buildFakeAdapter = (
readonly identityOrigin?: IdentityOrigin
/** Reject every substrate-side subscribe, for the part-way-failure paths. */
readonly subscribeError?: InboxError
/** How the realm answers the editing-setting probe. */
readonly editingAvailable?: MessagePublisher['editingAvailable']
} = {},
): { readonly adapter: ZulipAdapter; readonly calls: FakeAdapterCalls } => {
const acquired: string[] = []
Expand Down Expand Up @@ -222,7 +224,7 @@ const buildFakeAdapter = (
const publisher: MessagePublisher = {
post: () => Effect.die(new Error('unused fake')),
edit: () => Effect.void,
editingAvailable: () => Effect.succeed(true),
editingAvailable: options.editingAvailable ?? (() => Effect.succeed(true)),
react: () => Effect.void,
unreact: () => Effect.void,
resolveThread: () => Effect.void,
Expand Down Expand Up @@ -329,7 +331,6 @@ test('main writes acquire failure to stderr in the canonical format, fails boot,

// A seat that boots while the realm is unreachable retries rather than dying,
// because the host caches a failed MCP connection for the rest of its session.
// The retry has to finish inside the host's startup timeout, so it is bounded.
const unreachable = () =>
new IdentityError({
operation: 'acquire',
Expand Down Expand Up @@ -369,24 +370,34 @@ test('boot retries an unreachable realm and comes up once it answers', async ()
expect(logs.filter((line) => line.includes('could not reach the realm'))).toHaveLength(2)
})

test('boot gives up on a realm that stays unreachable, inside the host startup timeout', async () => {
test('boot keeps retrying a realm that stays unreachable, long after the host startup timeout', async () => {
const fake = buildFakeAdapter({
acquireFailures: Array.from({ length: 100 }, unreachable),
acquireFailures: Array.from({ length: 1000 }, unreachable),
})
const stderr: string[] = []
const stillRunning = await Effect.runPromise(
Effect.gen(function* () {
const fiber = yield* Effect.fork(provideProgram(validEnv, fake.adapter))
yield* TestClock.adjust('10 minutes')
const exit = yield* Fiber.poll(fiber)
yield* Fiber.interrupt(fiber)
return Option.isNone(exit)
}).pipe(Effect.provide(TestContext.TestContext)),
)
expect(stillRunning).toBe(true)
expect(fake.calls.acquired.length).toBeGreaterThan(20)
expect(fake.calls.closes.count).toBe(1)
})

test('boot does not wait on a realm that never answers the editing-setting probe', async () => {
const fake = buildFakeAdapter({ editingAvailable: () => Effect.never })
const exit = await runProgramOnTestClock(
validEnv,
fake.adapter,
{ loggerLayer: captureLogger(stderr) },
'25 seconds',
)
expect(Exit.isFailure(exit)).toBe(true)
expect(fake.calls.acquired.length).toBeGreaterThan(2)
expect(fake.calls.acquired.length).toBeLessThan(100)
expect(stderr.at(-1)).toBe(
'commy plugin: acquire("myproject-concierge") failed: Transport error (GET https://zulip.example.com/api/v1/users)',
{ readGitContext: () => Effect.succeed(NotInRepo()) },
'1 minute',
)
expect(fake.calls.closes.count).toBe(1)
expect(Exit.isSuccess(exit)).toBe(true)
expect(fake.calls.acquired).toEqual(['myproject-concierge'])
})

test('boot does not retry a realm that refuses the sign-in', async () => {
Expand Down
Loading