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
33 changes: 29 additions & 4 deletions packages/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,8 +560,9 @@ export interface MessagePublisher {
* `UnresolvedMention` when the body carries a mention token no identity
* resolves (pre-flighted for the same reason — the substrate would post it
* and notify nobody), and a `PublisherError` wrapping any other substrate
* failure. Calling before `identity.acquire` is a defect, not a typed
* failure.
* failure. A body the substrate cannot store whole fails with a typed
* `MessageRejected` rather than landing clipped. Calling before
* `identity.acquire` is a defect, not a typed failure.
*
* `opts.thread` names a thread by its clean name — resolution is never
* encoded in the name (see `ObservedThreadSchema`). A post into a **resolved**
Expand All @@ -584,7 +585,10 @@ export interface MessagePublisher {
channel: ChannelName,
body: MessageBody,
opts?: PostOpts,
): Effect.Effect<MessageRef, BindError | UnknownChannel | UnresolvedMention | PublisherError>
): Effect.Effect<
MessageRef,
BindError | UnknownChannel | UnresolvedMention | MessageRejected | PublisherError
>
/**
* Replace the body of an existing message attributed to the bound
* identity. Adapters target the substrate's in-place edit primitive
Expand All @@ -601,7 +605,10 @@ export interface MessagePublisher {
edit(
message: MessageRef,
body: MessageBody,
): Effect.Effect<void, BindError | UnresolvedMention | MessageEditRefused | PublisherError>
): Effect.Effect<
void,
BindError | UnresolvedMention | MessageRejected | MessageEditRefused | PublisherError
>
/**
* Whether the substrate permits `edit` at all *right now*, for anyone —
* the realm-wide switch behind `MessageEditRefused`'s `editing-disabled`
Expand Down Expand Up @@ -937,6 +944,24 @@ export class ChannelDescriptionRejected extends Data.TaggedError('ChannelDescrip
}
}

/**
* Refusal of `MessagePublisher.post` or `edit` when the substrate cannot store
* the body whole. A substrate that clips an over-long body and reports success
* leaves the sender believing the whole message landed, so adapters refuse
* instead. `detail` carries the adapter's own explanation, including the actual
* limit, for a caller to show a human. Tagged so the MCP edge can report a
* fixable input apart from a substrate outage, which stays `PublisherError`.
*/
export class MessageRejected extends Data.TaggedError('MessageRejected')<{
readonly operation: 'post' | 'edit'
readonly substrate: string
readonly detail: string
}> {
override get message(): string {
return `${this.operation} failed — ${this.substrate} rejected the message: ${this.detail}`
}
}

/**
* Failure of a `MessagePublisher` write whose body carries a mention token that
* resolves to no known identity — a stale `@**Full Name**` the substrate would
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA
{
name: 'post',
description:
'Post a message to a channel by name. Optional thread (topic) and reply_to (message id). To ping someone, write the @**Name** markup inline in body where you want it rendered. Returns {message_id, channel_id, channel_name, thread, permalink}. When you show a human this message afterwards, link it by the returned permalink — never a bare name or message id.',
'Post a message to a channel by name. Optional thread (topic) and reply_to (message id). To ping someone, write the @**Name** markup inline in body where you want it rendered. A body longer than the realm stores whole is refused with an error naming the limit; split it across several posts. Returns {message_id, channel_id, channel_name, thread, permalink}. When you show a human this message afterwards, link it by the returned permalink — never a bare name or message id.',
inputSchema: {
type: 'object',
properties: {
Expand Down
27 changes: 27 additions & 0 deletions packages/memory/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
Mention,
MessageEditRefused,
MessagePermalinkSchema,
MessageRejected,
mentionsIdentity,
notifiesIdentity,
PublisherError,
Expand Down Expand Up @@ -185,6 +186,13 @@ export interface MemoryAdapterConfig {
* assuming a number is the thing worth proving.
*/
readonly channelDescriptionLimit?: number
/**
* Longest message body this substrate will store, in characters. A longer
* body is refused with `MessageRejected` on post and edit rather than stored
* clipped. Like the description limit, the default is memory's own number
* and not Zulip's.
*/
readonly messageLengthLimit?: number
/**
* Longest thread name this substrate keeps, in characters. A longer name is
* stored cut to this length, as a real substrate cuts an over-long topic, and
Expand All @@ -201,6 +209,7 @@ export type MemorySeatConfig = Pick<
>

const DEFAULT_CHANNEL_DESCRIPTION_LIMIT = 512
const DEFAULT_MESSAGE_LENGTH_LIMIT = 4096
const DEFAULT_THREAD_NAME_LIMIT = 48

export type MemoryAdapter = AgentComms & {
Expand Down Expand Up @@ -309,6 +318,7 @@ const makeRealm = (config: MemoryAdapterConfig) =>
const descriptionsByChannel = new Map<string, ChannelDescription>()
const descriptionLimit = config.channelDescriptionLimit ?? DEFAULT_CHANNEL_DESCRIPTION_LIMIT
const threadNameLimit = config.threadNameLimit ?? DEFAULT_THREAD_NAME_LIMIT
const messageLimit = config.messageLengthLimit ?? DEFAULT_MESSAGE_LENGTH_LIMIT
const nextChannelId = yield* Ref.make(1)
const nextIdentityId = yield* Ref.make(1)
const nextMessageId = yield* Ref.make(1)
Expand Down Expand Up @@ -336,6 +346,7 @@ const makeRealm = (config: MemoryAdapterConfig) =>
descriptionsByChannel,
descriptionLimit,
threadNameLimit,
messageLimit,
nextChannelId,
nextIdentityId,
nextMessageId,
Expand Down Expand Up @@ -367,6 +378,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect
descriptionsByChannel,
descriptionLimit,
threadNameLimit,
messageLimit,
nextChannelId,
nextIdentityId,
nextMessageId,
Expand All @@ -375,6 +387,19 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect
notificationBot,
seats,
} = realm
const refuseOverlongBody = (
operation: 'post' | 'edit',
body: MessageBodyType,
): Effect.Effect<void, MessageRejected> =>
body.length > messageLimit
? Effect.fail(
new MessageRejected({
operation,
substrate: 'memory',
detail: `message is too long (limit: ${messageLimit} characters, got ${body.length})`,
}),
)
: Effect.void
const isThreadResolved = (channelId: string, threadName: string): boolean =>
resolvedThreadsByChannel.get(channelId)?.has(threadName) ?? false
const withThreadResolution = (stored: StoredMessage): StoredMessage =>
Expand Down Expand Up @@ -861,6 +886,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect
Effect.gen(function* () {
const self = yield* requireBound()
const channelRef = yield* resolveChannel(channel)
yield* refuseOverlongBody('post', body)
yield* refuseDeadMentions('post', body)
const thread =
opts?.thread === undefined
Expand All @@ -875,6 +901,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect
edit: (message, body) =>
Effect.gen(function* () {
const self = yield* requireBound()
yield* refuseOverlongBody('edit', body)
const stored = messagesById.get(message.id)
if (stored === undefined) {
return yield* new PublisherError({
Expand Down
3 changes: 3 additions & 0 deletions packages/memory/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { memoryAdapter } from './adapter.ts'
// is given the substrate's number the way any other substrate gives it —
// nothing in the contract assumes a particular one.
const MEMORY_DESCRIPTION_LIMIT = 512
const MEMORY_MESSAGE_LIMIT = 4096
const MEMORY_THREAD_NAME_LIMIT = 48

runAgentCommsContract('memory adapter', async () => {
Expand All @@ -26,6 +27,7 @@ runAgentCommsContract('memory adapter', async () => {
peerPost: (peer, channel, body, opts) =>
adapter.peerPost(peer, channel, body, opts).pipe(Effect.asVoid, Effect.orDie),
channelDescriptionLimit: MEMORY_DESCRIPTION_LIMIT,
messageLengthLimit: MEMORY_MESSAGE_LIMIT,
threadNameLimit: MEMORY_THREAD_NAME_LIMIT,
dispose: () => Effect.void,
}
Expand Down Expand Up @@ -70,6 +72,7 @@ runAgentCommsContract('memory adapter (allowlist mode for UnknownIdentity covera
peerPost: (peer, channel, body, opts) =>
adapter.peerPost(peer, channel, body, opts).pipe(Effect.asVoid, Effect.orDie),
channelDescriptionLimit: MEMORY_DESCRIPTION_LIMIT,
messageLengthLimit: MEMORY_MESSAGE_LIMIT,
threadNameLimit: MEMORY_THREAD_NAME_LIMIT,
dispose: () => Effect.void,
}
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ export interface ContractEnv {
* substrate reports its own (Zulip 1024, memory its configured limit).
*/
readonly channelDescriptionLimit: number
/**
* Longest message body this substrate stores whole, in characters. The
* over-length tests build their input from it, so the contract asserts that
* a substrate refuses what it cannot hold and never what the cap happens to
* be. Zulip reports its realm's `max_message_length`, memory its configured
* limit.
*/
readonly messageLengthLimit: number
/**
* Longest thread name this substrate keeps, in characters. A substrate that
* cuts a longer name short must return the name it kept from `post`; the
Expand Down
6 changes: 5 additions & 1 deletion packages/testing/contract/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ChannelName,
InboundEvent,
MessageBody,
MessageRejected,
PublisherError,
UnknownChannel,
UnresolvedMention,
Expand All @@ -23,7 +24,10 @@ export const postSpaced = (
comms: AgentComms,
channel: ChannelName,
bodies: ReadonlyArray<MessageBody>,
): Effect.Effect<void, BindError | UnknownChannel | UnresolvedMention | PublisherError> =>
): Effect.Effect<
void,
BindError | UnknownChannel | UnresolvedMention | MessageRejected | PublisherError
> =>
Effect.forEach(
bodies,
(body, index) =>
Expand Down
25 changes: 25 additions & 0 deletions packages/testing/contract/posting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
decodeThreadNameSync,
Mention,
type MessageRef,
MessageRejected,
mentionedIdentities,
UnboundEphemeralSession,
UnknownChannel,
Expand Down Expand Up @@ -55,6 +56,30 @@ const makeCountingBinder = (
})

export const publisherPostingContract = (env: () => ContractEnv): void => {
// A substrate that clips an over-long body and reports success leaves the
// sender believing the whole message landed. The publisher refuses instead.
test('publisher.post refuses a body longer than the substrate stores', () =>
Effect.runPromise(
Effect.gen(function* () {
const channel = yield* env().seedChannel('lobby')
const tooLong = decodeMessageBodySync('x'.repeat(env().messageLengthLimit + 1))
const error = yield* Effect.flip(env().comms.publisher.post(channel.name, tooLong))
expect(error).toBeInstanceOf(MessageRejected)
// Refused outright — nothing clipped was left behind.
const messages = yield* env().comms.history.readChannel(channel.name, { limit: 10 })
expect(messages).toEqual([])
}),
))
test('publisher.post stores a body exactly at the substrate limit whole', () =>
Effect.runPromise(
Effect.gen(function* () {
const channel = yield* env().seedChannel('lobby')
const atLimit = decodeMessageBodySync('x'.repeat(env().messageLengthLimit))
yield* env().comms.publisher.post(channel.name, atLimit)
const messages = yield* env().comms.history.readChannel(channel.name, { limit: 10 })
expect(messages.map((m) => m.body)).toEqual([atLimit])
}),
))
test('publisher.post returns a MessageRef whose channel matches the destination', () =>
Effect.runPromise(
Effect.gen(function* () {
Expand Down
14 changes: 14 additions & 0 deletions packages/testing/contract/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MessageEditRefused,
MessagePermalinkSchema,
type MessageRef,
MessageRejected,
mentionedIdentities,
PublisherError,
UnknownChannel,
Expand All @@ -34,6 +35,19 @@ const drainUntilSilent = (queue: Queue.Queue<InboundEvent>): Effect.Effect<void>
)

export const publisherContract = (env: () => ContractEnv): void => {
test('publisher.edit refuses a body longer than the substrate stores', () =>
Effect.runPromise(
Effect.gen(function* () {
const channel = yield* env().seedChannel('lobby')
const original = decodeMessageBodySync('short')
const ref = yield* env().comms.publisher.post(channel.name, original)
const tooLong = decodeMessageBodySync('x'.repeat(env().messageLengthLimit + 1))
const error = yield* Effect.flip(env().comms.publisher.edit(ref, tooLong))
expect(error).toBeInstanceOf(MessageRejected)
const messages = yield* env().comms.history.readChannel(channel.name, { limit: 10 })
expect(messages.map((m) => m.body)).toEqual([original])
}),
))
// Resolution is a status distinct from the thread name: the name a consumer
// addresses stays stable, and the flag surfaces on read as
// ObservedThread.resolved. These hold on both substrates — memory tracks the
Expand Down
Loading