From 6fd136e9448edf559947d279e0d1a33b24f7eb22 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:19:31 +0200 Subject: [PATCH] feat: suggestions the user accepts or dismisses with one tap (#142) Bots can offer an exact tool call as a card. Nothing runs until the user taps Accept; the call then runs as its own approved run of the bot it was meant for, and the card shows the outcome. Open suggestions also wait in the inbox and expire after a week. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude --- apps/api/src/inbox.test.ts | 43 ++++ apps/api/src/inbox.ts | 101 ++++++++- apps/api/src/router.ts | 11 +- apps/api/src/thread-cards.postgres.test.ts | 127 ++++++++++- apps/api/src/thread-cards.ts | 247 +++++++++++++++++---- apps/mobile/app/thread.tsx | 1 + apps/mobile/components/thread-card.tsx | 41 +++- apps/mobile/lib/locales/ru.ts | 3 + apps/mobile/lib/locales/zh.ts | 3 + apps/web/e2e/suggestion-card.spec.ts | 51 +++++ apps/web/scripts/translations-de.json | 5 +- apps/web/src/components/MainInbox.tsx | 46 +++- apps/web/src/components/ThreadCard.tsx | 34 ++- apps/web/src/lib/main-inbox.ts | 4 +- apps/web/src/lib/thread-events.ts | 1 + apps/web/src/locales/de/messages.po | 16 ++ apps/web/src/locales/en/messages.po | 16 ++ apps/web/src/locales/es/messages.po | 16 ++ apps/web/src/locales/hi/messages.po | 16 ++ apps/web/src/locales/ko/messages.po | 16 ++ apps/web/src/locales/pt-BR/messages.po | 16 ++ apps/web/src/locales/ru/messages.po | 16 ++ apps/web/src/locales/tr/messages.po | 16 ++ apps/web/src/locales/zh-CN/messages.po | 16 ++ apps/web/src/pages/shell/transcript.tsx | 1 + packages/adapters/src/builtin-tools.ts | 21 ++ packages/adapters/src/executor-tools.ts | 17 +- packages/adapters/src/executor.ts | 73 +++++- packages/adapters/src/schedule-tools.ts | 2 +- packages/adapters/src/scripted-runtime.ts | 23 ++ packages/adapters/src/suggestions.ts | 246 ++++++++++++++++++++ packages/contracts/src/cards.ts | 29 ++- packages/contracts/src/domain.ts | 1 + packages/contracts/src/inbox.ts | 9 +- packages/contracts/src/runs.ts | 1 + packages/core/src/cards.ts | 48 +++- packages/core/src/index.ts | 1 + packages/core/src/suggestions.test.ts | 61 +++++ packages/core/src/suggestions.ts | 132 +++++++++++ packages/db/src/card-messages.ts | 37 +++ packages/db/src/index.ts | 1 + 41 files changed, 1479 insertions(+), 86 deletions(-) create mode 100644 apps/web/e2e/suggestion-card.spec.ts create mode 100644 packages/adapters/src/suggestions.ts create mode 100644 packages/core/src/suggestions.test.ts create mode 100644 packages/core/src/suggestions.ts create mode 100644 packages/db/src/card-messages.ts diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 5da36aa..6b24226 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -1,8 +1,10 @@ +import { buildSuggestionCard, withSuggestionStatus } from "@rakazo/core"; import { describe, expect, it } from "vitest"; import { buildInboxItems, type InboxMessageRow, type InboxRunRow, + type InboxSuggestionRow, type InboxUnreadThreadRow, } from "./inbox.js"; @@ -291,4 +293,45 @@ describe("buildInboxItems", () => { expect(items[1]).not.toHaveProperty("space"); expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); }); + it("lists an open suggestion with its card, and drops it once settled", () => { + const card = buildSuggestionCard({ + id: "effect-9", + effectId: "effect-9", + tool: "schedule_create", + args: { name: "Morning walk", prompt: "Take a walk." }, + }); + const row = (blocks: unknown[]): InboxSuggestionRow => ({ + spaceId: "space-home", + createdAt: at(3), + bot: scout, + thread: { botId: scout.id, groupId: null, group: null }, + messageId: "msg-card", + cardId: "effect-9", + blocks, + }); + const items = buildInboxItems({ + runs: [], + messages: [], + unreadThreads: [unreadThread(scout, { id: "msg-card", text: "", minute: 3 })], + suggestions: [row([card])], + }); + expect(items).toEqual([ + { + kind: "suggestion", + bot: scout, + target: { botId: scout.id }, + messageId: "msg-card", + card, + createdAt: at(3).toISOString(), + }, + ]); + expect( + buildInboxItems({ + runs: [], + messages: [], + unreadThreads: [], + suggestions: [row([withSuggestionStatus(card, "dismissed")])], + }), + ).toEqual([]); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index b430f85..bc54610 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -1,5 +1,5 @@ import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts"; -import { MessageBlock } from "@rakazo/contracts"; +import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts"; import { isApprovalAskBlock } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { promptSnippet } from "./runs.js"; @@ -10,6 +10,7 @@ const INBOX_UNREAD_LIMIT = 50; const INBOX_PREVIEW_LENGTH = 280; type AskBlock = Extract; +type CardBlock = Extract; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; type InboxSpace = NonNullable; @@ -42,6 +43,18 @@ export interface InboxUnreadThreadRow { messages: Array>; } +/** An open suggestion: the card holding it and the thread it was posted in. */ +export interface InboxSuggestionRow { + spaceId: string; + createdAt: Date; + bot: InboxBot; + thread: InboxRunRow["thread"]; + messageId: string; + cardId: string; + /** The card message's blocks. */ + blocks: unknown; +} + function parseBlocks(blocks: unknown): MessageBlock[] { const parsed = MessageBlock.array().safeParse(blocks); return parsed.success ? parsed.data : []; @@ -98,7 +111,8 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number { } /** - * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: + * Turns waiting runs, their ask and takeover messages, open suggestions and unread threads into + * inbox items: * everything that waits on the user first, then unread threads not already covered, each * oldest first. Items from another space name it. */ @@ -107,6 +121,8 @@ export function buildInboxItems(input: { /** Ask and takeover messages of those runs, newest first. */ messages: InboxMessageRow[]; unreadThreads: InboxUnreadThreadRow[]; + /** Open suggestions, wherever their card was posted. */ + suggestions?: InboxSuggestionRow[]; /** The user's other spaces by id; rows of any space not listed belong to the current one. */ otherSpaces?: ReadonlyMap; }): InboxItem[] { @@ -156,6 +172,23 @@ export function buildInboxItems(input: { } } + for (const suggestion of input.suggestions ?? []) { + const target = askTarget(suggestion.thread, !input.otherSpaces?.has(suggestion.spaceId)); + const card = parseBlocks(suggestion.blocks).find( + (block): block is CardBlock => block.kind === "card" && block.id === suggestion.cardId, + ); + if (!target || card?.suggestion?.status !== "proposed") continue; + waiting.push({ + kind: "suggestion", + bot: suggestion.bot, + target, + ...spaceOf(suggestion.spaceId), + messageId: suggestion.messageId, + card, + createdAt: suggestion.createdAt.toISOString(), + }); + } + const covered = new Set(waiting.map((item) => targetKey(item.target))); const unread: InboxItem[] = []; for (const thread of input.unreadThreads) { @@ -203,7 +236,12 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise(); + for (const effect of suggestionEffects) { + const messageId = (effect.request as { card?: { messageId?: unknown } } | null)?.card + ?.messageId; + if (typeof messageId === "string") cardMessageIds.set(effect.id, messageId); + } + const cardMessages = + cardMessageIds.size === 0 + ? [] + : await prisma.message.findMany({ + where: { id: { in: [...cardMessageIds.values()] }, role: "bot" }, + select: { id: true, blocks: true }, + }); + const blocksById = new Map(cardMessages.map((message) => [message.id, message.blocks])); + const suggestions: InboxSuggestionRow[] = suggestionEffects.flatMap((effect) => { + const messageId = cardMessageIds.get(effect.id); + if (!messageId || !blocksById.has(messageId)) return []; + return [ + { + spaceId: effect.spaceId, + createdAt: effect.createdAt, + bot: effect.run.bot, + thread: effect.run.thread, + messageId, + cardId: effect.id, + blocks: blocksById.get(messageId), + }, + ]; + }); const messages = runs.length === 0 ? [] @@ -269,5 +348,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { - if (input.act.type !== "toggle" && (await modelSetup(deps, context.actor)).needsModel) { - throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." }); - } const target = await resolveThreadTarget(deps.prisma, context.actor, input); - return actOnThreadCard(deps, context.actor, target, input); + return actOnThreadCard(deps, context.actor, target, input, { + requireModel: async () => { + if ((await modelSetup(deps, context.actor)).needsModel) { + throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." }); + } + }, + }); }), stop: authed.threads.stop.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); diff --git a/apps/api/src/thread-cards.postgres.test.ts b/apps/api/src/thread-cards.postgres.test.ts index 0d18c34..7ab7c8a 100644 --- a/apps/api/src/thread-cards.postgres.test.ts +++ b/apps/api/src/thread-cards.postgres.test.ts @@ -1,6 +1,7 @@ import type { JobPublisher } from "@rakazo/adapter-kit"; import type { Actor, CardBlock, MessageBlock } from "@rakazo/contracts"; -import { buildCardFromTool } from "@rakazo/core"; +import { buildCardFromTool, buildSuggestionCard } from "@rakazo/core"; +import { approvalEffectKey } from "@rakazo/core/node/approval-effect-key"; import type { PrismaClient, ThreadEvents } from "@rakazo/db"; import { createDb, provisionMessagingIdentity } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -137,6 +138,130 @@ describePostgres("actOnThreadCard (PostgreSQL)", () => { ).rejects.toThrow("already answered"); }); + async function postSuggestion(createdAt?: Date) { + const task = await prisma.task.create({ + data: { + spaceId: identity.spaceId, + botId: identity.botId, + threadId: identity.threadId, + userId: identity.userId, + prompt: "I should move more", + status: "completed", + }, + }); + const run = await prisma.run.create({ + data: { + spaceId: identity.spaceId, + botId: identity.botId, + threadId: identity.threadId, + taskId: task.id, + userId: identity.userId, + status: "completed", + trigger: "user", + }, + }); + const call = { + tool: "schedule_create", + args: { name: "Morning walk", prompt: "Take a walk.", cron: "0 8 * * *" }, + }; + const effect = await prisma.externalEffect.create({ + data: { + spaceId: identity.spaceId, + runId: run.id, + kind: "suggestion", + idempotencyKey: `suggestion:${run.id}:test`, + status: "proposed", + request: { ...call, botId: identity.botId, threadId: identity.threadId, subject: "x" }, + ...(createdAt ? { createdAt } : {}), + }, + }); + const block = buildSuggestionCard({ id: effect.id, effectId: effect.id, ...call }); + const message = await prisma.message.create({ + data: { + threadId: identity.threadId, + seq: ++seq, + role: "bot", + botId: identity.botId, + blocks: [block], + }, + }); + await prisma.externalEffect.update({ + where: { id: effect.id }, + data: { + request: { + ...call, + botId: identity.botId, + threadId: identity.threadId, + subject: "x", + card: { threadId: identity.threadId, messageId: message.id }, + }, + }, + }); + return { messageId: message.id, cardId: effect.id, effectId: effect.id, ...call }; + } + + it("runs an accepted suggestion as an approved run, once", async () => { + const posted = await postSuggestion(); + enqueue.mockClear(); + const accept = { + messageId: posted.messageId, + cardId: posted.cardId, + act: { type: "action" as const, actionId: "accept" }, + }; + await act(accept, "accept-walk"); + await act(accept, "accept-walk"); + expect((await card(posted.messageId)).suggestion?.status).toBe("running"); + const effect = await prisma.externalEffect.findUniqueOrThrow({ + where: { id: posted.effectId }, + }); + expect(effect.status).toBe("accepted"); + const runId = (effect.result as { runId: string }).runId; + const run = await prisma.run.findUniqueOrThrow({ where: { id: runId } }); + expect(run).toMatchObject({ trigger: "suggestion", botId: identity.botId, status: "queued" }); + const approved = await prisma.externalEffect.findMany({ where: { runId } }); + expect(approved).toEqual([ + expect.objectContaining({ + kind: "schedule_create", + status: "approved", + idempotencyKey: approvalEffectKey(runId, "schedule_create", posted.args), + }), + ]); + expect(enqueue).toHaveBeenCalledTimes(1); + // No reply goes to the bot; the card itself carries the outcome. + expect(await prisma.message.count({ where: { replyToMessageId: posted.messageId } })).toBe(0); + await expect(act({ ...accept, act: { type: "action", actionId: "dismiss" } })).rejects.toThrow( + "already settled", + ); + }); + + it("dismisses a suggestion without running anything", async () => { + const posted = await postSuggestion(); + await act({ + messageId: posted.messageId, + cardId: posted.cardId, + act: { type: "action", actionId: "dismiss" }, + }); + expect((await card(posted.messageId)).suggestion?.status).toBe("dismissed"); + const effect = await prisma.externalEffect.findUniqueOrThrow({ + where: { id: posted.effectId }, + }); + expect(effect).toMatchObject({ status: "dismissed" }); + expect((effect.result as { runId: unknown }).runId).toBeNull(); + }); + + it("lets an old suggestion expire instead of running it", async () => { + const posted = await postSuggestion(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)); + await act({ + messageId: posted.messageId, + cardId: posted.cardId, + act: { type: "action", actionId: "accept" }, + }); + expect((await card(posted.messageId)).suggestion?.status).toBe("expired"); + expect( + await prisma.externalEffect.findUniqueOrThrow({ where: { id: posted.effectId } }), + ).toMatchObject({ status: "expired", result: null }); + }); + it("keeps other threads' cards out of reach", async () => { const posted = await postCard({ title: "Elsewhere", checklist: ["One"] }); await expect( diff --git a/apps/api/src/thread-cards.ts b/apps/api/src/thread-cards.ts index b6025b8..9e1feaa 100644 --- a/apps/api/src/thread-cards.ts +++ b/apps/api/src/thread-cards.ts @@ -1,28 +1,67 @@ import { ORPCError } from "@orpc/server"; import type { JobPublisher } from "@rakazo/adapter-kit"; +import { runContinueJob } from "@rakazo/adapter-kit"; import type { Actor, CardAct, CardBlock, MessageBlock } from "@rakazo/contracts"; -import { applyCardAct } from "@rakazo/core"; -import type { PrismaClient, ThreadEvents } from "@rakazo/db"; -import { appendEventInTransaction, IsolationError } from "@rakazo/db"; +import { SUGGESTION_TTL_MS } from "@rakazo/contracts"; +import type { CardActResult } from "@rakazo/core"; +import { applyCardAct, withSuggestionStatus } from "@rakazo/core"; +import { approvalEffectKey } from "@rakazo/core/node/approval-effect-key"; +import type { Prisma, PrismaClient, ThreadEvents } from "@rakazo/db"; +import { IsolationError, updateCardInTransaction } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import type { ThreadTarget } from "./thread-target.js"; -import { groupDefaultBotId, sendThreadMessage } from "./thread-target.js"; +import { sendThreadMessage } from "./thread-target.js"; type CardDeps = { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher }; -function isCard(block: MessageBlock, cardId: string): block is CardBlock { - return block.kind === "card" && block.id === cardId; +/** What an accepted suggestion runs: the stored call, by the bot it was meant for. */ +type StoredSuggestion = { + tool: string; + args: Record; + botId: string; + threadId: string; + card?: { threadId: string; messageId: string }; +}; + +function storedSuggestion(value: unknown): StoredSuggestion | null { + if (!value || typeof value !== "object") return null; + const request = value as Partial; + return typeof request.tool === "string" && + typeof request.botId === "string" && + typeof request.threadId === "string" && + request.args && + typeof request.args === "object" + ? (request as StoredSuggestion) + : null; +} + +function resultObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +async function loadCard(prisma: PrismaClient, threadId: string, messageId: string, cardId: string) { + const message = await prisma.message.findFirst({ + where: { id: messageId, threadId, role: "bot" }, + select: { blocks: true }, + }); + const blocks = (Array.isArray(message?.blocks) ? message.blocks : []) as MessageBlock[]; + const card = blocks.find((block) => block.kind === "card" && block.id === cardId); + return card?.kind === "card" ? card : null; } /** * Apply one tap on a bot's card. The card changes in place for everyone watching the thread; a * choice or an action then goes to the card's bot as the user's reply, through the normal send. + * Accept or Dismiss on a suggestion runs or drops the stored call instead. */ export async function actOnThreadCard( deps: CardDeps, actor: Actor, target: ThreadTarget, input: { messageId: string; cardId: string; act: CardAct; clientNonce: string }, + options: { requireModel?: () => Promise } = {}, ): Promise<{ ok: true }> { // A retried answer already landed as the user's reply; the card was answered with it. const replayed = await deps.prisma.message.findUnique({ @@ -31,49 +70,173 @@ export async function actOnThreadCard( }); if (replayed) return { ok: true }; - const applied = await deps.prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`; - const message = await tx.message.findFirst({ - where: { id: input.messageId, threadId: target.threadId, role: "bot" }, - select: { id: true, blocks: true, botId: true }, - }); - if (!message) throw new IsolationError(); - const blocks = (Array.isArray(message.blocks) ? message.blocks : []) as MessageBlock[]; - const index = blocks.findIndex((block) => isCard(block, input.cardId)); - const card = blocks[index]; - if (card?.kind !== "card") throw new IsolationError(); - const result = applyCardAct(card, input.act); - if (!result.ok) throw new ORPCError("CONFLICT", { message: result.error }); - if (!result.changed) return { reply: result.reply, botId: message.botId, seq: null }; - const next = blocks.map((block, position) => (position === index ? result.block : block)); - await tx.message.update({ where: { id: message.id }, data: { blocks: next } }); - const botId = - message.botId ?? (target.kind === "bot" ? target.botId : groupDefaultBotId(target)); - if (!botId) throw new IsolationError(); - const event = await appendEventInTransaction(tx, { - spaceId: actor.spaceId, - threadId: target.threadId, - botId, - type: "thread.message.updated", - payload: { messageId: message.id, role: "bot", blocks: next }, - }); - return { reply: result.reply, botId: message.botId, seq: event.seq }; - }); - if (applied.seq !== null) { - await deps.events.notify(target.threadId, applied.seq).catch((error) => { - getLogger().error("thread card realtime notification", error); - }); + const card = await loadCard(deps.prisma, target.threadId, input.messageId, input.cardId); + if (!card) throw new IsolationError(); + if (card.suggestion && input.act.type === "action") { + return settleSuggestion(deps, actor, target, input, card.suggestion.effectId); } - if (applied.reply) { + if (input.act.type !== "toggle") await options.requireModel?.(); + + let outcome: CardActResult | undefined; + let botId: string | null = null; + const updated = await deps.prisma.$transaction(async (tx) => { + const message = await tx.message.findFirst({ + where: { id: input.messageId, threadId: target.threadId }, + select: { botId: true }, + }); + botId = message?.botId ?? null; + return updateCardInTransaction( + tx, + { threadId: target.threadId, messageId: input.messageId, cardId: input.cardId }, + (current) => { + outcome = applyCardAct(current, input.act); + return outcome.ok && outcome.changed ? outcome.block : null; + }, + ); + }); + if (!outcome) throw new IsolationError(); + if (!outcome.ok) throw new ORPCError("CONFLICT", { message: outcome.error }); + if (updated) await notify(deps.events, target.threadId, updated.seq); + if (outcome.reply) { await sendThreadMessage(deps, actor, target, { - text: applied.reply, + text: outcome.reply, replyToMessageId: input.messageId, clientNonce: input.clientNonce, // In a group the answer goes to the bot that asked, not to whoever leads. - ...(target.kind === "group" && applied.botId - ? { mentions: [{ kind: "bot" as const, id: applied.botId }] } + ...(target.kind === "group" && botId + ? { mentions: [{ kind: "bot" as const, id: botId }] } : {}), }); } return { ok: true }; } + +async function notify(events: ThreadEvents, threadId: string, seq: number) { + await events.notify(threadId, seq).catch((error) => { + getLogger().error("thread card realtime notification", error); + }); +} + +/** + * Accept runs the stored call as its own run of the bot it was meant for, already approved: + * accepting is the approval. Dismiss only closes the card. Neither needs a model. + */ +async function settleSuggestion( + deps: CardDeps, + actor: Actor, + target: ThreadTarget, + input: { messageId: string; cardId: string; act: CardAct; clientNonce: string }, + effectId: string, +): Promise<{ ok: true }> { + const settled = await deps.prisma.$transaction(async (tx) => { + let outcome: CardActResult | undefined; + const effect = await tx.externalEffect.findUnique({ where: { id: effectId } }); + const request = storedSuggestion(effect?.request); + if ( + !effect || + !request || + effect.kind !== "suggestion" || + effect.spaceId !== actor.spaceId || + request.card?.messageId !== input.messageId + ) { + throw new IsolationError(); + } + // A retried tap that already went through. + if (resultObject(effect.result).clientNonce === input.clientNonce) return null; + const expired = Date.now() - effect.createdAt.getTime() > SUGGESTION_TTL_MS; + const updated = await updateCardInTransaction( + tx, + { threadId: target.threadId, messageId: input.messageId, cardId: input.cardId }, + (current: CardBlock) => { + if (expired && current.suggestion?.status === "proposed") { + return withSuggestionStatus(current, "expired"); + } + outcome = applyCardAct(current, input.act); + return outcome.ok && outcome.changed ? outcome.block : null; + }, + ); + if (expired) { + await tx.externalEffect.updateMany({ + where: { id: effect.id, status: "proposed" }, + data: { status: "expired" }, + }); + return { seq: updated?.seq ?? null, runId: null }; + } + if (!outcome) throw new IsolationError(); + if (!outcome.ok) throw new ORPCError("CONFLICT", { message: outcome.error }); + if (effect.status !== "proposed") { + throw new ORPCError("CONFLICT", { message: "This suggestion was already settled." }); + } + const runId = + outcome.suggestion === "accept" + ? await startAcceptedRun(tx, actor, effect.spaceId, request) + : null; + await tx.externalEffect.update({ + where: { id: effect.id }, + data: { + status: runId ? "accepted" : "dismissed", + result: { ...resultObject(effect.result), clientNonce: input.clientNonce, runId }, + }, + }); + return { seq: updated?.seq ?? null, runId }; + }); + if (settled?.seq) await notify(deps.events, target.threadId, settled.seq); + if (settled?.runId) { + await deps.jobs.enqueue(runContinueJob(settled.runId)).catch((error) => { + getLogger().error("accepted suggestion enqueue", error); + }); + } + return { ok: true }; +} + +async function startAcceptedRun( + tx: Prisma.TransactionClient, + actor: Actor, + spaceId: string, + request: StoredSuggestion, +): Promise { + const thread = await tx.thread.findFirst({ + where: { id: request.threadId, spaceId }, + select: { id: true }, + }); + const bot = await tx.bot.findFirst({ + where: { id: request.botId, spaceId }, + select: { id: true }, + }); + if (!thread || !bot) { + throw new ORPCError("CONFLICT", { message: "The bot for this suggestion is gone." }); + } + const task = await tx.task.create({ + data: { + spaceId, + botId: bot.id, + threadId: thread.id, + userId: actor.userId, + prompt: `Accepted suggestion: ${request.tool}`, + status: "queued", + }, + }); + const run = await tx.run.create({ + data: { + spaceId, + botId: bot.id, + threadId: thread.id, + taskId: task.id, + userId: actor.userId, + status: "queued", + trigger: "suggestion", + }, + }); + // The run replays this exact call; the approval gate sees it as already approved. + await tx.externalEffect.create({ + data: { + spaceId, + runId: run.id, + kind: request.tool, + idempotencyKey: approvalEffectKey(run.id, request.tool, request.args), + status: "approved", + request: request.args as never, + }, + }); + return run.id; +} diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index 89172ea..11d2948 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -2437,6 +2437,7 @@ const MessageBubble = memo(function MessageBubble({ onCardAct(message, cardId, act) : undefined} /> ); diff --git a/apps/mobile/components/thread-card.tsx b/apps/mobile/components/thread-card.tsx index a3ff2ed..b4f3506 100644 --- a/apps/mobile/components/thread-card.tsx +++ b/apps/mobile/components/thread-card.tsx @@ -7,6 +7,7 @@ import { checklistItemDisplay, formatCardClock, formatCardTimerLength, + suggestionStatus, } from "@rakazo/core"; import { useState } from "react"; import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; @@ -20,9 +21,12 @@ type Section = Extract; /** A bot's interactive card. Taps go to the server, which owns the card's state. */ export function ThreadCard({ block: stored, + createdAt, onAct, }: { block: CardBlock; + /** When the card was posted; an open suggestion expires after a while. */ + createdAt?: string; onAct?: (act: CardAct) => Promise; }) { const tokens = mobileTokens(); @@ -33,7 +37,11 @@ export function ThreadCard({ // Show the tap in flight on top of the stored card, so the card answers at once. const preview = pending ? applyCardAct(stored, pending) : null; const block = preview?.ok ? preview.block : stored; - const answered = Boolean(block.answer); + const suggestion = block.suggestion + ? suggestionStatus(block, createdAt ?? new Date()) + : undefined; + // A suggestion takes one tap; once it moved on, only its outcome shows. + const answered = Boolean(block.answer) || (suggestion !== undefined && suggestion !== "proposed"); const busy = !onAct || pending !== null; const factor = cardScale(block); const header = block.sections?.find((part): part is Section<"header"> => part.type === "header"); @@ -326,7 +334,11 @@ export function ThreadCard({ }, ]} > - {action.label} + {action.kind === "accept" + ? t("Accept") + : action.kind === "dismiss" + ? t("Dismiss") + : action.label} ); @@ -334,6 +346,31 @@ export function ThreadCard({ ); })} + {suggestion && suggestion !== "proposed" ? ( + + {suggestion === "failed" + ? (block.suggestion?.error ?? t("Failed")) + : suggestion === "running" + ? t("Running") + : suggestion === "done" + ? t("Done") + : suggestion === "dismissed" + ? t("Dismissed") + : t("Expired")} + + ) : null} {cooking && steps ? ( = { "Start timer": "Запустить таймер", "Step {position} of {total}": "Шаг {position} из {total}", "Stop timer": "Остановить таймер", + Accept: "Принять", + Dismissed: "Отклонено", + Expired: "Истекло", }; diff --git a/apps/mobile/lib/locales/zh.ts b/apps/mobile/lib/locales/zh.ts index c9477b4..a477242 100644 --- a/apps/mobile/lib/locales/zh.ts +++ b/apps/mobile/lib/locales/zh.ts @@ -600,4 +600,7 @@ export const ZH_MESSAGES: Record = { "Start timer": "开始计时", "Step {position} of {total}": "第 {position} 步,共 {total} 步", "Stop timer": "停止计时", + Accept: "接受", + Dismissed: "已忽略", + Expired: "已过期", }; diff --git a/apps/web/e2e/suggestion-card.spec.ts b/apps/web/e2e/suggestion-card.spec.ts new file mode 100644 index 0000000..396e900 --- /dev/null +++ b/apps/web/e2e/suggestion-card.spec.ts @@ -0,0 +1,51 @@ +import { expect, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers"; + +async function useAppearance(page: Page, appearance: "light" | "dark") { + const settings = await openUserSettings(page); + await settings.getByTestId(`ui-appearance-${appearance}`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", appearance); + await settings.getByRole("button", { name: "Close user settings" }).click(); + await expect(settings).toBeHidden(); +} + +test("a bot's suggestion runs only when the user accepts it", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `suggestion-card-${stamp}@rakazo.test`, "password12", "Suggestion Card"); + await completeOnboarding(page); + + await page.getByPlaceholder(/Message/).fill("I should move more."); + await page.keyboard.press("Enter"); + + const card = page.getByTestId("thread-card"); + await expect(card).toBeVisible({ timeout: 60_000 }); + await expect(card).toContainText("Set up a routine"); + await expect(card).toContainText("Morning walk"); + await expect(card).toContainText("A nudge each morning makes it a habit."); + // Nothing ran yet. + await expect(page.getByTestId("routine-event-line")).toHaveCount(0); + + await useAppearance(page, "light"); + await card.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "suggestion-card-light"); + await useAppearance(page, "dark"); + await card.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "suggestion-card-dark"); + + await card.getByRole("button", { name: "Accept" }).click(); + await expect(card.getByTestId("card-suggestion-status")).toContainText("Done", { + timeout: 60_000, + }); + await expect(card.getByRole("button", { name: "Accept" })).toHaveCount(0); + const line = page.getByTestId("routine-event-line"); + await expect(line).toBeVisible({ timeout: 30_000 }); + await expect(line).toContainText("Morning walk"); + await card.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "suggestion-card-done-dark"); + + // The outcome is stored on the card, so a reload keeps it. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("card-suggestion-status")).toContainText("Done", { + timeout: 30_000, + }); +}); diff --git a/apps/web/scripts/translations-de.json b/apps/web/scripts/translations-de.json index ece52dc..adbb5f2 100644 --- a/apps/web/scripts/translations-de.json +++ b/apps/web/scripts/translations-de.json @@ -636,5 +636,8 @@ "Show {hidden} more": "{hidden} weitere zeigen", "Start timer": "Timer starten", "Step {position} of {total}": "Schritt {position} von {total}", - "Stop timer": "Timer stoppen" + "Stop timer": "Timer stoppen", + "Accept": "Annehmen", + "Dismissed": "Abgelehnt", + "Expired": "Abgelaufen" } diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 64434f2..9e0cb0e 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -1,17 +1,20 @@ import { useLingui } from "@lingui/react/macro"; -import type { Bot, InboxItem, InboxThreadTarget } from "@rakazo/contracts"; +import type { Bot, CardAct, InboxItem, InboxThreadTarget } from "@rakazo/contracts"; import { resolveOoColor } from "@rakazo/core"; import { BotAvatar, parseBotAvatar } from "@rakazo/ui-web"; import { Monitor } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { inboxItemKey, inboxRefreshSignal } from "../lib/main-inbox"; import { rpc } from "../lib/rpc"; +import { newClientNonce } from "../pages/shell/thread-state"; import { AskCard } from "./AskCard"; +import { ThreadCard } from "./ThreadCard"; /** Catches what the bots' state does not reveal, such as a second ask with the same text. */ const INBOX_POLL_MS = 15_000; type PendingAskItem = Extract; +type SuggestionItem = Extract; /** Thread procedures for an item run in the item's own space. */ function itemContext(item: InboxItem) { @@ -20,7 +23,7 @@ function itemContext(item: InboxItem) { /** * What the user's bots in all of their spaces need from them outside the main conversation, with - * each bot as the sender: asks and approvals are answered in place, takeovers and unread threads + * each bot as the sender: asks, approvals and suggestions are answered in place, takeovers and unread threads * open the bot's thread. */ export function MainInbox({ @@ -101,6 +104,28 @@ export function MainInbox({ [refresh], ); + const actOnSuggestion = useCallback( + async (item: SuggestionItem, act: CardAct) => { + const cardId = item.card.id; + if (!cardId) return; + try { + await rpc.threads.act( + { + ...item.target, + messageId: item.messageId, + cardId, + act, + clientNonce: newClientNonce(), + }, + itemContext(item), + ); + } finally { + void refresh(); + } + }, + [refresh], + ); + if (items.length === 0) return null; return ( @@ -113,7 +138,22 @@ export function MainInbox({
    {items.map((item) => (
  • - {item.kind === "ask" || item.kind === "approval" ? ( + {item.kind === "suggestion" ? ( +
    + + actOnSuggestion(item, act)} + /> +
    + ) : item.kind === "ask" || item.kind === "approval" ? (
    ), )}
    ); })} + {suggestion === "running" ? ( + + {t`Running`} + + ) : suggestion === "done" ? ( + + + + ) : suggestion === "failed" ? ( +

    + {block.suggestion?.error ?? t`Failed`} +

    + ) : suggestion === "dismissed" || suggestion === "expired" ? ( +

    + {suggestion === "dismissed" ? t`Dismissed` : t`Expired`} +

    + ) : null} {error ?

    {error}

    : null} {cooking && steps ? ( ([ "cloud_agent", "delegation", "media", + "suggestion", ]); function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run { diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index e1285db..bf39da5 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -305,6 +305,10 @@ msgstr "Über Spaces" msgid "About you" msgstr "Über dich" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "Annehmen" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "Zugriffstoken" @@ -2015,6 +2019,7 @@ msgstr "Verbindung wird getrennt…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "Ausblenden" @@ -2023,6 +2028,10 @@ msgstr "Ausblenden" msgid "Dismiss error" msgstr "Fehler ausblenden" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "Abgelehnt" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "Verworfen. Du kannst die Verbindung jederzeit in den MCP-Einstellungen wiederherstellen." @@ -2062,6 +2071,7 @@ msgstr "Kein Stress — wir sind jetzt da." #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "Erweitern" msgid "Expand {0}" msgstr "{0} ausklappen" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "Abgelaufen" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "fehlgeschlagen" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "Fehlgeschlagen" @@ -4061,6 +4076,7 @@ msgstr "Die Ausführungszeit muss in der Zukunft liegen." msgid "running" msgstr "läuft" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "Läuft" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index db9f38f..c7b42d1 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -305,6 +305,10 @@ msgstr "About spaces" msgid "About you" msgstr "About you" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "Accept" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "Access token" @@ -2015,6 +2019,7 @@ msgstr "Disconnecting…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "Dismiss" @@ -2023,6 +2028,10 @@ msgstr "Dismiss" msgid "Dismiss error" msgstr "Dismiss error" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "Dismissed" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "Dismissed. Reconnect anytime from MCP settings." @@ -2062,6 +2071,7 @@ msgstr "Don't stress — we're here now." #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "Expand" msgid "Expand {0}" msgstr "Expand {0}" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "Expired" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "failed" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "Failed" @@ -4061,6 +4076,7 @@ msgstr "Run time must be in the future." msgid "running" msgstr "running" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "Running" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index ff38f65..5e156c8 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -305,6 +305,10 @@ msgstr "" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "" @@ -2015,6 +2019,7 @@ msgstr "Desconectando…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "Descartar" @@ -2023,6 +2028,10 @@ msgstr "Descartar" msgid "Dismiss error" msgstr "Descartar error" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "Descartado: puedes volver a conectar cuando quieras desde la configuración de MCP." @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "Expandir" msgid "Expand {0}" msgstr "Expandir {0}" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "fallido" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "Falló" @@ -4061,6 +4076,7 @@ msgstr "La hora de ejecución debe ser en el futuro." msgid "running" msgstr "en ejecución" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "En ejecución" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index b259b66..5790902 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -305,6 +305,10 @@ msgstr "" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "" @@ -2015,6 +2019,7 @@ msgstr "डिस्कनेक्ट हो रहा है…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "" @@ -2023,6 +2028,10 @@ msgstr "" msgid "Dismiss error" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "खारिज किया गया। MCP सेटिंग्स से कभी भी पुनः कनेक्ट करें।" @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "विस्तृत करें" msgid "Expand {0}" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "असफल" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "विफल" @@ -4061,6 +4076,7 @@ msgstr "" msgid "running" msgstr "चल रहा है" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "चल रहा है" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 36f9075..b273c9c 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -305,6 +305,10 @@ msgstr "" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "" @@ -2015,6 +2019,7 @@ msgstr "연결 해제 중…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "닫기" @@ -2023,6 +2028,10 @@ msgstr "닫기" msgid "Dismiss error" msgstr "오류 닫기" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "지금은 연결하지 않았습니다. MCP 설정에서 언제든 다시 연결할 수 있습니다." @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "펼치기" msgid "Expand {0}" msgstr "{0} 펼치기" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "실패" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "실패" @@ -4061,6 +4076,7 @@ msgstr "실행 시간은 미래여야 합니다." msgid "running" msgstr "실행 중" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "실행 중" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 2b98efb..4460e03 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -305,6 +305,10 @@ msgstr "" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "" @@ -2015,6 +2019,7 @@ msgstr "Desconectando…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "" @@ -2023,6 +2028,10 @@ msgstr "" msgid "Dismiss error" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "Dispensado. Reconecte a qualquer momento a partir das configurações do MCP." @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "Expandir" msgid "Expand {0}" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "falhou" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "Falha" @@ -4061,6 +4076,7 @@ msgstr "" msgid "running" msgstr "em execução" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "Em execução" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 362a098..aa27423 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -305,6 +305,10 @@ msgstr "О пространствах" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "Токен доступа" @@ -2015,6 +2019,7 @@ msgstr "Отключение…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "Закрыть" @@ -2023,6 +2028,10 @@ msgstr "Закрыть" msgid "Dismiss error" msgstr "Закрыть ошибку" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "Закрыто. Подключиться снова можно в любое время в настройках MCP." @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "Развернуть" msgid "Expand {0}" msgstr "Развернуть {0}" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "неуспешный" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "Ошибка" @@ -4061,6 +4076,7 @@ msgstr "Время выполнения должно быть в будущем. msgid "running" msgstr "выполняется" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "Выполняется" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index b7c354c..759e990 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -305,6 +305,10 @@ msgstr "" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "" @@ -2015,6 +2019,7 @@ msgstr "Bağlantı kesiliyor…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "" @@ -2023,6 +2028,10 @@ msgstr "" msgid "Dismiss error" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "Kapatıldı. MCP ayarlarından istediğin zaman yeniden bağlanabilirsin." @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "Genişlet" msgid "Expand {0}" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "başarısız" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "Başarısız" @@ -4061,6 +4076,7 @@ msgstr "" msgid "running" msgstr "çalışıyor" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "Çalışıyor" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 2eebc03..e8312a3 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -305,6 +305,10 @@ msgstr "" msgid "About you" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Accept" +msgstr "" + #: src/components/integrations/IntegrationSetup.tsx msgid "Access token" msgstr "" @@ -2015,6 +2019,7 @@ msgstr "正在断开…" #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/ThreadCard.tsx #: src/pages/shell/message-cards.tsx msgid "Dismiss" msgstr "" @@ -2023,6 +2028,10 @@ msgstr "" msgid "Dismiss error" msgstr "关闭错误" +#: src/components/ThreadCard.tsx +msgid "Dismissed" +msgstr "" + #: src/pages/shell/message-cards.tsx msgid "Dismissed. Reconnect anytime from MCP settings." msgstr "已关闭,可随时在 MCP 设置中重新连接。" @@ -2062,6 +2071,7 @@ msgstr "" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx #: src/pages/shell/avatar-studio-popover.tsx @@ -2251,6 +2261,10 @@ msgstr "展开" msgid "Expand {0}" msgstr "展开 {0}" +#: src/components/ThreadCard.tsx +msgid "Expired" +msgstr "" + #: src/components/media/MediaCard.tsx #: src/pages/shell/bot-panel.tsx msgid "Export" @@ -2284,6 +2298,7 @@ msgstr "失败" #: src/components/ai/DelegatedTaskCard.tsx #: src/components/media/MediaCard.tsx +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Failed" msgstr "失败" @@ -4061,6 +4076,7 @@ msgstr "运行时间必须是将来的时间。" msgid "running" msgstr "运行中" +#: src/components/ThreadCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Running" msgstr "运行中" diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 100a10e..25da752 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -1259,6 +1259,7 @@ const MessageView = memo(function MessageView({
    onCardAct(message, cardId, act) : undefined} />
    diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 302e281..2103a85 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -346,6 +346,27 @@ export const builtinAgentTools: ConnectorTool[] = [ required: ["title"], }, }, + { + name: "suggest", + description: + "Offer the user a ready-made action they start with one tap, instead of asking in text or doing it unasked: a routine, a note, a file, a task for a specialist. Name the exact tool and its arguments; nothing runs until the user taps Accept, and then it runs exactly as given. Use it when the user should decide, such as \"Should I do this every week?\". You can suggest several things in one reply. Results of a specialist's work that need the user's yes come back to you; suggest them with bot set to that specialist.", + inputSchema: { + type: "object", + properties: { + tool: { type: "string", description: "The name of one of your tools." }, + args: { + type: "object", + description: "That tool's arguments, exactly as you would call it.", + }, + why: { + type: "string", + maxLength: 120, + description: "One short reason the user sees, in the user's language.", + }, + }, + required: ["tool", "args"], + }, + }, { name: "message_user", description: diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index fabe476..bf14ffc 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -85,7 +85,11 @@ export function filterBuiltinToolsForBot( (personal || !PERSONAL_AGENT_TOOLS.has(tool.name)) && (!bot.restrictBuiltinTools || allowed.has(tool.name)), ) - .map((tool) => (main && tool.name === "schedule_create" ? withScheduleTargetBot(tool) : tool)); + .map((tool) => + main && (tool.name === "schedule_create" || tool.name === "suggest") + ? withScheduleTargetBot(tool) + : tool, + ); } export function filterPageBrowserTools( @@ -135,9 +139,14 @@ export function selectBuiltinToolsForRun(options: { (tool) => !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && (!options.messagingChannelRun || - (!["remember", "save_memory", "recall_memory", "forget_memory", "show_card"].includes( - tool.name, - ) && + (![ + "remember", + "save_memory", + "recall_memory", + "forget_memory", + "show_card", + "suggest", + ].includes(tool.name) && !USER_MODEL_TOOLS.has(tool.name) && !tool.name.startsWith("scratchpad_"))), ); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index ba957a1..917f76c 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -363,7 +363,7 @@ import { removeScratchpadItemFromTool, updateScratchpadItemFromTool, } from "./scratchpad-tools.js"; -import { inferScript } from "./scripted-runtime.js"; +import { inferScript, ScriptedAgentRuntime } from "./scripted-runtime.js"; import type { EncryptedSecretStore } from "./secrets.js"; import { listAgentSkillRecords, @@ -373,6 +373,12 @@ import { skillUpdateFromTool, } from "./skill-tools.js"; import { ensureStudioWhenMediaConfigured } from "./studio-specialist.js"; +import { + createSuggestionFromTool, + loadAcceptedSuggestion, + settleSuggestionCard, + toolResultError, +} from "./suggestions.js"; import { continueRunClaimFence, DESKTOP_HELD_FOR_TAKEOVER_MESSAGE, @@ -426,6 +432,8 @@ const BUILTIN_AGENT_TOOL_NAMES = new Set(builtinAgentTools.map((tool) => tool.na /** Cap the roster so a large Space cannot flood the prompt. */ const BOT_DIRECTORY_LIMIT = 40; const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots."; +/** Runs an accepted suggestion's stored call; it never calls a model. */ +const SUGGESTION_RUNTIME = new ScriptedAgentRuntime(); function runtimeFallbackModel(runtime: AgentRuntime) { return runtime.describe().capabilities.scripted ? { provider: "scripted", id: "scripted" } : null; @@ -950,6 +958,10 @@ export function createRunExecutor(deps: ExecutorDeps) { const run = await deps.prisma.run.findUnique({ where: { id: runId } }); if (!run) return; if (isTerminal(run.status as RunStatus)) return; + // An accepted suggestion replays the stored call through the normal tool path, no model. + const acceptedSuggestion = + run.trigger === "suggestion" ? await loadAcceptedSuggestion(deps.prisma, runId) : null; + const runtime = acceptedSuggestion ? SUGGESTION_RUNTIME : deps.runtime; let { resumeCheckpoint, heldForTakeover, resumeHeldLease, takeoverResume } = takeoverContinuePlan(run); @@ -1033,7 +1045,7 @@ export function createRunExecutor(deps: ExecutorDeps) { if (!leaseValid) return; leaseValid = false; runAbortController?.abort(); - void deps.runtime.abort(runId).catch(() => undefined); + void runtime.abort(runId).catch(() => undefined); }; const verifyLeaseOwnership = async (): Promise => { if (!leaseValid) return false; @@ -1321,7 +1333,7 @@ export function createRunExecutor(deps: ExecutorDeps) { ); } const runDeployment = deps.deploymentModelKey ? resolveDeploymentModel() : null; - const runtimeFallback = runtimeFallbackModel(deps.runtime); + const runtimeFallback = runtimeFallbackModel(runtime); const selected = selectConfiguredModel({ bot, tierModel, @@ -1430,7 +1442,7 @@ export function createRunExecutor(deps: ExecutorDeps) { // above. Deriving it a second time here dropped the deployment fallback, so a // vision-capable default was gated as "scripted" and lost its screenshot tools. const acceptsImages = - deps.runtime.describe().capabilities.scripted || + runtime.describe().capabilities.scripted || modelAcceptsImageInput(runModelProvider, runModelId, resolved.acceptsImages); const groupContext = thread.groupId ? await loadGroupContext(deps.prisma, thread.groupId, { id: bot.id, name: bot.name }) @@ -1632,8 +1644,12 @@ export function createRunExecutor(deps: ExecutorDeps) { let approvalPausePending = false; let handedOff = false; let progressRedactor = createStreamingRedactor(runSecrets); - const scripted = deps.runtime.describe().capabilities.scripted; - const script = scripted ? inferScript(task.prompt, takeoverResume?.checkpoint) : undefined; + const scripted = runtime.describe().capabilities.scripted; + const script = acceptedSuggestion + ? acceptedSuggestion.script + : scripted + ? inferScript(task.prompt, takeoverResume?.checkpoint) + : undefined; const flushProgress = async () => { if (scripted || !pendingProgress) return; await deps.events.append({ @@ -3490,6 +3506,20 @@ export function createRunExecutor(deps: ExecutorDeps) { autoSpeak: updated.autoSpeak, }); } + if (name === "suggest") { + const suggested = await createSuggestionFromTool(deps, { + run: { ...run, threadId: thread.id }, + bot, + executionId, + exposedToolNames, + args: JSON.parse(redactSecrets(JSON.stringify(args), runSecrets)) as Record< + string, + unknown + >, + }); + if ("ok" in suggested) publishedMidTurnUserMessage = true; + return finish(suggested); + } if (name === "show_card") { const built = buildCardFromTool( JSON.parse(redactSecrets(JSON.stringify(args), runSecrets)) as ShowCardArgs, @@ -3846,7 +3876,7 @@ export function createRunExecutor(deps: ExecutorDeps) { } try { - const runtimeEvents = deps.runtime.run( + const runtimeEvents = runtime.run( { botId: bot.id, threadId: thread.id, @@ -4253,6 +4283,16 @@ export function createRunExecutor(deps: ExecutorDeps) { const startedAt = Date.now(); try { const result = await applyTool(event.name, event.args, event.executionId); + if (acceptedSuggestion && !isToolPauseResult(result)) { + const failure = toolResultError(result); + await settleSuggestionCard( + deps, + acceptedSuggestion.effectId, + acceptedSuggestion.card, + failure ? "failed" : "done", + failure, + ); + } await appendToolCompletionAudit( deps, { @@ -4273,6 +4313,15 @@ export function createRunExecutor(deps: ExecutorDeps) { ); if (isToolPauseResult(result)) return; } catch (error) { + if (acceptedSuggestion) { + await settleSuggestionCard( + deps, + acceptedSuggestion.effectId, + acceptedSuggestion.card, + "failed", + error instanceof Error ? error.message : String(error), + ).catch((settleError) => getLogger().error("suggestion settle", settleError)); + } await appendToolCompletionAudit( deps, { @@ -4451,10 +4500,16 @@ export function createRunExecutor(deps: ExecutorDeps) { // only progress was posted, result when a final reply exists). messageSegments = completionMessageSegments(messageSegments, { allowSilentEmpty: - allowSilentPeerMessage || messagingChannelRun || publishedMidTurnUserMessage, + allowSilentPeerMessage || + messagingChannelRun || + publishedMidTurnUserMessage || + Boolean(acceptedSuggestion), emptyResponseText, suppressOutput: handedOff, - skipEmptyFallback: publishedTerminalSubagent || publishedMidTurnUserMessage, + skipEmptyFallback: + publishedTerminalSubagent || + publishedMidTurnUserMessage || + Boolean(acceptedSuggestion), }); } const blocks = handedOff diff --git a/packages/adapters/src/schedule-tools.ts b/packages/adapters/src/schedule-tools.ts index 529ae7a..53525c3 100644 --- a/packages/adapters/src/schedule-tools.ts +++ b/packages/adapters/src/schedule-tools.ts @@ -29,7 +29,7 @@ export function withScheduleTargetBot(tool: ConnectorTool): ConnectorTool { bot: { type: "string", description: - "Optional name or id of one of the user's specialists who should run this schedule. Omit to schedule for yourself.", + "Optional name or id of one of the user's specialists who should run this. Omit to run it yourself.", }, }, }, diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 60a8580..d4c039c 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -275,6 +275,29 @@ export function inferScript( }, ]; } + // An idea for the user: nothing runs until they accept it. + if (lower.includes("i should move more")) { + return [ + { + assistant: "", + toolCalls: [ + { + name: "suggest", + args: { + tool: "schedule_create", + args: { + name: "Morning walk", + prompt: "Remind me to take a short walk.", + cron: "0 8 * * *", + }, + why: "A nudge each morning makes it a habit.", + }, + }, + ], + complete: true, + }, + ]; + } // Everyday answers come as a card the user taps, not a list in prose. if (lower.includes("what should we cook tonight")) { return [ diff --git a/packages/adapters/src/suggestions.ts b/packages/adapters/src/suggestions.ts new file mode 100644 index 0000000..21ec774 --- /dev/null +++ b/packages/adapters/src/suggestions.ts @@ -0,0 +1,246 @@ +import type { AgentRunRequest } from "@rakazo/adapter-kit"; +import type { CardBlock } from "@rakazo/contracts"; +import { + buildSuggestionCard, + describeSuggestion, + UNSUGGESTABLE_TOOLS, + withSuggestionStatus, +} from "@rakazo/core"; +import type { PrismaClient, ThreadEvents } from "@rakazo/db"; +import { updateCardInTransaction } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; +import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; +import { publishMessage } from "./executor-computer.js"; +import { filterBuiltinToolsForBot } from "./executor-tools.js"; +import type { ExecutorDeps } from "./executor-types.js"; +import { resolveScheduleOwner } from "./schedule-tools.js"; + +const BUILTIN_NAMES = new Set([...builtinAgentTools, ...agentConnectionTools].map((t) => t.name)); + +/** What a suggestion effect stores: the exact call, who runs it, and where its card is. */ +export interface SuggestionRequest { + tool: string; + args: Record; + botId: string; + threadId: string; + why?: string; + /** Set once the card is posted. */ + card?: { threadId: string; messageId: string }; + /** Same tool and subject: a newer suggestion replaces an open older one. */ + subject: string; +} + +export function suggestionRequest(value: unknown): SuggestionRequest | null { + if (!value || typeof value !== "object") return null; + const request = value as Partial; + return typeof request.tool === "string" && + typeof request.botId === "string" && + typeof request.threadId === "string" && + request.args && + typeof request.args === "object" + ? (request as SuggestionRequest) + : null; +} + +type Notify = Pick; + +async function notify(events: Notify, threadId: string, seq: number) { + await events.notify(threadId, seq).catch((error) => { + getLogger().error("suggestion card realtime notification", error); + }); +} + +/** + * `suggest`: check the call as if it ran now, store it as a proposed effect and post its card. + * Nothing runs until the user accepts. + */ +export async function createSuggestionFromTool( + deps: Pick, + input: { + run: { id: string; spaceId: string; userId: string; threadId: string; botId: string }; + bot: { id: string; kind: string }; + executionId: string; + exposedToolNames: ReadonlySet; + args: Record; + }, +): Promise> { + const tool = typeof input.args.tool === "string" ? input.args.tool.trim() : ""; + if (!tool) return { error: "tool is required." }; + if (UNSUGGESTABLE_TOOLS.has(tool) || tool.startsWith("autopilot_")) { + return { error: `${tool} cannot be suggested; use it directly.` }; + } + const raw = input.args.args; + const args = + raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + const owner = await resolveScheduleOwner( + deps.prisma, + { + botId: input.bot.id, + kind: input.bot.kind, + spaceId: input.run.spaceId, + userId: input.run.userId, + threadId: input.run.threadId, + }, + typeof input.args.bot === "string" ? input.args.bot : undefined, + ); + if ("error" in owner) return owner; + if (owner.spaceId !== input.run.spaceId) { + return { error: "Suggest only for bots in this project." }; + } + if (owner.botId === input.bot.id) { + if (!input.exposedToolNames.has(tool)) return { error: `You cannot use ${tool} here.` }; + } else if (BUILTIN_NAMES.has(tool)) { + const target = await deps.prisma.bot.findUnique({ + where: { id: owner.botId }, + select: { kind: true, restrictBuiltinTools: true, allowedBuiltinTools: true }, + }); + const allowed = target + ? filterBuiltinToolsForBot( + builtinAgentTools.filter((candidate) => candidate.name === tool), + target, + ) + : []; + if (!allowed.length) return { error: `${owner.botName ?? "That bot"} cannot use ${tool}.` }; + } + const why = typeof input.args.why === "string" ? input.args.why.trim() : ""; + const { title, details } = describeSuggestion(tool, args); + const request: SuggestionRequest = { + tool, + args, + botId: owner.botId, + threadId: owner.threadId, + subject: [title, ...details].join(" · "), + ...(why ? { why } : {}), + }; + const idempotencyKey = `suggestion:${input.run.id}:${input.executionId}`; + const existing = await deps.prisma.externalEffect.findUnique({ where: { idempotencyKey } }); + const effect = + existing ?? + (await deps.prisma.externalEffect.create({ + data: { + spaceId: input.run.spaceId, + runId: input.run.id, + kind: "suggestion", + idempotencyKey, + status: "proposed", + request: request as never, + }, + })); + if (!existing) { + await expireOlderSuggestions(deps, effect.id, input.run.threadId, request); + const card = buildSuggestionCard({ + id: effect.id, + effectId: effect.id, + tool, + args, + ...(why ? { why } : {}), + }); + const message = await publishMessage( + deps as ExecutorDeps, + input.run, + "bot", + [card], + undefined, + `suggestion:${effect.id}`, + ); + await deps.prisma.externalEffect.update({ + where: { id: effect.id }, + data: { + request: { + ...request, + card: { threadId: input.run.threadId, messageId: message.id }, + } as never, + }, + }); + } + return { + ok: true, + suggested: request.subject, + note: "The user sees it as a card with Accept and Dismiss. Do not ask for it again in text.", + }; +} + +/** A newer suggestion about the same thing replaces an open older one in the same thread. */ +async function expireOlderSuggestions( + deps: Pick, + keepId: string, + threadId: string, + request: SuggestionRequest, +) { + const open = await deps.prisma.externalEffect.findMany({ + where: { + kind: "suggestion", + status: "proposed", + id: { not: keepId }, + AND: [ + { request: { path: ["card", "threadId"], equals: threadId } }, + { request: { path: ["subject"], equals: request.subject } }, + ], + }, + select: { id: true, request: true }, + }); + for (const older of open) { + const card = suggestionRequest(older.request)?.card; + await settleSuggestionCard(deps, older.id, card, "expired"); + } +} + +/** Move a suggestion effect and its card to a final state. */ +export async function settleSuggestionCard( + deps: Pick, + effectId: string, + card: SuggestionRequest["card"], + status: "done" | "failed" | "expired", + error?: string, +) { + const updated = await deps.prisma.$transaction(async (tx) => { + const effect = await tx.externalEffect.findUnique({ + where: { id: effectId }, + select: { result: true }, + }); + const previous = + effect?.result && typeof effect.result === "object" ? (effect.result as object) : {}; + await tx.externalEffect.update({ + where: { id: effectId }, + data: { status, ...(error ? { result: { ...previous, error } } : {}) }, + }); + if (!card) return null; + return updateCardInTransaction( + tx, + { threadId: card.threadId, messageId: card.messageId, cardId: effectId }, + (current: CardBlock) => withSuggestionStatus(current, status, error), + ); + }); + if (updated && card) await notify(deps.events, card.threadId, updated.seq); +} + +/** The call an accepted suggestion runs, as a script that needs no model. */ +export async function loadAcceptedSuggestion( + prisma: PrismaClient, + runId: string, +): Promise<{ + effectId: string; + card: SuggestionRequest["card"]; + script: NonNullable; +} | null> { + const suggestion = await prisma.externalEffect.findFirst({ + where: { kind: "suggestion", result: { path: ["runId"], equals: runId } }, + select: { id: true, request: true }, + }); + const request = suggestionRequest(suggestion?.request); + if (!suggestion || !request) return null; + return { + effectId: suggestion.id, + card: request.card, + script: [ + { assistant: "", toolCalls: [{ name: request.tool, args: request.args }], complete: true }, + ], + }; +} + +/** A tool result's failure, in the tool's words. */ +export function toolResultError(result: unknown): string | undefined { + if (!result || typeof result !== "object") return undefined; + const error = (result as { error?: unknown }).error; + return typeof error === "string" && error ? error : undefined; +} diff --git a/packages/contracts/src/cards.ts b/packages/contracts/src/cards.ts index b578b11..ad87fcd 100644 --- a/packages/contracts/src/cards.ts +++ b/packages/contracts/src/cards.ts @@ -112,12 +112,13 @@ export const CardSelectSchema = z.object({ /** * "reply" sends the label, with any selection, to the bot as the user's answer. "steps" opens the - * card's steps one at a time in full screen (cooking mode) and never reaches the bot. + * card's steps one at a time in full screen (cooking mode) and never reaches the bot. "accept" + * and "dismiss" settle a suggestion card: accepting runs the prepared tool call, with no model. */ export const CardActionSchema = z.object({ id: SectionId, label: Label, - kind: z.enum(["reply", "steps"]), + kind: z.enum(["reply", "steps", "accept", "dismiss"]), }); export const CardActionsSchema = z.object({ @@ -136,6 +137,29 @@ export const CardSectionSchema = z.discriminatedUnion("type", [ ]); export type CardSection = z.infer; +/** How long a suggestion can be accepted before it is stale. */ +export const SUGGESTION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export const SuggestionStatusSchema = z.enum([ + "proposed", + "running", + "done", + "failed", + "dismissed", + "expired", +]); +export type SuggestionStatus = z.infer; + +/** A prepared tool call the user starts with one tap; `effectId` holds the exact call. */ +export const CardSuggestionSchema = z.object({ + effectId: SectionId, + /** The tool the suggestion runs, so clients can name it in the user's language. */ + tool: z.string().min(1).max(120), + status: SuggestionStatusSchema, + /** Why it failed, in the tool's words. */ + error: z.string().max(300).optional(), +}); + /** Set once the user answered the card; the card then stays as it was answered. */ export const CardAnswerSchema = z.object({ text: z.string(), actionId: SectionId.optional() }); @@ -149,6 +173,7 @@ export const CardBlockSchema = z.object({ lines: z.array(z.object({ k: z.string(), v: z.string() })).optional(), sections: z.array(CardSectionSchema).max(CARD_LIMITS.sections).optional(), answer: CardAnswerSchema.optional(), + suggestion: CardSuggestionSchema.optional(), }); export type CardBlock = z.infer; diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 7401e33..49c247c 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -929,6 +929,7 @@ export const RunSchema = z.object({ "cloud_agent", "delegation", "media", + "suggestion", ]), routineId: Id.nullable(), modelProvider: z.string().nullable(), diff --git a/packages/contracts/src/inbox.ts b/packages/contracts/src/inbox.ts index 3bd318d..7a56a0f 100644 --- a/packages/contracts/src/inbox.ts +++ b/packages/contracts/src/inbox.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { CardBlockSchema } from "./cards.js"; import { AskBlockSchema } from "./events.js"; import { Id } from "./ids.js"; @@ -30,7 +31,7 @@ const PendingAskItem = InboxItemBase.extend({ /** * What a bot needs from the user outside the main conversation, in any of the user's spaces: an - * answer, an approval, the computer screen, or a look at an unread thread. + * answer, an approval, the computer screen, a suggestion to accept, or a look at an unread thread. */ export const InboxItemSchema = z.discriminatedUnion("kind", [ PendingAskItem.extend({ kind: z.literal("ask") }), @@ -42,6 +43,12 @@ export const InboxItemSchema = z.discriminatedUnion("kind", [ /** Why the bot needs the screen. */ text: z.string(), }), + InboxItemBase.extend({ + kind: z.literal("suggestion"), + /** The bot message holding the card; accept or dismiss it with threads.act. */ + messageId: Id, + card: CardBlockSchema, + }), InboxItemBase.extend({ kind: z.literal("unread"), messageId: Id.nullable(), diff --git a/packages/contracts/src/runs.ts b/packages/contracts/src/runs.ts index cfb2a92..6244ebb 100644 --- a/packages/contracts/src/runs.ts +++ b/packages/contracts/src/runs.ts @@ -23,6 +23,7 @@ export const RunActivityRowSchema = z.object({ "cloud_agent", "delegation", "media", + "suggestion", ]), notificationsEnabled: z.boolean(), promptSnippet: z.string(), diff --git a/packages/core/src/cards.ts b/packages/core/src/cards.ts index 7eb3b42..fcd782e 100644 --- a/packages/core/src/cards.ts +++ b/packages/core/src/cards.ts @@ -1,4 +1,10 @@ -import type { CardAct, CardBlock, CardQuantity, CardSection } from "@rakazo/contracts"; +import type { + CardAct, + CardBlock, + CardQuantity, + CardSection, + SuggestionStatus, +} from "@rakazo/contracts"; import { CARD_LIMITS, CardBlockSchema } from "@rakazo/contracts"; type CardChecklistItem = Extract["items"][number]; @@ -145,7 +151,14 @@ export function buildCardFromTool( } export type CardActResult = - | { ok: true; block: CardBlock; changed: boolean; reply?: string } + | { + ok: true; + block: CardBlock; + changed: boolean; + reply?: string; + /** Set when the tap settles a suggestion; the caller runs or drops the stored call. */ + suggestion?: "accept" | "dismiss"; + } | { ok: false; error: string }; function section( @@ -238,6 +251,22 @@ export function applyCardAct( ); return { ok: true, changed: true, block: replaceSection(block, { ...list, items }, index) }; } + if (act.type === "action" && block.suggestion) { + const kind = section(block, "actions")?.actions.find((a) => a.id === act.actionId)?.kind; + if (kind !== "accept" && kind !== "dismiss") return { ok: false, error: "No such action." }; + if (block.suggestion.status !== "proposed") { + return { ok: false, error: "This suggestion was already settled." }; + } + return { + ok: true, + changed: true, + suggestion: kind, + block: { + ...block, + suggestion: { ...block.suggestion, status: kind === "accept" ? "running" : "dismissed" }, + }, + }; + } if (block.answer) return { ok: false, error: "This card was already answered." }; if (act.type === "select") { const index = sections.findIndex( @@ -289,6 +318,15 @@ export function applyCardAct( }; } +const SUGGESTION_HISTORY: Record = { + proposed: "waiting for the user", + running: "accepted, running", + done: "accepted and done", + failed: "accepted but failed", + dismissed: "dismissed by the user", + expired: "expired", +}; + /** The card as a later turn reads it: content plus what the user has done with it. */ export function cardHistoryText(block: CardBlock): string { if (!block.sections) return (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n"); @@ -323,6 +361,12 @@ export function cardHistoryText(block: CardBlock): string { } } if (block.answer) parts.push(`answered: ${block.answer.text}`); + if (block.suggestion) { + parts[parts.length - 1] = + `${block.suggestion.tool} ${SUGGESTION_HISTORY[block.suggestion.status]}`; + if (block.suggestion.error) parts.push(block.suggestion.error); + return `[suggestion: ${parts.join(" — ")}]`; + } return `[card: ${parts.join(" — ")}]`; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62fcfd7..e05968a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,7 @@ export * from "./secrets-guard.js"; export * from "./self-update.js"; export * from "./signup-policy.js"; export * from "./speech-text.js"; +export * from "./suggestions.js"; export * from "./teach-playbook.js"; export * from "./teach-recording.js"; export * from "./text-direction.js"; diff --git a/packages/core/src/suggestions.test.ts b/packages/core/src/suggestions.test.ts new file mode 100644 index 0000000..b524f80 --- /dev/null +++ b/packages/core/src/suggestions.test.ts @@ -0,0 +1,61 @@ +import { SUGGESTION_TTL_MS } from "@rakazo/contracts"; +import { describe, expect, it } from "vitest"; +import { applyCardAct, cardHistoryText } from "./cards.js"; +import { + buildSuggestionCard, + describeSuggestion, + suggestionStatus, + withSuggestionStatus, +} from "./suggestions.js"; + +const walk = { + id: "s1", + effectId: "s1", + tool: "schedule_create", + args: { name: "Morning walk", prompt: "Take a walk.", cron: "0 8 * * *" }, + why: "A nudge each morning makes it a habit.", +}; + +describe("suggestions", () => { + it("says what the stored call does, not what the model claims", () => { + expect(describeSuggestion(walk.tool, walk.args)).toEqual({ + title: "Set up a routine", + details: ["Morning walk", "Take a walk.", "Every day at 8:00 AM"], + }); + expect(describeSuggestion("gmail_send_email", {}).title).toBe("Gmail send email"); + }); + + it("takes one tap: accept runs it, then nothing else applies", () => { + const card = buildSuggestionCard(walk); + const accepted = applyCardAct(card, { type: "action", actionId: "accept" }); + expect(accepted).toMatchObject({ ok: true, suggestion: "accept" }); + if (!accepted.ok) throw new Error(accepted.error); + expect(accepted.block.suggestion?.status).toBe("running"); + expect(accepted.reply).toBeUndefined(); + expect(applyCardAct(accepted.block, { type: "action", actionId: "dismiss" })).toMatchObject({ + ok: false, + }); + expect(applyCardAct(card, { type: "action", actionId: "dismiss" })).toMatchObject({ + ok: true, + suggestion: "dismiss", + block: { suggestion: { status: "dismissed" } }, + }); + }); + + it("reads an open suggestion past its time as expired", () => { + const card = buildSuggestionCard(walk); + const created = new Date("2026-10-01T08:00:00Z"); + expect(suggestionStatus(card, created, created.getTime() + 1000)).toBe("proposed"); + expect(suggestionStatus(card, created, created.getTime() + SUGGESTION_TTL_MS + 1)).toBe( + "expired", + ); + const done = withSuggestionStatus(card, "done"); + expect(suggestionStatus(done, created, created.getTime() + SUGGESTION_TTL_MS + 1)).toBe("done"); + }); + + it("tells the model where the suggestion stands", () => { + const failed = withSuggestionStatus(buildSuggestionCard(walk), "failed", "No such tool."); + expect(cardHistoryText(failed)).toContain("schedule_create"); + expect(cardHistoryText(failed)).toContain("No such tool."); + }); +}); diff --git a/packages/core/src/suggestions.ts b/packages/core/src/suggestions.ts new file mode 100644 index 0000000..61c5646 --- /dev/null +++ b/packages/core/src/suggestions.ts @@ -0,0 +1,132 @@ +import type { CardBlock, SuggestionStatus } from "@rakazo/contracts"; +import { CARD_LIMITS, SUGGESTION_TTL_MS } from "@rakazo/contracts"; +import { formatCron } from "./cron.js"; + +/** Tools a suggestion may never wrap: they talk to the user or steer the run itself. */ +export const UNSUGGESTABLE_TOOLS: ReadonlySet = new Set([ + "suggest", + "show_card", + "ask_user", + "message_user", + "request_secret", + "request_takeover", + "run_subagent", +]); + +/** Plain names for builtin tools; anything else is named from its tool name. */ +const TOOL_TITLES: Record = { + schedule_create: "Set up a routine", + schedule_cancel: "Stop a routine", + remember: "Remember this", + write_file: "Write a file", + web_search: "Search the web", + delegate_task: "Hand this to a specialist", + spawn_bot: "Create a bot", + create_project: "Create a project", + update_goal: "Set a goal", + update_habit: "Track a habit", + scratchpad_add: "Note this for later", + "destination.write": "Save it", +}; + +/** Arguments that say what a call is about, in the order a reader looks for them. */ +const SUBJECT_KEYS = [ + "name", + "title", + "query", + "path", + "bot", + "content", + "prompt", + "task", + "brief", +]; + +function clip(value: string, max: number) { + const flat = value.replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +/** "gmail_send_email" reads as "Gmail send email". */ +function humanizeTool(tool: string): string { + const words = tool.replace(/[._-]+/g, " ").trim(); + return words ? words[0]!.toUpperCase() + words.slice(1) : tool; +} + +/** + * What a suggestion will do, written by the backend from the exact call rather than by the model, + * so the button always says what really happens. + */ +export function describeSuggestion( + tool: string, + args: Record, +): { title: string; details: string[] } { + const details: string[] = []; + for (const key of SUBJECT_KEYS) { + const value = args[key]; + if (typeof value === "string" && value.trim()) details.push(clip(value, CARD_LIMITS.label)); + if (details.length === CARD_LIMITS.meta) break; + } + if (tool === "schedule_create" && typeof args.cron === "string" && details.length < 3) { + details.push(clip(formatCron(args.cron), CARD_LIMITS.label)); + } + return { title: TOOL_TITLES[tool] ?? humanizeTool(tool), details }; +} + +/** A suggestion card: what the call does, why, and Accept or Dismiss. */ +export function buildSuggestionCard(input: { + id: string; + effectId: string; + tool: string; + args: Record; + why?: string; +}): CardBlock { + const { title, details } = describeSuggestion(input.tool, input.args); + const why = input.why?.trim(); + return { + kind: "card", + id: input.id, + sections: [ + { + type: "header", + title, + ...(why ? { subtitle: clip(why, CARD_LIMITS.title) } : {}), + ...(details.length ? { meta: details } : {}), + }, + { + type: "actions", + actions: [ + { id: "accept", label: "Accept", kind: "accept" }, + { id: "dismiss", label: "Dismiss", kind: "dismiss" }, + ], + }, + ], + suggestion: { effectId: input.effectId, tool: input.tool, status: "proposed" }, + }; +} + +/** A proposed suggestion past its time reads as expired, whatever is stored. */ +export function suggestionStatus( + card: Pick, + createdAt: Date | string, + now: number = Date.now(), +): SuggestionStatus | undefined { + const status = card.suggestion?.status; + if (status !== "proposed") return status; + const created = typeof createdAt === "string" ? Date.parse(createdAt) : createdAt.getTime(); + return now - created > SUGGESTION_TTL_MS ? "expired" : status; +} + +/** The same card with its suggestion moved on. */ +export function withSuggestionStatus( + card: CardBlock, + status: SuggestionStatus, + error?: string, +): CardBlock { + if (!card.suggestion) return card; + const { error: _previous, ...rest } = card.suggestion; + return { + ...card, + suggestion: { ...rest, status, ...(error ? { error: clip(error, 300) } : {}) }, + }; +} diff --git a/packages/db/src/card-messages.ts b/packages/db/src/card-messages.ts new file mode 100644 index 0000000..6ac7d67 --- /dev/null +++ b/packages/db/src/card-messages.ts @@ -0,0 +1,37 @@ +import type { CardBlock, MessageBlock } from "@rakazo/contracts"; +import type { Prisma } from "./client.js"; +import { appendEventInTransaction } from "./events.js"; + +/** + * Change one card in a bot message and tell the thread. The thread row is locked so two taps on + * the same card apply one after the other. `change` returns null to leave the card as it is; + * the result is null when the message, the card or a change is missing. + */ +export async function updateCardInTransaction( + tx: Prisma.TransactionClient, + input: { threadId: string; messageId: string; cardId: string }, + change: (card: CardBlock) => CardBlock | null, +): Promise<{ seq: number; card: CardBlock } | null> { + await tx.$queryRaw`SELECT id FROM threads WHERE id = ${input.threadId} FOR UPDATE`; + const message = await tx.message.findFirst({ + where: { id: input.messageId, threadId: input.threadId, role: "bot" }, + select: { id: true, blocks: true, botId: true, thread: { select: { spaceId: true } } }, + }); + if (!message?.botId) return null; + const blocks = (Array.isArray(message.blocks) ? message.blocks : []) as MessageBlock[]; + const index = blocks.findIndex((block) => block.kind === "card" && block.id === input.cardId); + const card = blocks[index]; + if (card?.kind !== "card") return null; + const next = change(card); + if (!next) return null; + const updated = blocks.map((block, position) => (position === index ? next : block)); + await tx.message.update({ where: { id: message.id }, data: { blocks: updated } }); + const event = await appendEventInTransaction(tx, { + spaceId: message.thread.spaceId, + threadId: input.threadId, + botId: message.botId, + type: "thread.message.updated", + payload: { messageId: message.id, role: "bot", blocks: updated }, + }); + return { seq: event.seq, card: next }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 83c8165..910d8b6 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,5 +1,6 @@ export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; +export * from "./card-messages.js"; export * from "./client.js"; export * from "./coach.js"; export * from "./computers.js";