@datalayer/agent-runtimes 1.3.37 → 1.3.39

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.
@@ -6,12 +6,15 @@
6
6
  * A chat, a widget or a worker runs as its conversation. Its page is its
7
7
  * A2UI surface (`interface.surface`) drawn beside that conversation, fed from
8
8
  * it: the question last asked, the answer as it streams, where the turn
9
- * stands. What a block writes — an input's `value` — stays in the surface's
9
+ * stands, a chat's whole conversation. What a block writes — an input's `value` — stays in the surface's
10
10
  * data model at a path the application takes, and what a button does reaches
11
11
  * the application through the chat's own channels: a message sent, the
12
12
  * answer stopped, the conversation started over. Nothing here is a server
13
- * API; a setting reaches the application in the words of the message it is
14
- * sent with, until the session API carries a settings change (R-04).
13
+ * API: what a button did besides its message — the block's action, the files
14
+ * given, the settings — goes with the chat's own run to the application's
15
+ * session (LOOP R-04, `forwardedProps.loop`), which puts a file where the
16
+ * application reads it; a setting also reaches a written application in the
17
+ * words of its message.
15
18
  *
16
19
  * A decision is not here: its page is drawn by the Studio's run page, with
17
20
  * its own paths (`INTERFACE_PATHS`), until it moves onto the workspace (R-02).
@@ -21,7 +24,7 @@
21
24
  * @module loop/plugins/app-page/appPageModel
22
25
  */
23
26
  import type { AppSettingSpec, AppSpec } from '../../../types/agentspecs';
24
- import type { ChatTurnSnapshot } from '../../core';
27
+ import type { ChatTurnSnapshot, ConversationEntry } from '../../core';
25
28
  /** The id of the editor surface an application's page is drawn in. */
26
29
  export declare const APP_PAGE_SURFACE = "app-page";
27
30
  /** The id of the A2UI surface itself, inside it. */
@@ -94,8 +97,11 @@ export declare function hasAppPage(app: Pick<AppSpec, 'kind' | 'interface' | 'ag
94
97
  export declare function surfaceUnshown(app: Pick<AppSpec, 'kind' | 'interface' | 'agent'>): string | null;
95
98
  /** A setting's first value, as its input holds it. */
96
99
  export declare function initialInput(setting: AppSettingSpec): unknown;
97
- /** What the conversation publishes to the page, by path, now. */
98
- export declare function appPageData(app: Pick<AppSpec, 'kind' | 'name' | 'goal'>, turn: ChatTurnSnapshot): Record<string, unknown>;
100
+ /**
101
+ * What the conversation publishes to the page, by path, now: from its
102
+ * current turn, and for a chat its whole conversation.
103
+ */
104
+ export declare function appPageData(app: Pick<AppSpec, 'kind' | 'name' | 'goal'>, turn: ChatTurnSnapshot, conversation: ConversationEntry[]): Record<string, unknown>;
99
105
  /** The page's data model when it is drawn: what is published, the inputs, the draft. */
100
106
  export declare function appPageInitialData(app: Pick<AppSpec, 'kind' | 'name' | 'goal' | 'interface'>): Record<string, unknown>;
101
107
  /**
@@ -103,6 +109,51 @@ export declare function appPageInitialData(app: Pick<AppSpec, 'kind' | 'name' |
103
109
  * its label, then any other value a block wrote under `/inputs` by its id.
104
110
  */
105
111
  export declare function inputsInWords(app: Pick<AppSpec, 'interface'>, inputs: unknown): string;
112
+ /**
113
+ * The largest file a session takes: what the session API refuses above
114
+ * (`agent_runtimes.loop.apps.sessions.MAX_FILE_BYTES`), said here before
115
+ * the file is sent.
116
+ */
117
+ export declare const MAX_FILE_BYTES: number;
118
+ /** A file as File upload gives it, and as the session API takes it. */
119
+ export type GivenFile = {
120
+ name: string;
121
+ type: string;
122
+ size: number;
123
+ data_url: string;
124
+ };
125
+ /**
126
+ * The files given on the page, as the session API takes them — or why they
127
+ * cannot go: what was given is not files as File upload gives them, or one
128
+ * is larger than a session takes. What becomes of each — put in the
129
+ * application's sandbox, or its text in the message — is the session's to
130
+ * decide (LOOP R-04).
131
+ */
132
+ export declare function givenFiles(files: unknown): {
133
+ files: GivenFile[];
134
+ } | {
135
+ refused: string;
136
+ };
137
+ /**
138
+ * The application's settings as the page holds them, as the session API
139
+ * takes them: a choice as the option chosen, a number as a number, a toggle
140
+ * as on or off — each setting the page has a value for.
141
+ */
142
+ export declare function settingsOf(app: Pick<AppSpec, 'interface'>, inputs: unknown): Record<string, unknown>;
143
+ /**
144
+ * What the page did besides its message, for the session API: the action of
145
+ * the block pressed, the files given with it, the settings it holds. Sent
146
+ * with the chat's own run (AG-UI's `forwardedProps.loop`), so that what it
147
+ * answers streams in the conversation (LOOP R-04).
148
+ */
149
+ export type AppPageLoop = {
150
+ action: {
151
+ name: string;
152
+ payload: Record<string, unknown>;
153
+ };
154
+ files?: GivenFile[];
155
+ settings?: Record<string, unknown>;
156
+ };
106
157
  /** What a button asked, as the surface hands it over. */
107
158
  export type AppPageEvent = {
108
159
  name: string;
@@ -112,6 +163,8 @@ export type AppPageEvent = {
112
163
  export type AppPageOutcome = {
113
164
  /** A message for the application, sent as the person's next turn. */
114
165
  send: string;
166
+ /** What the page did besides, for the session it is sent in. */
167
+ loop: AppPageLoop;
115
168
  /** The inputs in words as sent, to tell a change next time. */
116
169
  inputs: string;
117
170
  /** Paths to empty once it is sent. */
@@ -28,6 +28,18 @@ const SEND = {
28
28
  context: ['message'],
29
29
  asks: { message: 'The message it sends' },
30
30
  };
31
+ const MESSAGES = {
32
+ path: '/messages',
33
+ meaning: 'The conversation so far, in order: each message {role: user or assistant, text}, each tool called {role: tool, name, args, result}.',
34
+ words: 'The conversation',
35
+ list: true,
36
+ };
37
+ const FILES = {
38
+ path: '/files',
39
+ meaning: 'The files given on the page, each {name, type, size, data_url}: they go to the application’s session with the next run, put in its sandbox when it has a shell, a text file in the message when it has none.',
40
+ words: 'The files given',
41
+ list: true,
42
+ };
31
43
  const STOP = {
32
44
  name: 'stop',
33
45
  meaning: 'Stop the answer being written.',
@@ -54,6 +66,7 @@ export const APP_KIND_PATHS = {
54
66
  words: 'Its answer',
55
67
  },
56
68
  STATUS,
69
+ MESSAGES,
57
70
  ],
58
71
  accepts: [DRAFT],
59
72
  actions: [
@@ -79,15 +92,22 @@ export const APP_KIND_PATHS = {
79
92
  },
80
93
  STATUS,
81
94
  ],
82
- accepts: [],
95
+ accepts: [FILES],
83
96
  actions: [
84
97
  {
85
98
  name: 'run',
86
- meaning: 'Run it on its inputs: every value written under /inputs is sent, with the context’s message when there is one.',
99
+ meaning: 'Run it on its inputs: every value written under /inputs is sent, and each file at /files, with the context’s message when there is one.',
87
100
  words: 'Run it on its inputs',
88
101
  context: ['message'],
89
102
  asks: { message: 'What it says before the inputs' },
90
103
  },
104
+ {
105
+ name: 'upload',
106
+ meaning: 'Run it on the files just given — the context’s files, else those at /files — with its inputs.',
107
+ words: 'Run it on the files given',
108
+ context: ['files'],
109
+ asks: { files: 'The files it runs on' },
110
+ },
91
111
  STOP,
92
112
  ],
93
113
  inputs: true,
@@ -197,8 +217,11 @@ export function initialInput(setting) {
197
217
  return String(setting.default ?? '');
198
218
  }
199
219
  }
200
- /** What the conversation publishes to the page, by path, now. */
201
- export function appPageData(app, turn) {
220
+ /**
221
+ * What the conversation publishes to the page, by path, now: from its
222
+ * current turn, and for a chat its whole conversation.
223
+ */
224
+ export function appPageData(app, turn, conversation) {
202
225
  const data = {
203
226
  '/app': app.name,
204
227
  '/status': STATUS_WORDS[turn.status] ?? '',
@@ -207,6 +230,7 @@ export function appPageData(app, turn) {
207
230
  case 'chat':
208
231
  data['/question'] = turn.user ?? '';
209
232
  data['/answer'] = turn.assistant ?? '';
233
+ data['/messages'] = conversation;
210
234
  break;
211
235
  case 'widget':
212
236
  data['/output'] = turn.assistant ?? '';
@@ -224,7 +248,7 @@ export function appPageData(app, turn) {
224
248
  /** The page's data model when it is drawn: what is published, the inputs, the draft. */
225
249
  export function appPageInitialData(app) {
226
250
  const tree = {};
227
- for (const [path, value] of Object.entries(appPageData(app, { id: 0, status: 'idle' }))) {
251
+ for (const [path, value] of Object.entries(appPageData(app, { id: 0, status: 'idle' }, []))) {
228
252
  tree[path.slice(1)] = value;
229
253
  }
230
254
  const paths = appKindPaths(app);
@@ -237,6 +261,9 @@ export function appPageInitialData(app) {
237
261
  if (paths.accepts.some(entry => entry.path === DRAFT.path)) {
238
262
  tree.draft = '';
239
263
  }
264
+ if (paths.accepts.some(entry => entry.path === FILES.path)) {
265
+ tree.files = [];
266
+ }
240
267
  return tree;
241
268
  }
242
269
  /** One input in words: `Plan: Team`; nothing for an empty one. */
@@ -273,6 +300,87 @@ export function inputsInWords(app, inputs) {
273
300
  }
274
301
  return lines.filter(Boolean).join('\n');
275
302
  }
303
+ /**
304
+ * The largest file a session takes: what the session API refuses above
305
+ * (`agent_runtimes.loop.apps.sessions.MAX_FILE_BYTES`), said here before
306
+ * the file is sent.
307
+ */
308
+ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
309
+ /**
310
+ * The files given on the page, as the session API takes them — or why they
311
+ * cannot go: what was given is not files as File upload gives them, or one
312
+ * is larger than a session takes. What becomes of each — put in the
313
+ * application's sandbox, or its text in the message — is the session's to
314
+ * decide (LOOP R-04).
315
+ */
316
+ export function givenFiles(files) {
317
+ if (files === undefined || files === null) {
318
+ return { files: [] };
319
+ }
320
+ if (!Array.isArray(files)) {
321
+ return { refused: 'What was given as files is not a list of files.' };
322
+ }
323
+ const given = [];
324
+ for (const [index, file] of files.entries()) {
325
+ const item = (file ?? {});
326
+ if (typeof item.name !== 'string' || typeof item.data_url !== 'string') {
327
+ return {
328
+ refused: `File ${index + 1} is not a file as File upload gives it ({name, type, size, data_url}).`,
329
+ };
330
+ }
331
+ const size = typeof item.size === 'number' ? item.size : 0;
332
+ if (size > MAX_FILE_BYTES) {
333
+ return {
334
+ refused: `${item.name} is ${size.toLocaleString('en')} bytes: a session takes files of at most ${MAX_FILE_BYTES.toLocaleString('en')}.`,
335
+ };
336
+ }
337
+ given.push({
338
+ name: item.name,
339
+ type: typeof item.type === 'string' ? item.type : '',
340
+ size,
341
+ data_url: item.data_url,
342
+ });
343
+ }
344
+ return { files: given };
345
+ }
346
+ /**
347
+ * The application's settings as the page holds them, as the session API
348
+ * takes them: a choice as the option chosen, a number as a number, a toggle
349
+ * as on or off — each setting the page has a value for.
350
+ */
351
+ export function settingsOf(app, inputs) {
352
+ const values = inputs && typeof inputs === 'object'
353
+ ? inputs
354
+ : {};
355
+ const settings = {};
356
+ for (const setting of app.interface.settings) {
357
+ const value = values[setting.id];
358
+ if (value === undefined || value === null) {
359
+ continue;
360
+ }
361
+ switch (setting.type) {
362
+ case 'select': {
363
+ const chosen = Array.isArray(value) ? value[0] : value;
364
+ if (chosen !== undefined && chosen !== '') {
365
+ settings[setting.id] = String(chosen);
366
+ }
367
+ break;
368
+ }
369
+ case 'toggle':
370
+ settings[setting.id] = Boolean(value);
371
+ break;
372
+ case 'slider':
373
+ case 'number':
374
+ if (value !== '' && !Number.isNaN(Number(value))) {
375
+ settings[setting.id] = Number(value);
376
+ }
377
+ break;
378
+ default:
379
+ settings[setting.id] = String(value);
380
+ }
381
+ }
382
+ return settings;
383
+ }
276
384
  /**
277
385
  * What a button pressed on the page does.
278
386
  *
@@ -293,15 +401,35 @@ export function appPageAction(app, event, read, lastInputs = '') {
293
401
  const given = event.context?.message;
294
402
  const message = typeof given === 'string' ? given.trim() : '';
295
403
  const inputs = paths.inputs ? inputsInWords(app, read('/inputs')) : '';
404
+ const settings = paths.inputs ? settingsOf(app, read('/inputs')) : undefined;
405
+ const payload = Object.fromEntries(Object.entries(event.context ?? {}).filter(([key]) => key !== 'message' && key !== 'files'));
406
+ const loop = (files) => ({
407
+ action: { name: event.name, payload },
408
+ ...(files && files.length > 0 ? { files } : {}),
409
+ ...(settings ? { settings } : {}),
410
+ });
296
411
  switch (event.name) {
297
412
  case 'stop':
298
413
  return { stop: true };
299
414
  case 'new':
300
415
  return { newChat: true };
301
- case 'run': {
302
- const text = [message, inputs].filter(Boolean).join('\n');
416
+ case 'run':
417
+ case 'upload': {
418
+ const context = event.context?.files;
419
+ const files = givenFiles(event.name === 'upload' && context !== undefined
420
+ ? context
421
+ : read(FILES.path));
422
+ if ('refused' in files) {
423
+ return files;
424
+ }
425
+ if (event.name === 'upload' && files.files.length === 0) {
426
+ return { refused: 'No file was given: choose one first.' };
427
+ }
428
+ const names = files.files.map(file => file.name).join(', ');
429
+ const text = [message, inputs].filter(Boolean).join('\n') ||
430
+ (names ? `Run on ${names}.` : '');
303
431
  return text
304
- ? { send: text, inputs, clear: [] }
432
+ ? { send: text, inputs, clear: [], loop: loop(files.files) }
305
433
  : { refused: `${app.name} has no inputs to run on: set one first.` };
306
434
  }
307
435
  default: {
@@ -315,6 +443,7 @@ export function appPageAction(app, event, read, lastInputs = '') {
315
443
  send: changed ? `${written}\n\n${inputs}` : written,
316
444
  inputs,
317
445
  clear: message ? [] : [DRAFT.path],
446
+ loop: loop(),
318
447
  };
319
448
  }
320
449
  }
@@ -1,8 +1,44 @@
1
+ /**
2
+ * `@datalayer/loop-plugin-app-page` — an application's page, as a plugin
3
+ * (LOOP R-01, R-01b).
4
+ *
5
+ * A chat, a widget or a worker runs as its conversation; its page — its
6
+ * A2UI surface, or the default page of its kind when none is composed — is
7
+ * contributed as the editor beside the chat (`LoopEditorView`), the place a
8
+ * notebook or a document takes in other workspaces. `AppRenderer` mounts it
9
+ * for an application that has one (`hasAppPage`) and opens the chat on it.
10
+ *
11
+ * What the page shows and takes, by kind, is `APP_KIND_PATHS`.
12
+ *
13
+ * A page the workspace does not draw itself — a decision's, whose page is
14
+ * its host's (the Studio's run page, its hosted address, its embed) — is
15
+ * contributed by the host through `defineAppHostPagePlugin`, as the
16
+ * workspace's one view (`LoopViewType`), in a workspace without a
17
+ * conversation (LOOP R-02).
18
+ *
19
+ * @module loop/plugins/app-page
20
+ */
21
+ import type { ComponentType } from 'react';
1
22
  import { type ReactorPlugin } from '@datalayer/reactor';
2
23
  import type { AppSpec } from '../../../types/agentspecs';
24
+ import { type LoopWorkspaceContext } from '../../core';
3
25
  export declare const APP_PAGE_PLUGIN_NAME = "@datalayer/loop-plugin-app-page";
4
26
  /** The plugin that draws an application's page beside its conversation. */
5
27
  export declare function defineAppPagePlugin(app: AppSpec): ReactorPlugin<Record<string, never>, unknown, unknown>;
28
+ /** The view an application's page is, when its host draws it. */
29
+ export declare const APP_HOST_PAGE_VIEW = "app-page";
30
+ /** What a host's page is given: the application, and the workspace it is the view of. */
31
+ export type AppHostPageProps = {
32
+ app: AppSpec;
33
+ workspace: LoopWorkspaceContext;
34
+ };
35
+ /**
36
+ * The plugin that makes a host's page the workspace's view (LOOP R-02): the
37
+ * page a host draws for an application whose kind the workspace has no page
38
+ * for — a decision's. The workspace opens on it; it is ordered before every
39
+ * other view.
40
+ */
41
+ export declare function defineAppHostPagePlugin(app: AppSpec, Page: ComponentType<AppHostPageProps>): ReactorPlugin<Record<string, never>, unknown, unknown>;
6
42
  export { AppPage, type AppPageProps } from './AppPage';
7
43
  export * from './appPageModel';
8
44
  export default defineAppPagePlugin;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { BrowserIcon } from '@primer/octicons-react';
3
3
  import { contribution, definePlugin, } from '@datalayer/reactor';
4
- import { LoopEditorView } from '../../core';
4
+ import { LoopEditorView, LoopViewType, } from '../../core';
5
5
  import { AppPage } from './AppPage';
6
6
  import { APP_PAGE_SURFACE } from './appPageModel';
7
7
  export const APP_PAGE_PLUGIN_NAME = '@datalayer/loop-plugin-app-page';
@@ -29,6 +29,37 @@ export function defineAppPagePlugin(app) {
29
29
  });
30
30
  return plugin;
31
31
  }
32
+ /** The view an application's page is, when its host draws it. */
33
+ export const APP_HOST_PAGE_VIEW = 'app-page';
34
+ /**
35
+ * The plugin that makes a host's page the workspace's view (LOOP R-02): the
36
+ * page a host draws for an application whose kind the workspace has no page
37
+ * for — a decision's. The workspace opens on it; it is ordered before every
38
+ * other view.
39
+ */
40
+ export function defineAppHostPagePlugin(app, Page) {
41
+ // One component per plugin: the view host keeps it, and the application with it.
42
+ function View({ workspace, }) {
43
+ return _jsx(Page, { app: app, workspace: workspace });
44
+ }
45
+ const plugin = definePlugin({
46
+ name: `${APP_PAGE_PLUGIN_NAME}-${app.id}`,
47
+ displayName: `${app.name}: its page`,
48
+ description: `The page of ${app.name}, drawn by its host.`,
49
+ octicon: 'browser',
50
+ emoji: app.emoji || undefined,
51
+ contributes: [
52
+ contribution(LoopViewType, {
53
+ viewType: APP_HOST_PAGE_VIEW,
54
+ title: app.name,
55
+ icon: BrowserIcon,
56
+ order: -10,
57
+ load: async () => ({ default: View }),
58
+ }, { id: APP_HOST_PAGE_VIEW, order: -10 }),
59
+ ],
60
+ });
61
+ return plugin;
62
+ }
32
63
  export { AppPage } from './AppPage';
33
64
  export * from './appPageModel';
34
65
  export default defineAppPagePlugin;
@@ -39,7 +39,7 @@ import { useConfig } from '../../../hooks/useConfig';
39
39
  import { useSkills, useSkillActions } from '../../../hooks/useSkills';
40
40
  import { AI_MODEL_CATALOGUE, isChatModel } from '../../../specs/models';
41
41
  import { LoopAgentBlueprint, LoopAgentGate, LoopChatComposer, LoopChatExtras, LoopChatHeader, LoopChatLayout, LoopChatSuggestion, LoopChatSurface, LoopChatTurn, LoopFrontendTool, LoopSlots, canOpenView, onPromptFocusRequest, onSurfaceRequest, useLoopPromptStore, } from '../../core';
42
- import { turnWritersOf } from './turnState';
42
+ import { messageText, turnWritersOf } from './turnState';
43
43
  import { isInactiveSurfaceContribution, orderToolContributions, toolsForChatView, } from './chatViewTools';
44
44
  import { useAgentCommandTools } from '../../../tools/adapters/commands';
45
45
  import { NONE_EDITOR, seedEditorChoice, setEditorOptions, } from '../shell/editorChoice';
@@ -225,6 +225,8 @@ export default function ChatView({ workspace }) {
225
225
  if (!feed) {
226
226
  return;
227
227
  }
228
+ // The whole conversation, for a page that shows it (a Chat block).
229
+ feed.items(items);
228
230
  const SETTLED = new Set([
229
231
  'complete',
230
232
  'completed',
@@ -315,24 +317,42 @@ export default function ChatView({ workspace }) {
315
317
  break;
316
318
  }
317
319
  }
318
- const textOf = (message) => {
319
- const content = message.content;
320
- return typeof content === 'string'
321
- ? content
322
- : content
323
- .map(part => typeof part === 'string'
324
- ? part
325
- : (part.text ?? ''))
326
- .join('');
327
- };
328
320
  const reply = messages
329
321
  .slice(lastUser + 1)
330
322
  .filter(message => message.role === 'assistant')
331
- .map(textOf)
323
+ .map(messageText)
332
324
  .filter(text => text.trim().length > 0)
333
325
  .join('\n\n');
334
326
  feed.assistant(reply);
335
327
  }, []);
328
+ const heldForAdapter = useRef([]);
329
+ /** Whether the agent is turned in this page: read by `sendNow`, set below. */
330
+ const inPageRef = useRef(false);
331
+ /*
332
+ * Every message that goes to the agent goes through here: the composer's
333
+ * (through the workspace's dispatch and the prompt channel), a host's, and
334
+ * one sent from the application's page (`viewControls.send`) — so each
335
+ * begins its turn, and a page reading the turn sees it asked and answered.
336
+ *
337
+ * What the page did besides the message goes with it as AG-UI's
338
+ * `forwardedProps` (`loop`, LOOP R-04): the application's session takes a
339
+ * file there. An agent turned in this page has no session, and no file is
340
+ * handed to it: said, rather than dropped.
341
+ */
342
+ const sendNow = useCallback((message, forwardedProps) => {
343
+ const loop = forwardedProps?.loop;
344
+ if (inPageRef.current && loop?.files && loop.files.length > 0) {
345
+ return 'A file is given to an application running on a runtime: run it on Datalayer or on your machine to give it one.';
346
+ }
347
+ // A new turn: whatever the panel showed is gone, this message is it.
348
+ turnFeedRef.current?.begin(message, controlsRef.current?.thread());
349
+ const send = controlsRef.current?.send;
350
+ if (!send) {
351
+ heldForAdapter.current.push([message, forwardedProps]);
352
+ return;
353
+ }
354
+ send(message, forwardedProps);
355
+ }, []);
336
356
  // One writer, so neither fact can erase the other.
337
357
  useEffect(() => {
338
358
  setViewControls(sendReady
@@ -340,10 +360,10 @@ export default function ChatView({ workspace }) {
340
360
  busy,
341
361
  stop: controlsRef.current?.stop,
342
362
  newChat: controlsRef.current?.newChat,
343
- send: controlsRef.current?.send,
363
+ send: sendNow,
344
364
  }
345
365
  : null);
346
- }, [busy, sendReady, setViewControls]);
366
+ }, [busy, sendReady, setViewControls, sendNow]);
347
367
  // Stop reporting when the view goes away, or the shell would keep a stop
348
368
  // button for something that is no longer there.
349
369
  useEffect(() => () => setViewControls(null), [setViewControls]);
@@ -358,23 +378,15 @@ export default function ChatView({ workspace }) {
358
378
  * again. Held instead, and sent the moment the adapter is ready, which is
359
379
  * the next second or two rather than minutes.
360
380
  */
361
- const heldForAdapter = useRef([]);
362
- useEffect(() => workspace.prompts.subscribe(message => {
363
- const send = controlsRef.current?.send;
364
- if (!send) {
365
- heldForAdapter.current.push(message);
366
- return;
367
- }
368
- send(message);
369
- }), [workspace.prompts]);
381
+ useEffect(() => workspace.prompts.subscribe(sendNow), [workspace.prompts, sendNow]);
370
382
  // The adapter arrived: whatever was said while it was starting goes now.
371
383
  useEffect(() => {
372
384
  if (!sendReady || heldForAdapter.current.length === 0) {
373
385
  return;
374
386
  }
375
387
  const held = heldForAdapter.current.splice(0, heldForAdapter.current.length);
376
- for (const message of held) {
377
- controlsRef.current?.send(message);
388
+ for (const [message, forwardedProps] of held) {
389
+ controlsRef.current?.send(message, forwardedProps);
378
390
  }
379
391
  }, [sendReady]);
380
392
  /*
@@ -397,8 +409,8 @@ export default function ChatView({ workspace }) {
397
409
  inputPromptConfig?.firstPromptHook?.();
398
410
  }
399
411
  setTransient(null);
400
- // A new turn: whatever the panel showed is gone, this message is it.
401
- turnFeedRef.current?.begin(message, controlsRef.current?.thread());
412
+ // The turn begins where the message goes out (`sendNow`): a command
413
+ // the workspace runs is not one.
402
414
  const outcome = await workspace.submit(message);
403
415
  if (!outcome.handled) {
404
416
  /*
@@ -628,6 +640,7 @@ export default function ChatView({ workspace }) {
628
640
  * for — so the location wins, which is the same rule the examples follow.
629
641
  */
630
642
  const inPage = targetRunsAgentInPage(workspace.sandbox.target ?? 'local');
643
+ inPageRef.current = inPage;
631
644
  /*
632
645
  * Who is being addressed, when this workspace runs a team.
633
646
  *
@@ -1115,7 +1128,13 @@ export default function ChatView({ workspace }) {
1115
1128
  })
1116
1129
  : {
1117
1130
  type: 'ag-ui',
1118
- endpoint: `${agentServerUrl}/api/v1/ag-ui/${agentId}/`,
1131
+ // An application's agent is spoken to through its session API
1132
+ // (LOOP R-04): each thread a session, recorded under its uid,
1133
+ // run in the name the application runs in, and taking what the
1134
+ // page did besides the message — the same AG-UI events back.
1135
+ endpoint: blueprintTurn?.createPayload?.app_spec
1136
+ ? `${agentServerUrl}/api/v1/apps/agents/${encodeURIComponent(agentId)}/ag-ui/`
1137
+ : `${agentServerUrl}/api/v1/ag-ui/${agentId}/`,
1119
1138
  agentId,
1120
1139
  // `/api/v1/configure`, not `/api/v1/configure/config`: the hooks
1121
1140
  // strip one trailing `config`/`configure` segment to find the API
@@ -82,7 +82,12 @@ export const ChatPlugin = definePlugin({
82
82
  */
83
83
  build: ctx => {
84
84
  const feed = createTurnFeed();
85
- ctx.contribute(LoopChatTurn, { id: 'chat-turn', turn: feed.turn, ...feedWriters(feed) }, { id: 'chat-turn' });
85
+ ctx.contribute(LoopChatTurn, {
86
+ id: 'chat-turn',
87
+ turn: feed.turn,
88
+ conversation: feed.conversation,
89
+ ...feedWriters(feed),
90
+ }, { id: 'chat-turn' });
86
91
  return {};
87
92
  },
88
93
  contributes: [
@@ -7,11 +7,17 @@
7
7
  * previous turn), `assistant` as the reply arrives, `end` when the agent
8
8
  * stops. Readers hold the signal.
9
9
  *
10
+ * Beside the turn, the whole conversation (`conversation`), for a page that
11
+ * shows it as the Chat component does: the chat view hands it every change
12
+ * of its items, streaming included.
13
+ *
10
14
  * @module loop/plugins/chat/turnState
11
15
  */
12
16
  import { type ReadonlySignal } from '@datalayer/reactor';
13
17
  import type { ContextSnapshotData } from '../../../types';
14
- import type { ChatTurnSnapshot, ChatTurnStatus } from '../../core';
18
+ import type { DisplayItem } from '../../../types/chat';
19
+ import type { ChatMessage } from '../../../types/messages';
20
+ import type { ChatTurnSnapshot, ChatTurnStatus, ConversationEntry } from '../../core';
15
21
  export type TurnFeed = {
16
22
  /** The turn, for readers. */
17
23
  turn: ReadonlySignal<ChatTurnSnapshot>;
@@ -28,7 +34,20 @@ export type TurnFeed = {
28
34
  usage: (snapshot: ContextSnapshotData | undefined) => void;
29
35
  /** What the agent is doing now, or nothing. */
30
36
  activity: (label: string | undefined) => void;
37
+ /** The conversation, for readers. */
38
+ conversation: ReadonlySignal<ConversationEntry[]>;
39
+ /** The chat's items as they now stand, streaming included. */
40
+ items: (items: DisplayItem[]) => void;
31
41
  };
42
+ /** A message's words, whether its content is a string or parts. */
43
+ export declare function messageText(message: ChatMessage): string;
44
+ /**
45
+ * The conversation as a page shows it: each person's and assistant's
46
+ * message with words in it, and each tool call by its name, its arguments
47
+ * and its result once it has one. A system message is the chat's own and is
48
+ * left out.
49
+ */
50
+ export declare function conversationOf(items: DisplayItem[]): ConversationEntry[];
32
51
  export declare function createTurnFeed(): TurnFeed;
33
52
  /**
34
53
  * The writers, as extra fields on the contribution.