Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
33ca4b2a6a
commit
b21c84f231
@@ -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', () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user