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
11 changes: 9 additions & 2 deletions examples/chat-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 19 additions & 2 deletions src/chat-routes/turn-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,20 @@ export interface ChatTurnRouteProducer extends ChatTurnProducer {
}

export type ChatTurnAuthorization<TContext> =
| { 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 {
Expand Down Expand Up @@ -564,7 +577,11 @@ export function createChatTurnRoutes<TContext = void>(
}

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',
Expand Down
27 changes: 27 additions & 0 deletions tests/chat-routes/turn-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) => fakeProducer([{ type: 'text', text: 'ok' }], 'ok'))
const { routes, rows, ctx, pending } = makeRoutes({ produce })
Expand Down
Loading