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";