From f7c78c3e15cf7cb39e4e6c275af79983abd27bae Mon Sep 17 00:00:00 2001 From: vutuanlinh2k2 Date: Mon, 20 Jul 2026 10:39:29 +0700 Subject: [PATCH] feat(chat-routes): let authorize suppress the user-row insert for a dispatched turn Add an optional insertUserMessage?: boolean to the ok:true branch of ChatTurnAuthorization. When false, the engine skips the role:'user' appendMessage for a product-dispatched / synthetic turn (e.g. a plan follow-up the product raised itself) that must not surface a new user row. AND-composed with the engine's retry-dedup so it can only subtract, never resurrect a turn already deduped as a retry. Omitting the field reproduces today's behavior exactly. --- examples/chat-app.md | 11 +++++++++-- src/chat-routes/turn-routes.ts | 21 +++++++++++++++++++-- tests/chat-routes/turn-routes.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/examples/chat-app.md b/examples/chat-app.md index f3c0730..ddbf420 100644 --- a/examples/chat-app.md +++ b/examples/chat-app.md @@ -55,11 +55,18 @@ export function buildChat(env: Env) { }) // The guard throws a JSON 401; guardResolution adapts it to {ok, response}. - const authorize = async ({ request }: { request: Request }) => { + // A dispatched/synthetic turn (e.g. a follow-up the product raised itself, not + // typed by the user) can set `insertUserMessage: false` to run the turn without + // surfacing a new `role:'user'` row. It only subtracts — the engine's retry + // dedup still applies — and defaults to today's behavior when omitted. + const authorize = async ({ request, body }: { request: Request; body?: { planFollowUp?: unknown } }) => { const auth = await guardResolution(() => requireApiUser(request)) if (!auth.ok) return auth const { user } = auth.value - return { ok: true as const, tenantId: user.id, userId: user.id, context: { user } } + return { + ok: true as const, tenantId: user.id, userId: user.id, context: { user }, + ...(body?.planFollowUp ? { insertUserMessage: false } : {}), + } } const routes = createChatTurnRoutes({ diff --git a/src/chat-routes/turn-routes.ts b/src/chat-routes/turn-routes.ts index 5e568c6..39b4951 100644 --- a/src/chat-routes/turn-routes.ts +++ b/src/chat-routes/turn-routes.ts @@ -114,7 +114,20 @@ export interface ChatTurnRouteProducer extends ChatTurnProducer { } export type ChatTurnAuthorization = - | { ok: true; tenantId: string; userId: string; context: TContext } + | { + ok: true + tenantId: string + userId: string + context: TContext + /** When `false`, skip the `role:'user'` message insert for this turn — for + * a product-dispatched / synthetic turn (e.g. a follow-up the product + * raised itself) that must not surface a new user row. Composes with — + * never overrides — the engine's retry-dedup: `authorize` runs before + * turn identity is resolved, so it cannot tell a retry from a fresh turn; + * a turn already deduped stays deduped. Omit / `true` → today's behavior. + * @experimental Single-consumer; shape may change. */ + insertUserMessage?: boolean + } | { ok: false; response: Response } export interface ChatTurnAuthorizeArgs { @@ -564,7 +577,11 @@ export function createChatTurnRoutes( } try { - if (chatTurn.shouldInsertUserMessage) { + // The product (via `authorize`) may suppress the user-row insert for a + // dispatched/synthetic turn. AND-composition: it can only subtract, never + // resurrect a turn the engine already deduped as a retry. + const insertUserMessage = chatTurn.shouldInsertUserMessage && (auth.insertUserMessage ?? true) + if (insertUserMessage) { await options.store.appendMessage({ threadId: payload.threadId, role: 'user', diff --git a/tests/chat-routes/turn-routes.test.ts b/tests/chat-routes/turn-routes.test.ts index 3fb7856..5827e48 100644 --- a/tests/chat-routes/turn-routes.test.ts +++ b/tests/chat-routes/turn-routes.test.ts @@ -145,6 +145,33 @@ describe('createChatTurnRoutes — turn', () => { expect(rows.filter((r) => r.role === 'user')).toHaveLength(1) }) + it('authorize insertUserMessage:false suppresses the user-row insert but still runs the turn', async () => { + const produce = vi.fn(() => fakeProducer([{ type: 'text', text: 'ack' }], 'ack')) + const { routes, rows, ctx, pending } = makeRoutes({ + produce, + authorize: async () => ({ ok: true, tenantId: 'ws-1', userId: 'user-1', context: undefined, insertUserMessage: false }), + }) + await readLines((await routes.turn(turnRequest({ threadId: 't-1', content: 'synthetic follow-up' }), ctx)).body!) + await Promise.all(pending) + + expect(produce).toHaveBeenCalledTimes(1) + expect(rows.filter((r) => r.role === 'user')).toHaveLength(0) + expect(rows.filter((r) => r.role === 'assistant')).toHaveLength(1) + }) + + it('authorize insertUserMessage:true cannot resurrect a deduped retry (AND-composition)', async () => { + const { routes, rows, ctx, pending } = makeRoutes({ + authorize: async () => ({ ok: true, tenantId: 'ws-1', userId: 'user-1', context: undefined, insertUserMessage: true }), + }) + const body = { threadId: 't-1', content: 'same', turnId: 'turn-abc' } + await readLines((await routes.turn(turnRequest(body), ctx)).body!) + await Promise.all(pending.splice(0)) + await readLines((await routes.turn(turnRequest(body), ctx)).body!) + await Promise.all(pending.splice(0)) + + expect(rows.filter((r) => r.role === 'user')).toHaveLength(1) + }) + it('persists echoed file parts onto the user message and hands parts to the producer', async () => { const produce = vi.fn((_args: ChatTurnProduceArgs) => fakeProducer([{ type: 'text', text: 'ok' }], 'ok')) const { routes, rows, ctx, pending } = makeRoutes({ produce })