refactor(chat): route browser chat through one runtime
ci/woodpecker/pr/ci Pipeline was successful

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
This commit is contained in:
shaggy (mosaic-dev box)
2026-08-12 14:19:58 -05:00
co-authored by Claude Opus 4.8
parent 33ca4b2a6a
commit b21c84f231
5 changed files with 491 additions and 23 deletions
@@ -863,4 +863,96 @@ describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
// `await prompt()` the timer could never even be observed, so the turn hung forever.
prompt: vi.fn(() => new Promise<void>(() => undefined)),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
});
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
});
@@ -967,7 +967,11 @@ describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Fiv
prompt: vi.fn().mockResolvedValue(undefined),
};
const harnessConversations = { append: vi.fn() };
const create = vi.fn().mockResolvedValue(undefined);
// The durable store honours the mint: it returns the exact record asked for (same id, same
// scoped owner). Admission requires that non-null, correctly-identified record before any effect.
const create = vi.fn((data: { id: string; userId: string }) =>
Promise.resolve({ id: data.id, userId: data.userId }),
);
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
@@ -1009,6 +1013,159 @@ describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Fiv
expect(agentService.prompt).toHaveBeenCalledTimes(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => {
// Minting is not fire-and-forget: if create resolves nullish (the durable write silently
// produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch
// against a conversation that was never persisted.
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const order: string[] = [];
const counters = { dispatch: 0, dispose: 0 };
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession: vi.fn().mockResolvedValue(session),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const harnessConversations = { append: vi.fn() };
const create = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' });
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
create,
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const router = readyRouter('legacy', agentService, harnessConversations);
instrumentLease(router, order, counters);
const gateway = new ChatGateway(
router as never,
{ api: { getSession: vi.fn() } } as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: 'browser-new-nullish-1',
data: { user: { id: 'user-a' } },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
{
content: 'first message, mint resolves nullish',
} as never,
);
// The mint was attempted for the sender, then admission failed closed: no persist, no session
// mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
);
expect(addMessage).not.toHaveBeenCalled();
expect(agentService.createSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(counters.dispatch).toBe(0);
expect(counters.dispose).toBe(0);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'conversation_unavailable' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => {
// A create that rejects (durable store error) is a persistence failure, not a reason to proceed:
// the exception is caught at the admission seam and collapses to the same fail-closed refusal.
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const order: string[] = [];
const counters = { dispatch: 0, dispose: 0 };
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession: vi.fn().mockResolvedValue(session),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const harnessConversations = { append: vi.fn() };
const create = vi.fn().mockRejectedValue(new Error('durable store unavailable'));
const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' });
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
create,
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const router = readyRouter('legacy', agentService, harnessConversations);
instrumentLease(router, order, counters);
const gateway = new ChatGateway(
router as never,
{ api: { getSession: vi.fn() } } as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: 'browser-new-throw-1',
data: { user: { id: 'user-a' } },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
{
content: 'first message, mint throws',
} as never,
);
// The mint was attempted for the sender, then admission failed closed on the thrown error: no
// persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal.
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String), userId: 'user-a' }),
);
expect(addMessage).not.toHaveBeenCalled();
expect(agentService.createSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(counters.dispatch).toBe(0);
expect(counters.dispose).toBe(0);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'conversation_unavailable' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
});
describe('Chat DTO validation', () => {
+14 -9
View File
@@ -492,16 +492,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
);
// Reconcile the verified binding against the durable agent record BEFORE claiming the message
// id (security fix, finding 3). The configured agent must exist and its record name must match
// the binding's instance id exactly; a missing record, a name mismatch, or a lookup error
// rejects the turn WITHOUT consuming the replay claim, so a corrected retry is still admitted.
// The branded identity is derived from the record (id + name), never from the raw binding
// strings — an unreconciled binding must not execute a default or different embedded agent
// under a verified label.
// id (security fix, finding 3). The configured agent must exist and its record must match the
// binding EXACTLY on BOTH id and name — a record whose id differs from the binding's
// agentConfigId (an aliased/substituted lookup) is rejected as firmly as a name mismatch. A
// missing record, an id mismatch, a name mismatch, or a lookup error rejects the turn WITHOUT
// consuming the replay claim, so a corrected retry is still admitted. The branded identity is
// derived from the record (id + name), never from the raw binding strings — an unreconciled
// binding must not execute a default or different embedded agent under a verified label.
let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string };
try {
const record = await this.brain.agents.findById(binding.agentConfigId);
if (!record || record.name !== binding.instanceId) {
if (!record || record.id !== binding.agentConfigId || record.name !== binding.instanceId) {
this.logger.warn(
`Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`,
);
@@ -1164,8 +1165,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const owned = await this.brain.conversations.findById(suppliedConversationId, userId);
return owned !== undefined;
}
await this.brain.conversations.create({ id: conversationId, userId });
return true;
// Minting a new conversation must actually yield the durable record we asked for before any
// runtime effect runs (finding 1). A `create` that resolves nullish, or returns a record that
// is not this exact id owned by this user, is a persistence failure — fail closed so the caller
// never dispatches/persists against an unpersisted or mis-scoped conversation.
const created = await this.brain.conversations.create({ id: conversationId, userId });
return created != null && created.id === conversationId && created.userId === userId;
} catch (err) {
this.logger.error(
`Conversation admission failed for conversation=${conversationId}`,
+30 -13
View File
@@ -94,9 +94,16 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort
);
});
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
const prompting = this.agentService.prompt(conversationId, input.content, scope);
try {
await this.agentService.prompt(conversationId, input.content, scope);
await done;
await Promise.all([prompting, done]);
} catch (err) {
dispose();
const message = err instanceof Error ? err.message : String(err);
@@ -300,18 +307,22 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
> {
// A verified-Discord turn may only run under a session whose configured identity matches the
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
// pre-existing session AND a freshly created one: a session carrying a different configured
// agent — however it arose — is rejected rather than executed under the verified label, so we
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
// no expectedAgent and skips the check.
const identityMatches = (candidate: AgentSession): boolean =>
expectedAgent === undefined ||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
candidate.agentName === expectedAgent.instanceId);
let session = this.agentService.getSession(conversationId, scope);
if (session && expectedAgent) {
// A verified-Discord turn reuses an existing in-process session only when its configured
// identity still matches the reconciled agent record. A pre-existing same-scope session
// carrying a different configured agent (config id or resolved name) is rejected rather than
// executed under the verified label — never silently run a different prompt/model/tool policy.
if (
session.agentConfigId !== expectedAgent.agentConfigId ||
session.agentName !== expectedAgent.instanceId
) {
return CONVERSATION_UNAVAILABLE;
}
if (session && !identityMatches(session)) {
// Reused same-scope session minted under a different configured identity — reject with zero
// effects rather than dispatch a verified turn onto a foreign agent's session.
return CONVERSATION_UNAVAILABLE;
}
if (!session) {
try {
@@ -330,6 +341,12 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
// The just-created session must ALSO carry the reconciled identity before any effect. A
// createSession that returns a session under a different configured agent (misconfiguration
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
if (!identityMatches(session)) {
return CONVERSATION_UNAVAILABLE;
}
}
return { ok: true, presentation: this.presentationForSession(session) };
}
@@ -612,6 +612,8 @@ describe('Discord ingress security', () => {
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -697,6 +699,8 @@ describe('Discord ingress security', () => {
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -796,6 +800,8 @@ describe('Discord ingress security', () => {
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -963,6 +969,197 @@ describe('Discord ingress security', () => {
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
// then admit the corrected record whose id matches the binding.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile-id',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with aliased agent id',
'discord-reconcile-id-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
// another agent's verified label. (The existing reuse test covers the getSession path; this
// covers the createSession path scrappy flagged as unvalidated.)
const mintedForeignSession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-postcreate-mismatch',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// The freshly minted session failed the post-create identity recheck: refused with a typed
// error, no prompt, no persist, no ack.
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
const attachment = {
id: 'attachment-history',