dopus 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # dopus
2
2
 
3
- Drop an AI agent into your existing Next.js app. Your server functions become its tools.
3
+ A lightweight agent for Next.js. Hand it the server functions your app already has, and it does the user's tasks with
4
+ them, with the user's own permissions.
4
5
 
5
6
  ```bash
6
7
  pnpm add dopus
@@ -14,6 +15,21 @@ pnpm dopus init
14
15
 
15
16
  Built on the [Vercel AI SDK](https://ai-sdk.dev) 7. Supports OpenAI, Anthropic and Google models.
16
17
 
18
+ **What it is for.** An assistant inside your app that answers questions about the user's data and makes small changes
19
+ on request: "show my open orders", "move this task to done", "assign the deploy checklist to Carol". Each turn is a few
20
+ quick server-function calls inside one request, usually a few seconds.
21
+
22
+ **What it is not.** dopus stays small on purpose. These are out of scope:
23
+
24
+ - Running long work itself. A tool should finish in seconds. Longer jobs run in your own queue, Lambda or worker; a
25
+ tool starts them, and the agent picks up again when your app announces they finished (see
26
+ [Background jobs](#background-jobs)).
27
+ - Autonomous, scheduled or multi-agent workflows.
28
+ - Built-in memory, RAG or a vector store. If you need search over documents, expose it as a tool.
29
+ - Frameworks other than Next.js, and the Pages Router.
30
+ - Running the agent somewhere other than your Next.js app. `dopus infra` only writes the deploy config that keeps the
31
+ agent route streaming on AWS, Cloudflare or Cloud Run.
32
+
17
33
  ---
18
34
 
19
35
  ## Quick start
@@ -108,6 +124,20 @@ export default defineAgent({
108
124
 
109
125
  `ctx` is `{ user, toolCallId, abortSignal }`. `user` is only set when you configure `identify` (see below).
110
126
 
127
+ ### Designing tools
128
+
129
+ A lightweight agent works best with small, fast tools:
130
+
131
+ - **Few.** Register the tools a user needs here, not every function you have. Past roughly 20 to 30 tools, models pick
132
+ the wrong one more often, and every request carries every description.
133
+ - **Fast.** A tool should return in a second or two. Longer work belongs in a job (see
134
+ [Timeouts and long-running tools](#timeouts-and-long-running-tools)).
135
+ - **Small results.** Take filters and a limit, and return only the fields the agent needs (`output` helps). A thousand
136
+ rows cost tokens on every later step, and the whole history counts against `limits.maxBodyBytes`.
137
+ - **Batch, don't loop.** "Close all 200 stale tickets" should be one server function that takes a filter, not 200 calls.
138
+ A turn allows at most `maxSteps` (10) model ↔ tool round trips.
139
+ - **Clear names and descriptions.** The model chooses by them. Say when _not_ to use a tool in `usage`.
140
+
111
141
  ### Errors
112
142
 
113
143
  Only two kinds of error message reach the model:
@@ -151,7 +181,7 @@ Your part, so an injected instruction cannot cause damage:
151
181
 
152
182
  We tested this with a real model in a small task-board app. A member wrote a task whose title ordered the assistant to delete other tasks, then an admin's assistant listed the tasks. Claude (claude-sonnet-5-5) did not even request the deletion and pointed out the planted text. If a model did request it, the approval card would still stop it. Other models were not tested this way.
153
183
 
154
- **Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly. Infra adapters that run tools elsewhere will build on this.
184
+ **Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly. dopus itself always runs tools inside the request; this is for jobs your tools hand off.
155
185
 
156
186
  ## Prompts
157
187
 
@@ -173,7 +203,8 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
173
203
  - `<AgentChat />`: the panel;
174
204
  - `<AgentWidget />`: a floating launcher that opens the panel.
175
205
 
176
- Both accept `api`, `suggestions`, `labels` (every UI string, for translation) and `className`.
206
+ Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
207
+ `maxJobFollowUps` for [background jobs](#background-jobs).
177
208
 
178
209
  The UI shows each tool call as it runs. It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
179
210
 
@@ -288,11 +319,98 @@ The job runs in your queue or background worker, outside the request, so it does
288
319
  permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
289
320
  check that the job belongs to the caller.
290
321
 
322
+ With a status tool alone, the user has to ask "is it done?". To have the agent continue by itself when the job finishes,
323
+ mark the start tool with `job` and give the chat UI a channel: see [Background jobs](#background-jobs).
324
+
291
325
  **If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
292
326
  Tools behind `confirm: true` need a new approval; make other writes idempotent.
293
327
 
328
+ ## Background jobs
329
+
330
+ For work that takes longer than a request: a Lambda that calls an LLM for a minute, a report, a bulk import. The tool
331
+ starts the job and returns at once; your worker does the work; your app announces "job X finished" on a channel it
332
+ already has (Supabase Realtime, a WebSocket, SSE, polling). The chat UI hears it and the agent continues: it reads the
333
+ result with another of your tools and does what the user asked next.
334
+
335
+ ```
336
+ user: "build the workload report, then assign unassigned tasks to whoever is least busy"
337
+ → startWorkloadReport returns { jobId } at once; the agent says it started, the turn ends
338
+ … your worker runs for a minute, saves the result, publishes "job-7 finished" on your channel …
339
+ → the chat UI hears job-7 sends a notice carrying only the ID; a new turn starts
340
+ → getReportResult({ jobId }) your tool reads the result and checks it belongs to the user
341
+ → assignTask(...) confirmed tools still stop at the approval card
342
+ ```
343
+
344
+ **Server**: mark the tool that starts the job, and add a tool that reads the result.
345
+
346
+ ```ts
347
+ startReport: defineTool(() => startReportJob(), { // invoke the Lambda / enqueue, return { jobId } at once
348
+ description: 'Starts the workload report. Takes about a minute.',
349
+ input: z.object({}),
350
+ job: (result) => result.jobId, // this tool starts a background job
351
+ }),
352
+ getReportResult: defineTool((input) => readReport(input.jobId), { // check the job belongs to the caller
353
+ description: 'Reads a report job by its id: status and, when done, the report.',
354
+ usage: 'After the app says a report job has finished.',
355
+ input: z.object({ jobId: z.string() }),
356
+ }),
357
+ ```
358
+
359
+ `job` returns the job's ID (letters, digits, `_ . : -`, up to 128 characters). dopus tells the model the job runs in
360
+ the background and that it will be told when it finishes, so it does not poll.
361
+
362
+ **Client**: give the chat a `channel`. It holds functions, so create it in a client component, once:
363
+
364
+ ```tsx
365
+ // components/agent/agent.tsx
366
+ 'use client'
367
+ import { type AgentChannel, type AgentChatProps, AgentWidget } from './agent-chat'
368
+ import { supabase } from '@/lib/supabase/client'
369
+
370
+ const channel: AgentChannel = {
371
+ subscribe(finished) {
372
+ const ch = supabase
373
+ .channel('jobs', { config: { private: true } })
374
+ .on('broadcast', { event: 'job-done' }, ({ payload }) => finished(payload.jobId))
375
+ .subscribe()
376
+ return () => {
377
+ supabase.removeChannel(ch)
378
+ }
379
+ },
380
+ }
381
+
382
+ export function Agent(props: AgentChatProps) {
383
+ return <AgentWidget {...props} channel={channel} />
384
+ }
385
+ ```
386
+
387
+ Any source works: `new EventSource(url)`, a WebSocket, or a `setInterval` that asks your API which jobs finished. Call
388
+ `finished(jobId)` for each one; return a function that stops listening. Then use `<Agent />` in your layout instead of
389
+ `<AgentWidget />`.
390
+
391
+ **How it is kept safe:**
392
+
393
+ - **The channel is a doorbell, not a mailbox.** Only the job ID goes from the channel to the server. The text the model
394
+ sees ("Background job X has finished…") is written by the server, and the result comes from your tool, which checks
395
+ permissions. A forged or replayed event can at most make the agent look up a job the user can already read.
396
+ - **Only this chat's jobs.** The UI reacts only to IDs that a tool in this conversation returned and that it has not
397
+ reported yet. Another tab, or a new chat, ignores them.
398
+ - **Follow-ups are capped.** After `maxJobFollowUps` (default 3) automatic turns in a row, a finished job is shown in
399
+ the chat but the agent waits for the user's next message, so a job that starts another job cannot loop.
400
+ - **Approvals still apply.** An automatic turn stops at the approval card like any other.
401
+
402
+ **Limits:**
403
+
404
+ - The follow-up runs only while the chat is open in a browser. If the user closes the tab, the result is still in your
405
+ app (show it there); the agent just does not act on it. Work that must continue without the user belongs in your
406
+ worker, not in the agent.
407
+ - Each finished job costs one more model call.
408
+
294
409
  ## Deploying
295
410
 
411
+ The `dopus infra` commands write the deploy configuration that keeps the agent route streaming within each platform's
412
+ limits. The agent still runs inside your Next.js app; nothing is moved elsewhere.
413
+
296
414
  **Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it; the time budget follows it.
297
415
 
298
416
  **AWS (OpenNext / SST).**
@@ -370,9 +488,10 @@ Swap in a scripted `MockLanguageModelV4` from `ai/test` behind an environment va
370
488
 
371
489
  dopus is pre-1.0, so expect breaking changes before 1.0. It is tested on Next.js 16 with the Node.js runtime.
372
490
 
373
- Planned next:
491
+ Planned next, within the lightweight scope above:
374
492
 
375
- - `dopus prompts diff` to compare your copies with newer templates;
493
+ - a command that updates your copies of the chat UI and route when the templates change (it also brings background
494
+ job support into projects created before it);
376
495
  - tool input schemas inferred from TypeScript types.
377
496
 
378
497
  ## Development
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as PROVIDERS, t as VERSION } from "./version-DmrGrSmM.js";
2
+ import { n as PROVIDERS, t as VERSION } from "./version-Dm8IBEUF.js";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { parseArgs } from "node:util";
5
5
  import * as p from "@clack/prompts";
package/dist/index.d.ts CHANGED
@@ -60,6 +60,12 @@ interface ToolOptions<SCHEMA extends z.ZodType, RESULT> {
60
60
  confirm?: ConfirmOption<z.infer<SCHEMA>>;
61
61
  /** Map the function's return value before it is sent to the model (trim fields, hide secrets). */
62
62
  output?: (result: Awaited<RESULT>) => unknown;
63
+ /**
64
+ * Marks a tool that starts a background job and returns at once (hands work to a queue, a Lambda, ...).
65
+ * Return the job's ID from the function's result. The chat UI waits for that ID on its `channel`; when
66
+ * the job finishes the agent is told, reads the result with another of your tools, and continues.
67
+ */
68
+ job?: (result: Awaited<RESULT>) => string;
63
69
  }
64
70
  type ToolFunction<INPUT, RESULT> = (input: INPUT, ctx: ToolContext) => RESULT;
65
71
  interface DopusTool<SCHEMA extends z.ZodType = z.ZodType, RESULT = unknown> {
@@ -210,5 +216,5 @@ interface ProviderInfo {
210
216
  }
211
217
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
212
218
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
213
- export declare const VERSION = "0.2.5";
219
+ export declare const VERSION = "0.2.6";
214
220
  export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
package/dist/index.js CHANGED
@@ -1,5 +1,32 @@
1
- import { n as PROVIDERS, t as VERSION } from "./version-DmrGrSmM.js";
1
+ import { n as PROVIDERS, t as VERSION } from "./version-Dm8IBEUF.js";
2
2
  import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
3
+ // src/job.ts
4
+ /**
5
+ * Background jobs: a tool marked with `job` starts work elsewhere (a queue, a Lambda) and returns at once.
6
+ * The chat UI listens on the app's channel for "job <id> finished", then sends a notice part back. The
7
+ * notice carries only the job ID; the agent reads the result with one of the app's tools, which checks the
8
+ * user's permissions. Nothing the channel delivers reaches the model as text.
9
+ */
10
+ /** Key added to a job tool's output; the chat UI reads it to know which jobs this conversation started. */
11
+ const JOB_OUTPUT_KEY = "dopusJob";
12
+ const JOB_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
13
+ function isJobId(value) {
14
+ return typeof value === "string" && JOB_ID.test(value);
15
+ }
16
+ const JOB_USAGE = "This starts a background job and returns at once. The app tells you in this chat when the job finishes; do not check on it unless the user asks.";
17
+ function withJob(output, id) {
18
+ return typeof output === "object" && output !== null && !Array.isArray(output) ? {
19
+ ...output,
20
+ [JOB_OUTPUT_KEY]: id
21
+ } : {
22
+ result: output,
23
+ [JOB_OUTPUT_KEY]: id
24
+ };
25
+ }
26
+ /** Server-written text for a job notice. Only the validated ID comes from the client. */
27
+ function jobNotice(id) {
28
+ return `[Background job ${id} has finished. This notice comes from the app, not from the user. Read the job's result with your tools, then continue with what the user asked, and tell the user briefly what came of it.]`;
29
+ }
3
30
  // src/next-errors.ts
4
31
  /**
5
32
  * An error whose message is safe to show to the model (and, through it, the user).
@@ -110,7 +137,8 @@ function isDopusTool(value) {
110
137
  function toolDescription(options) {
111
138
  const description = options.description.trim();
112
139
  const usage = options.usage?.trim();
113
- return usage ? `${description}\n\nWhen to use: ${usage}` : description;
140
+ const text = usage ? `${description}\n\nWhen to use: ${usage}` : description;
141
+ return options.job ? `${text}\n\n${JOB_USAGE}` : text;
114
142
  }
115
143
  // src/agent.ts
116
144
  const KEEP_ALIVE = new TextEncoder().encode(": keep-alive\n\n");
@@ -198,7 +226,13 @@ function buildTools(tools, ctx) {
198
226
  toolCallId,
199
227
  abortSignal
200
228
  });
201
- const output = options.output ? options.output(result) : result;
229
+ const mapped = options.output ? options.output(result) : result;
230
+ let output = mapped;
231
+ if (options.job) {
232
+ const jobId = options.job(result);
233
+ if (!isJobId(jobId)) throw new TypeError(`tools.${name}: job() must return an ID matching [A-Za-z0-9_.:-]{1,128}`);
234
+ output = withJob(mapped, jobId);
235
+ }
202
236
  await runHook(ctx.onToolCall, {
203
237
  ...base,
204
238
  outcome: "success",
@@ -284,6 +318,7 @@ function createAgentHandler(config, options = {}) {
284
318
  if (!validated.success) return errorResponse(400, "Invalid messages");
285
319
  const messages = validated.data;
286
320
  if (messages.some((message) => message.role === "user" && message.parts.some((part) => part.type === "text" && part.text.length > maxMessageChars))) return errorResponse(413, "Message is too long");
321
+ if (messages.some((message) => message.parts.some((part) => part.type === "data-dopus-job" && !isJobId(part.data?.id)))) return errorResponse(400, "Invalid job notice");
287
322
  const early = await config.beforeRequest?.({
288
323
  request,
289
324
  user,
@@ -313,7 +348,13 @@ function createAgentHandler(config, options = {}) {
313
348
  ...config.callOptions,
314
349
  model: config.model,
315
350
  instructions: buildInstructions(config.prompt, runtime),
316
- messages: await convertToModelMessages(messages, { tools }),
351
+ messages: await convertToModelMessages(messages, {
352
+ tools,
353
+ convertDataPart: (part) => part.type === "data-dopus-job" ? {
354
+ type: "text",
355
+ text: jobNotice(part.data.id)
356
+ } : void 0
357
+ }),
317
358
  tools,
318
359
  toolApproval,
319
360
  stopWhen: isStepCount(maxSteps),
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.5";
30
+ const VERSION = "0.2.6";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.5",
4
- "description": "Drop an AI agent into your existing Next.js app — your server functions become its tools.",
3
+ "version": "0.2.6",
4
+ "description": "A lightweight agent for Next.js: your existing server functions become its tools, run with the signed-in user's permissions.",
5
5
  "license": "MIT",
6
6
  "author": "akodm",
7
7
  "homepage": "https://github.com/akodm/dopus#readme",
@@ -24,7 +24,10 @@
24
24
  "openai",
25
25
  "anthropic",
26
26
  "gemini",
27
- "chatbot"
27
+ "chatbot",
28
+ "lightweight",
29
+ "app-router",
30
+ "tools"
28
31
  ],
29
32
  "type": "module",
30
33
  "engines": {
@@ -142,6 +142,12 @@
142
142
  color: var(--dopus-muted);
143
143
  }
144
144
 
145
+ .dopus-notice {
146
+ align-self: center;
147
+ color: var(--dopus-muted);
148
+ font-size: 0.8125rem;
149
+ }
150
+
145
151
  .dopus-error {
146
152
  color: var(--dopus-danger);
147
153
  }
@@ -4,6 +4,7 @@
4
4
  * Chat UI for the dopus agent. Copied by `dopus init`; this file is yours to edit.
5
5
  * Talks to POST /api/agent with the AI SDK `useChat` protocol.
6
6
  * Styles: ./agent-chat.css — plain global CSS with `dopus-` class names (works with or without Tailwind).
7
+ * Background jobs: pass a `channel` and the agent continues by itself when a job it started finishes.
7
8
  */
8
9
  import { useChat } from '@ai-sdk/react'
9
10
  import {
@@ -19,6 +20,7 @@ import {
19
20
  type FormEvent,
20
21
  type KeyboardEvent,
21
22
  useEffect,
23
+ useMemo,
22
24
  useRef,
23
25
  useState,
24
26
  } from 'react'
@@ -44,6 +46,8 @@ const DEFAULT_LABELS = {
44
46
  declined: 'Declined',
45
47
  failed: 'Failed',
46
48
  details: 'Details',
49
+ jobRunning: 'Working in the background',
50
+ jobDone: 'Background task finished',
47
51
  open: 'Open assistant',
48
52
  close: 'Close assistant',
49
53
  }
@@ -57,6 +61,35 @@ export interface AgentChatProps {
57
61
  /** Example prompts shown before the first message. */
58
62
  suggestions?: string[]
59
63
  className?: string
64
+ /**
65
+ * Where the app announces finished background jobs (Supabase Realtime, a WebSocket, SSE, polling...).
66
+ * When a job started in this chat finishes, the agent is told and continues by itself. Create the
67
+ * channel once (module scope or `useMemo`) in a client component; it holds functions, so it cannot be
68
+ * passed from a server component.
69
+ */
70
+ channel?: AgentChannel
71
+ /** Follow-up turns the agent may take in a row on finished jobs before waiting for the user. Default 3. */
72
+ maxJobFollowUps?: number
73
+ }
74
+
75
+ export interface AgentChannel {
76
+ /** Start listening and call `finished(jobId)` for each job that finishes. Return a function that stops. */
77
+ subscribe: (finished: (jobId: string) => void) => () => void
78
+ }
79
+
80
+ // Must match the dopus server: a job tool's output carries `dopusJob: <id>`, and a finished job is reported
81
+ // back as a `data-dopus-job` part. The part carries only the ID; the server writes the text the model sees.
82
+ const JOB_KEY = 'dopusJob'
83
+ const JOB_PART = 'data-dopus-job'
84
+
85
+ function startedJob(part: UIMessage['parts'][number]): string | undefined {
86
+ if (!isToolUIPart(part) || part.state !== 'output-available') return undefined
87
+ const id = (part.output as Record<string, unknown> | null)?.[JOB_KEY]
88
+ return typeof id === 'string' ? id : undefined
89
+ }
90
+
91
+ function isJobNotice(message: UIMessage) {
92
+ return message.role === 'user' && message.parts.every((part) => part.type === JOB_PART)
60
93
  }
61
94
 
62
95
  type ToolPart = Extract<UIMessage['parts'][number], { toolCallId: string }>
@@ -78,12 +111,21 @@ function ToolCall({
78
111
  part,
79
112
  labels,
80
113
  onRespond,
114
+ waiting,
81
115
  }: {
82
116
  part: ToolPart
83
117
  labels: AgentChatLabels
84
118
  onRespond: (approvalId: string, approved: boolean) => void
119
+ waiting: boolean
85
120
  }) {
86
121
  const name = humanize(getToolName(part))
122
+ if (waiting) {
123
+ return (
124
+ <div className="dopus-tool" data-state="running">
125
+ <span className="dopus-spinner" aria-hidden /> {labels.jobRunning}: {name}
126
+ </div>
127
+ )
128
+ }
87
129
 
88
130
  switch (part.state) {
89
131
  case 'input-streaming':
@@ -187,11 +229,16 @@ function Message({
187
229
  message,
188
230
  labels,
189
231
  onRespond,
232
+ waitingJobs,
190
233
  }: {
191
234
  message: UIMessage
192
235
  labels: AgentChatLabels
193
236
  onRespond: (approvalId: string, approved: boolean) => void
237
+ waitingJobs: Set<string>
194
238
  }) {
239
+ if (isJobNotice(message)) {
240
+ return <div className="dopus-notice">✓ {labels.jobDone}</div>
241
+ }
195
242
  return (
196
243
  <div className="dopus-message" data-role={message.role}>
197
244
  {message.parts.map((part, index) => {
@@ -207,7 +254,16 @@ function Message({
207
254
  )
208
255
  }
209
256
  if (isToolUIPart(part)) {
210
- return <ToolCall key={key} part={part} labels={labels} onRespond={onRespond} />
257
+ const job = startedJob(part)
258
+ return (
259
+ <ToolCall
260
+ key={key}
261
+ part={part}
262
+ labels={labels}
263
+ onRespond={onRespond}
264
+ waiting={job !== undefined && waitingJobs.has(job)}
265
+ />
266
+ )
211
267
  }
212
268
  return null
213
269
  })}
@@ -220,6 +276,8 @@ export function AgentChat({
220
276
  labels: labelOverrides,
221
277
  suggestions = [],
222
278
  className,
279
+ channel,
280
+ maxJobFollowUps = 3,
223
281
  }: AgentChatProps) {
224
282
  const labels = { ...DEFAULT_LABELS, ...labelOverrides }
225
283
  const router = useRouter()
@@ -256,6 +314,55 @@ export function AgentChat({
256
314
  const listRef = useRef<HTMLDivElement>(null)
257
315
  const busy = status === 'submitted' || status === 'streaming'
258
316
 
317
+ // Jobs the channel reported finished. Kept even before this chat shows the job (a job can finish before
318
+ // its tool output has streamed in); only IDs this chat started are acted on.
319
+ const [finishedJobs, setFinishedJobs] = useState<Set<string>>(() => new Set())
320
+ useEffect(() => {
321
+ if (!channel) return
322
+ return channel.subscribe((id) => setFinishedJobs((prev) => new Set(prev).add(id)))
323
+ }, [channel])
324
+
325
+ // Derived from the messages, so a new chat (or another tab) never reacts to jobs it did not start.
326
+ const { waitingJobs, readyJobs } = useMemo(() => {
327
+ const started = new Set<string>()
328
+ const reported = new Set<string>()
329
+ for (const message of messages) {
330
+ for (const part of message.parts) {
331
+ const id = startedJob(part)
332
+ if (id) started.add(id)
333
+ if (part.type === JOB_PART) reported.add((part.data as { id: string }).id)
334
+ }
335
+ }
336
+ const waiting = new Set([...started].filter((id) => !reported.has(id)))
337
+ return { waitingJobs: waiting, readyJobs: [...waiting].filter((id) => finishedJobs.has(id)) }
338
+ }, [messages, finishedJobs])
339
+
340
+ useEffect(() => {
341
+ if (readyJobs.length === 0 || status !== 'ready') return
342
+ const last = messages.at(-1)
343
+ // Wait while an approval card is open: the user answers that first.
344
+ if (last?.parts.some((part) => isToolUIPart(part) && part.state === 'approval-requested'))
345
+ return
346
+ const notice = {
347
+ role: 'user' as const,
348
+ parts: readyJobs.map(
349
+ (id) => ({ type: JOB_PART, data: { id } }) as UIMessage['parts'][number],
350
+ ),
351
+ }
352
+ // Follow-ups since the user last wrote; past the cap, the notice waits in the history for the user's next message.
353
+ let followUps = 0
354
+ for (const message of [...messages].reverse()) {
355
+ if (message.role !== 'user') continue
356
+ if (!isJobNotice(message)) break
357
+ followUps++
358
+ }
359
+ if (followUps >= maxJobFollowUps) {
360
+ setMessages([...messages, { id: `job-${readyJobs.join('-')}`, ...notice }])
361
+ } else {
362
+ sendMessage(notice)
363
+ }
364
+ }, [readyJobs, status, messages, maxJobFollowUps, sendMessage, setMessages])
365
+
259
366
  // biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever messages change
260
367
  useEffect(() => {
261
368
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
@@ -322,7 +429,13 @@ export function AgentChat({
322
429
  </div>
323
430
  )}
324
431
  {messages.map((message) => (
325
- <Message key={message.id} message={message} labels={labels} onRespond={respond} />
432
+ <Message
433
+ key={message.id}
434
+ message={message}
435
+ labels={labels}
436
+ onRespond={respond}
437
+ waitingJobs={waitingJobs}
438
+ />
326
439
  ))}
327
440
  {status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
328
441
  {error && (