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
14 changes: 6 additions & 8 deletions clients/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ process env inheritance:
### Eager vs lazy boot, in one diagram

```
parseEnv → buildAdapter → reconcileMinterSubscriptions (non-fatal) →
parseEnv → buildAdapter →
├── COMMY_BOT_NAME set →
│ persistent single-identity cache;
Expand All @@ -90,16 +90,14 @@ parseEnv → buildAdapter → reconcileMinterSubscriptions (non-fatal) →
subscribeFromEnv → registerTools → connect transport →
startEventPump (minter-side queue, filtered by narrowSet) →
startEventPump (seat-side queue, filtered by narrowSet) →
wire shutdown (release only if acquire happened)
```

`reconcileMinterSubscriptions` is the boot-time backstop that keeps the
minter subscribed to every public stream in the realm. It runs once per
plugin process; new streams created during the process's lifetime are
covered by the per-session POST inside `inbox.subscribe()`. Failure is
non-fatal — the diagnostic goes to stderr and boot continues with a
possibly-degraded lurker view.
Boot touches no channel the seat wasn't asked to hold. Every subscription
and the events queue itself belong to the seat's own principal, registered
when the seat first subscribes — so a seat that never mints leaves no trace
on the realm.

### Run shapes

Expand Down
30 changes: 17 additions & 13 deletions docs/agent-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ something must listen on its behalf. That something was the minter,
subscribed to every public stream, with the event queue registered against
it rather than the per-session bot.

The example is told in the past tense because the first two steps of the
chain have since been taken apart: the queue and the subscriptions now
belong to the seat. The rest of the chain is still standing, and the
paragraphs below say which parts.
The example is told in the past tense because that arrangement is gone: the
queue and the subscriptions belong to the seat, and the minter listens for
nobody. One piece of the chain is still standing, and the paragraphs below
say which.

Everything else follows from that one deferral. One shared subscriber means
per-agent narrowing cannot be a realm subscription, so it becomes a
Expand Down Expand Up @@ -188,11 +188,16 @@ had to move together, because Zulip builds a channel message's recipient set
from the channel's subscription rows: a seat-owned queue over minter-held
subscriptions would have received nothing at all.

What has not moved yet: the minter still holds its blanket public-stream
subscription, and topic-level narrowing is still a client-side filter with a
local record behind it. Those are the next steps of the same unwinding, not
exemptions — channel-level narrowing has moved, and is now read back from the
realm on every boot.
The minter holds no stream subscriptions. It has two jobs, both genuinely
its own: minting bots, and the directory and history reads that need no
principal. Nothing listens on another seat's behalf, so a seat that never
mints costs the realm nothing — the saving the optimisation was after,
without the architecture that funded it.

Topic-level narrowing is still a client-side filter with a local record
behind it — the last piece of the chain standing. That is the next step of
the same unwinding, not an exemption; channel-level narrowing is realm state
under the seat's own principal, read back from the realm on every boot.

Principle 5 catches it at the first step. Principle 3 catches the store.
Principle 1 catches `session_id` reaching the tool surface.
Expand All @@ -204,10 +209,9 @@ while the architecture funding it does not. What could not be deferred was
receiving — a queue is state held on the agent's behalf. Reading was never
the problem.

Two things would remain client-side afterwards, both legitimately:
topic-level narrows and the event-queue handle. The difference is that they
would filter the agent's own queue rather than a shared one — local, small,
and interfering with nobody.
Two things remain client-side, both legitimately: topic-level narrows and
the event-queue handle. Both filter the agent's own queue rather than a
shared one — local, small, and interfering with nobody.

They are legitimate for different reasons, and only one of them is an
exemption. The event-queue handle is principle 3's second: the substrate
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ export const readGitContext = (
/**
* The full driven surface `main` composes against: the
* universal `AgentComms` aggregate plus the Zulip-shaped boot extras
* (reconcile / download / upload / close). Lives in the plugin — core
* (download / upload / close). Lives in the plugin — core
* stays substrate-neutral, and `registerTools` keeps its narrower
* `AgentComms` dependency via structural subtyping. Request-time DI
* (methods carrying `R = HttpClient`) is deferred.
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/disconnect-exit.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ const inMemorySubscriptionStore = {

/**
* Complete the in-memory substrate to the `ZulipAdapter` shape the program
* expects. `reconcileMinterSubscriptions` (boot) and `close` (shutdown
* finalizer) are exercised, so the helper's inert no-ops suffice;
* expects. `close` (shutdown finalizer) is exercised, so the helper's inert
* no-op suffices;
* `uploadFile`/`downloadFile` are never reached without an MCP client driving
* tools, so they die loudly if anything calls them.
*/
Expand Down
23 changes: 9 additions & 14 deletions packages/mcp/memory-substrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { Duration, Effect } from 'effect'
* docs/architecture.md § Test architecture). But the `SubstrateAdapter` port
* those programs depend on is currently *typed as* {@link ZulipAdapter}, so any
* provided double must be completed from the universal {@link AgentComms} core
* to that Zulip-shaped aggregate: `reconcileMinterSubscriptions`,
* `downloadFile`, `uploadFile`, `close`. Concentrating that completion here
* to that Zulip-shaped aggregate: `downloadFile`, `uploadFile`, `close`.
* Concentrating that completion here
* keeps the rule self-enforcing: among the above-port tests, `@commy/zulip`
* appears in exactly one module, this one. Tests that deliberately drive the
* real adapter (`queue-resume`, `bootstrap`, the live suite) name it for the
Expand All @@ -23,32 +23,27 @@ import { Duration, Effect } from 'effect'
*
* The members themselves no longer speak Zulip. `downloadFile` / `uploadFile`
* are the port's `AttachmentStore`, so their doubles are built from
* `@commy/core/ports` types alone; only `reconcileMinterSubscriptions` and
* `close` are still Zulip-shaped, and they are why the aggregate is still
* named here at all.
* `@commy/core/ports` types alone; only `close` is still Zulip-shaped, and it
* is why the aggregate is still named here at all.
*
* `ZulipAdapter` is re-exported so callers annotate their doubles without
* naming `@commy/zulip` themselves.
*/
export type { ZulipAdapter } from '@commy/zulip/adapter'

/** The four members that complete `AgentComms` to a `ZulipAdapter`. */
type SubstrateExtras = Pick<
ZulipAdapter,
'reconcileMinterSubscriptions' | 'downloadFile' | 'uploadFile' | 'close'
>
/** The three members that complete `AgentComms` to a `ZulipAdapter`. */
type SubstrateExtras = Pick<ZulipAdapter, 'downloadFile' | 'uploadFile' | 'close'>

/**
* Per-member overrides. Anything omitted falls back to an inert default: a
* no-op reconcile report, an empty download, a stub upload result, a no-op
* close. Tests override only the member whose behaviour they actually assert.
* Per-member overrides. Anything omitted falls back to an inert default: an
* empty download, a stub upload result, a no-op close. Tests override only the
* member whose behaviour they actually assert.
*/
type SubstrateExtrasOverrides = Partial<SubstrateExtras>

const stubAttachmentRef = decodeAttachmentRefSync('/user_uploads/0/stub')

const inertExtras: SubstrateExtras = {
reconcileMinterSubscriptions: () => Effect.succeed({ added: [], error: undefined }),
// Empty bytes, but a real filename: the port's contract is that the adapter
// names the file, so a double that answered a constant would let a caller
// deriving its own name from the handle pass its tests.
Expand Down
5 changes: 2 additions & 3 deletions packages/mcp/server.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,9 +1245,8 @@ test('post by self does NOT fire a claude/channel notification (self-echo suppre
// ─── Pump narrow-set filter ─────────────────────────────────────────────────

test('pump filter: event for a never-subscribed channel does NOT fire claude/channel notification', async () => {
// Production wiring assertion. The Zulip minter is subscribed to every
// public stream (per `minter-reconciler.ts`), so the adapter
// inbox yields events for streams the calling session never subscribed
// Production wiring assertion. A seat's own event queue carries no narrow,
// so it can yield events for streams the calling session never subscribed
// to via the MCP `subscribe` tool or `COMMY_SUBSCRIBE` env. The
// plugin-layer NarrowSet (`narrow-set.ts`) is the filter that decides
// which of those events the MCP host actually sees. This test exercises
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/server.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const liveEnv = (): LiveEnv => {
}

// Same minter-call spacing as the substrate live suite —
// boot's reconcile + acquire + subscribe sequence plus per-test
// boot's acquire + subscribe sequence plus per-test
// release all hit the shared minter, so the plugin live suite trips
// the same per-user limit if we don't pace.
const MINTER_PACE = Duration.millis(900)
Expand Down Expand Up @@ -425,7 +425,7 @@ describeLive('commy plugin live integration — zulip.example.com', () => {
const e = liveEnv()
yield* Effect.scoped(
Effect.gen(function* () {
// Pace before main()'s boot-time minter calls (reconcile + subscribe).
// Pace before main()'s boot-time minter calls (acquire + subscribe).
yield* Effect.sleep(MINTER_PACE)
const client = yield* buildHarness({
COMMY_SUBSCRIBE: `${e.channelName}`,
Expand Down
56 changes: 2 additions & 54 deletions packages/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ interface FakeAdapterCalls {
readonly acquired: string[]
readonly closes: { count: number }
readonly subscribed: SubscriptionTarget[]
readonly reconcileCalls: { count: number }
readonly events: string[]
}

Expand All @@ -178,16 +177,11 @@ const buildFakeAdapter = (
readonly identityOrigin?: IdentityOrigin
/** Reject every substrate-side subscribe, for the part-way-failure paths. */
readonly subscribeError?: InboxError
readonly reconcileReport?: {
readonly added: ReadonlyArray<ChannelName>
readonly error: string | undefined
}
} = {},
): { readonly adapter: ZulipAdapter; readonly calls: FakeAdapterCalls } => {
const acquired: string[] = []
const closes = { count: 0 }
const subscribed: SubscriptionTarget[] = []
const reconcileCalls = { count: 0 }
const events: string[] = []
const identity: Identity = {
id: decodeIdentityIdSync('bot:myproject-concierge'),
Expand Down Expand Up @@ -258,25 +252,15 @@ const buildFakeAdapter = (
channelDescription: () => Effect.succeed(Option.none()),
presence: (_id: Identity): Effect.Effect<Presence> => Effect.succeed('offline'),
}
const defaultReconcileReport = {
added: [] as ReadonlyArray<ChannelName>,
error: undefined as string | undefined,
}
const adapter = completeAsSubstrate(
{ identity: identityPort, publisher, inbox, history, directory },
{
reconcileMinterSubscriptions: () =>
Effect.sync(() => {
events.push('reconcile')
reconcileCalls.count += 1
return options.reconcileReport ?? defaultReconcileReport
}),
close: async () => {
closes.count += 1
},
},
)
return { adapter, calls: { acquired, closes, subscribed, reconcileCalls, events } }
return { adapter, calls: { acquired, closes, subscribed, events } }
}

test('main resolves cleanly when given a valid env', async () => {
Expand Down Expand Up @@ -348,7 +332,7 @@ test('lazy mode (cc-<8> from session id) does NOT acquire at boot', async () =>
}
const exit = await runProgram(env, fake.adapter, { loggerLayer: captureLogger(stderr) })
// No acquire call, no acquire-failure stderr, clean boot, adapter still
// closed. Reconcile is silent in the no-op case.
// closed.
expect(Exit.isSuccess(exit)).toBe(true)
expect(fake.calls.acquired).toEqual([])
expect(stderr).toEqual([])
Expand Down Expand Up @@ -641,42 +625,6 @@ test('main applies env-driven subscriptions in order after acquire and Type-1 de
expect(fake.calls.closes.count).toBe(1)
})

test('main reconciles minter subscriptions during boot before env subscribes', async () => {
const fake = buildFakeAdapter({
reconcileReport: {
added: [decodeChannelNameSync('commy'), decodeChannelNameSync('general')],
error: undefined,
},
})
const log: string[] = []
const env = { ...validEnv, COMMY_SUBSCRIBE: 'home' }
await runProgram(env, fake.adapter, { loggerLayer: captureLogger(log) })
expect(fake.calls.reconcileCalls.count).toBe(1)
expect(fake.calls.events.indexOf('reconcile')).toBeLessThan(
fake.calls.events.indexOf('subscribe'),
)
expect(log.some((line) => line.includes('commy') && line.includes('general'))).toBe(true)
})

test('main calls reconcile but stays silent when there is nothing to add', async () => {
const fake = buildFakeAdapter()
const log: string[] = []
await runProgram(validEnv, fake.adapter, { loggerLayer: captureLogger(log) })
expect(fake.calls.reconcileCalls.count).toBe(1)
expect(log).toEqual([])
})

test('main keeps booting when reconcile reports an error (log + continue)', async () => {
const fake = buildFakeAdapter({
reconcileReport: { added: [], error: 'realm unreachable' },
})
const log: string[] = []
const exit = await runProgram(validEnv, fake.adapter, { loggerLayer: captureLogger(log) })
expect(Exit.isSuccess(exit)).toBe(true)
expect(fake.calls.acquired).toEqual(['myproject-concierge'])
expect(log.some((line) => line.includes('realm unreachable'))).toBe(true)
})

// ─── Type-1 default sub set for project concierges ──────────────

test('persistent mode + project registers Type-1 defaults (new-topics + thread/general)', async () => {
Expand Down
19 changes: 1 addition & 18 deletions packages/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ const buildIdentityCache = (

/**
* The plugin's boot program as ONE composed Effect,
* from parse → reconcile → identity → tools → pump, run at a single
* from parse → identity → tools → pump, run at a single
* `runMain` edge. Services (substrate adapter, cursor store,
* ConfigProvider, logger) arrive through the app Layer;
* {@link ProgramParams} carries the remaining per-run knobs.
Expand Down Expand Up @@ -580,23 +580,6 @@ export const makeProgram = (
return Effect.map(deriveProject({ cwd, readGitContext }), Option.getOrUndefined)
}

// Minter subscription reconcile: boot-time backstop that
// keeps the minter subscribed to every public stream. Non-fatal —
// log + continue. Silent in the steady-state no-op case.
yield* adapter.reconcileMinterSubscriptions().pipe(
Effect.flatMap((reconcile) => {
if (reconcile.error !== undefined) {
return Effect.logError(`commy plugin: minter reconcile failed: ${reconcile.error}`)
}
if (reconcile.added.length > 0) {
return Effect.logInfo(
`commy plugin: minter reconcile — subscribed minter to ${reconcile.added.length} new public stream(s): ${reconcile.added.join(', ')}`,
)
}
return Effect.void
}),
)

// Sample the realm-wide editing switch once, before the tool list is
// built, so a seat on a realm with editing off is never offered
// `edit_message`. Sampled here rather than held as a static capability
Expand Down
9 changes: 4 additions & 5 deletions packages/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,8 @@ export interface ToolsCache extends ToolsMemory {
* `narrowSet` is the consumer-side filter for the inbound event
* pump. `subscribe` / `unsubscribe` mutate it so the pump tees only
* intended events to the MCP host. The substrate-side call
* (`inbox.subscribe` / `inbox.unsubscribe`) handles streams created
* after the plugin booted; the boot-time minter reconciler
* covers the rest.
* (`inbox.subscribe` / `inbox.unsubscribe`) carries the same change to
* the realm under the seat's own principal.
*/
export interface RegisterToolsDeps {
readonly adapter: AgentComms
Expand Down Expand Up @@ -911,8 +910,8 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
}
// Two sinks (see bootstrap.subscribeFromEnv): the consumer-side
// narrow tells the event pump to tee matching events through;
// the substrate-side call subscribes the minter to streams the
// boot-time reconciler didn't have a chance to cover.
// the substrate-side call subscribes THIS SEAT to the channel, so
// the realm delivers its messages to the seat's own queue.
yield* Effect.sync(() => narrowSet.add(intent)).pipe(
Effect.andThen(adapter.inbox.subscribe(intentToTarget(intent))),
)
Expand Down
Loading