@temporalio/agent-harness-svelte 0.1.0 → 0.2.0

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
@@ -28,13 +28,305 @@ harness agent session.
28
28
  - **The whole tree.** `messages`, `states` and `agentStatus` are the root agent's; `agents` and
29
29
  `agent(id)` reach every subagent, and a `subagent` part's `messageId` names the child message
30
30
  it ran. `pendingApprovals` and `pendingCallbacks` collect waiting tool calls from all of them.
31
+ - **Key message rows on `message.key`** (`{#each messages as m (m.key)}`), not `m.id`. A send's
32
+ `id` changes from its local id to the server's when it is admitted; its `key` does not, so the
33
+ row keeps its DOM, focus and measured height. (From the client release after 0.1.0.)
34
+
35
+ ## Rendering replies as Markdown
36
+
37
+ A reply is model output, so treat its Markdown as untrusted. With `@tanstack/markdown`, which
38
+ escapes raw HTML by default, use `urlTransform` for two more rules:
39
+ - keep links only to `http:`, `https:` and `mailto:`;
40
+ - render images as their alt text. The renderer loads remote images by default, so a reply could
41
+ plant a tracking pixel or an image URL that carries data out.
42
+
43
+ ```svelte
44
+ <script lang="ts">
45
+ import type { UrlTransform } from "@tanstack/markdown";
46
+ import { renderHtml } from "@tanstack/markdown/html";
47
+ import { parseMarkdown } from "@tanstack/markdown/parser";
48
+
49
+ let { text }: { text: string } = $props();
50
+
51
+ const urlTransform: UrlTransform = (url, kind, defaultUrl) =>
52
+ kind === "link" && defaultUrl && /^(https?:|mailto:)/i.test(defaultUrl) ? defaultUrl : null;
53
+ // Once per change of `text`, which the session batches per flush.
54
+ const html = $derived(renderHtml(parseMarkdown(text, { urlTransform })));
55
+ </script>
56
+
57
+ <div class="reply">{@html html}</div>
58
+ ```
59
+
60
+ `null` from `urlTransform` drops the link or image but keeps its text. Relative links are
61
+ dropped too, since a reply has no base to be relative to.
62
+
63
+ To show some images, allow only sources that cannot phone home, as the console does: a raster
64
+ data URL (`/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$/i`, never SVG, which
65
+ can carry script), capped in size, or a content-addressed path your own API serves. Return `url`
66
+ for `kind === "image"` when it passes, and keep `null` for everything else.
67
+
68
+ ## Rendering replies per handler
69
+
70
+ With generated types, `message.handler` narrows `message.output` to that handler's reply, so a
71
+ map from handler to component can be typed end to end. Handlers without a card fall back to
72
+ JSON:
73
+
74
+ ```svelte
75
+ <!-- Reply.svelte -->
76
+ <script lang="ts">
77
+ import type { Component } from "svelte";
78
+ import {
79
+ parsePartialJson,
80
+ type AgentSchema,
81
+ type HandlerName,
82
+ type HandlerOutput,
83
+ type HarnessMessage
84
+ } from "@temporalio/agent-harness-client";
85
+ import type { TicTacToeAgent } from "./client_sdk/TicTacToeAgent"; // from harness-codegen
86
+ import MoveCard from "./MoveCard.svelte"; // props: { output: Partial<TextReply> }
87
+
88
+ type Cards<A extends AgentSchema> = {
89
+ [H in HandlerName<A>]?: Component<{ output: Partial<HandlerOutput<A, H>> }>;
90
+ };
91
+ const cards: Cards<TicTacToeAgent> = { play: MoveCard };
92
+
93
+ let { message }: { message: HarnessMessage<TicTacToeAgent> } = $props();
94
+
95
+ // TypeScript can't tie the lookup to the narrowed message, so it is cast once here.
96
+ const Card = $derived(cards[message.handler] as Component<{ output: unknown }> | undefined);
97
+ // The reply text streamed so far: a data handler streams its output as JSON.
98
+ const text = $derived(
99
+ message.output == null && Card ? message.parts.map((part) => (part.type === "reply_delta" ? part.text : "")).join("") : ""
100
+ );
101
+ // Once per flush; strip a ```json fence a model may open with.
102
+ const draft = $derived(text ? parsePartialJson(text.replace(/^\s*```(?:json)?\s*/, "")) : undefined);
103
+ const output = $derived(message.output ?? draft);
104
+ </script>
105
+
106
+ {#if output != null}
107
+ {#if Card}<Card {output} />{:else}<pre>{JSON.stringify(output, null, 2)}</pre>{/if}
108
+ {/if}
109
+ ```
110
+
111
+ While the reply streams, `parsePartialJson` reads the JSON so far as a document: it closes open
112
+ strings and brackets and drops a half-written key or value. A card therefore gets a `Partial`
113
+ of its output and should draw a missing field as a placeholder. The parser is best effort, and
114
+ quadratic on text that never parses, so run it once per flush and only for handlers with a
115
+ card. (`parsePartialJson` is in the client release after 0.1.0.)
116
+
117
+ ## Forms from a handler's schema
118
+
119
+ `GET /api/agent-interface/{sessionId}` lists each handler with `parameters`, the JSON Schema
120
+ of its input. The client turns that schema into fields (`describeSchema`), checks values
121
+ against it (`validate`, keyed by field path such as `address.city`) and builds the payload
122
+ (`buildPayload`). The server validates again and is the authority. (These helpers are in the
123
+ client release after 0.1.0.)
124
+
125
+ With native inputs, the schema's constraints become attributes the browser checks too:
126
+
127
+ ```svelte
128
+ <!-- SchemaInput.svelte -->
129
+ <script lang="ts">
130
+ import type { SchemaField } from "@temporalio/agent-harness-client";
131
+
132
+ let { field, value, onchange }: { field: SchemaField; value: unknown; onchange: (value: unknown) => void } = $props();
133
+ const required = $derived(field.required && field.kind !== "boolean");
134
+ </script>
135
+
136
+ {#if field.kind === "boolean"}
137
+ <input id={field.name} type="checkbox" checked={value === true} onchange={(e) => onchange(e.currentTarget.checked)} />
138
+ {:else if field.kind === "enum"}
139
+ <select id={field.name} {required} value={String(value)} onchange={(e) => onchange(e.currentTarget.value)}>
140
+ {#each field.choices ?? [] as choice (choice)}<option>{choice}</option>{/each}
141
+ </select>
142
+ {:else if field.kind === "integer" || field.kind === "number"}
143
+ <input id={field.name} {required} type="number" step={field.kind === "integer" ? 1 : "any"} min={field.minimum}
144
+ max={field.maximum} value={String(value)} oninput={(e) => onchange(e.currentTarget.value)} />
145
+ {:else}
146
+ <!-- Text. `pattern` is anchored by the browser but not by JSON Schema, hence the `.*`s. Give
147
+ "json", "array" and "object" fields a textarea or recurse into `field.fields`/`field.item`. -->
148
+ <input id={field.name} {required} minlength={field.minLength} maxlength={field.maxLength}
149
+ pattern={field.pattern && `.*(?:${field.pattern}).*`} value={String(value)} oninput={(e) => onchange(e.currentTarget.value)} />
150
+ {/if}
151
+ ```
152
+
153
+ ```svelte
154
+ <!-- HandlerForm.svelte -->
155
+ <script lang="ts">
156
+ import { buildPayload, describeSchema, emptyValues, validate } from "@temporalio/agent-harness-client";
157
+ import { AgentSession } from "@temporalio/agent-harness-svelte";
158
+ import SchemaInput from "./SchemaInput.svelte";
159
+
160
+ /** `handler` is one entry of `GET /api/agent-interface/{sessionId}`. */
161
+ let { sessionId, handler }: { sessionId: string; handler: { name: string; parameters: Record<string, unknown> } } = $props();
162
+ const agent = new AgentSession({ get sessionId() { return sessionId; } });
163
+ const fields = $derived(describeSchema(handler.parameters));
164
+ // Reassigned on each edit (Svelte 5.25 or later), and blank again when `handler` changes.
165
+ let values = $derived(emptyValues(fields));
166
+ let problems = $state<Record<string, string>>({});
167
+
168
+ function submit(event: SubmitEvent) {
169
+ event.preventDefault();
170
+ problems = validate(fields, values);
171
+ if (Object.keys(problems).length === 0) void agent.sendMessage(handler.name, buildPayload(fields, values));
172
+ }
173
+ </script>
174
+
175
+ <form onsubmit={submit}>
176
+ {#each fields as field (field.name)}
177
+ <p>
178
+ <label for={field.name}>{field.title}</label>
179
+ <SchemaInput {field} value={values[field.name]} onchange={(value) => (values = { ...values, [field.name]: value })} />
180
+ {#if problems[field.name]}<span role="alert">{problems[field.name]}</span>{/if}
181
+ </p>
182
+ {/each}
183
+ <button>Send</button>
184
+ </form>
185
+ ```
186
+
187
+ With [TanStack Form](https://tanstack.com/form), wrap `validate` as a
188
+ [Standard Schema](https://standardschema.dev), which TanStack Form accepts as a validator and
189
+ whose issue paths it maps onto fields. The same `SchemaInput` renders each field:
190
+
191
+ ```svelte
192
+ <!-- HandlerTanStackForm.svelte: wrap it in {#key handler.name} so another handler starts blank. -->
193
+ <script lang="ts">
194
+ import { createForm, type StandardSchemaV1 } from "@tanstack/svelte-form";
195
+ import { buildPayload, describeSchema, emptyValues, validate, type SchemaField } from "@temporalio/agent-harness-client";
196
+ import { AgentSession } from "@temporalio/agent-harness-svelte";
197
+ import SchemaInput from "./SchemaInput.svelte";
198
+
199
+ /** The client's `validate` as a Standard Schema: each problem is an issue at its field's path. */
200
+ const standardSchema = (fields: SchemaField[]): StandardSchemaV1<Record<string, unknown>> => ({
201
+ "~standard": {
202
+ version: 1,
203
+ vendor: "agent-harness",
204
+ validate: (input) => {
205
+ const value = input as Record<string, unknown>;
206
+ const problems = Object.entries(validate(fields, value));
207
+ return problems.length === 0
208
+ ? { value }
209
+ : { issues: problems.map(([path, message]) => ({ message, path: path.split(".") })) };
210
+ }
211
+ }
212
+ });
213
+
214
+ let { sessionId, handler }: { sessionId: string; handler: { name: string; parameters: Record<string, unknown> } } = $props();
215
+ const agent = new AgentSession({ get sessionId() { return sessionId; } });
216
+ const fields = $derived(describeSchema(handler.parameters));
217
+ const form = createForm(() => ({
218
+ defaultValues: emptyValues(fields),
219
+ validators: { onSubmit: standardSchema(fields) },
220
+ onSubmit: ({ value }) => agent.sendMessage(handler.name, buildPayload(fields, value))
221
+ }));
222
+ </script>
223
+
224
+ <form onsubmit={(event) => { event.preventDefault(); void form.handleSubmit(); }}>
225
+ {#each fields as schemaField (schemaField.name)}
226
+ <form.Field name={schemaField.name}>
227
+ {#snippet children(field)}
228
+ <p>
229
+ <label for={schemaField.name}>{schemaField.title}</label>
230
+ <SchemaInput field={schemaField} value={field.state.value} onchange={field.handleChange} />
231
+ {#each field.state.meta.errors as issue (issue?.message)}<span role="alert">{issue?.message}</span>{/each}
232
+ </p>
233
+ {/snippet}
234
+ </form.Field>
235
+ {/each}
236
+ <button>Send</button>
237
+ </form>
238
+ ```
239
+
240
+ ## A virtual transcript
241
+
242
+ For long sessions, render only the visible messages with `@tanstack/svelte-virtual`:
243
+ - `anchorTo: "end"` keeps the view on the latest message as replies grow.
244
+ - `followOnAppend` follows new messages only while the reader is already at the end.
245
+ - `getItemKey` uses `message.key`, so a send keeps its measured height when the server admits it.
246
+
247
+ ```svelte
248
+ <script lang="ts">
249
+ import { createVirtualizer } from "@tanstack/svelte-virtual";
250
+ import { get } from "svelte/store";
251
+
252
+ let { sessionId }: { sessionId: string } = $props();
253
+ const agent = new AgentSession({ get sessionId() { return sessionId; } });
254
+ const messages = $derived([...agent.messages, ...agent.pending]);
255
+
256
+ let scroller = $state<HTMLDivElement | null>(null);
257
+ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
258
+ count: 0,
259
+ getScrollElement: () => scroller,
260
+ estimateSize: () => 72,
261
+ anchorTo: "end",
262
+ followOnAppend: true,
263
+ overscan: 6
264
+ });
265
+ $effect(() => {
266
+ const list = messages;
267
+ // `get`, not `$virtualizer`: setOptions updates the store, which would re-run this effect.
268
+ get(virtualizer).setOptions({ count: list.length, getItemKey: (index) => list[index]!.key });
269
+ });
270
+ const measure = (row: HTMLDivElement) => get(virtualizer).measureElement(row);
271
+ </script>
272
+
273
+ <div bind:this={scroller} tabindex="-1" style="height: 600px; overflow: auto">
274
+ <div style="position: relative; height: {$virtualizer.getTotalSize()}px">
275
+ {#each $virtualizer.getVirtualItems() as item (item.key)}
276
+ <div {@attach measure} data-index={item.index} style="position: absolute; width: 100%; transform: translateY({item.start}px)">
277
+ <Message message={messages[item.index]} />
278
+ </div>
279
+ {/each}
280
+ </div>
281
+ </div>
282
+ ```
283
+
284
+ A row scrolled out of the window unmounts, taking focus with it. When that happens, move focus to
285
+ the row now at the same index, or to the list itself (hence its `tabindex="-1"`).
286
+
287
+ ## With TanStack Query
288
+
289
+ The session list and session creation are plain request/response calls, so they suit TanStack
290
+ Query. Sends, approvals, callbacks and status stay in the session: they go through
291
+ `AgentSession` and arrive on its stream, not through queries or mutations.
292
+
293
+ ```svelte
294
+ <script lang="ts">
295
+ import { createMutation, createQuery, queryOptions, useQueryClient } from "@tanstack/svelte-query";
296
+ import { HttpTransport, startSession } from "@temporalio/agent-harness-svelte";
297
+ import { TicTacToeAgent } from "./client_sdk/TicTacToeAgent"; // from harness-codegen
298
+
299
+ const transport = new HttpTransport({ baseUrl: "/api/" });
300
+ const sessionsQuery = () =>
301
+ queryOptions({
302
+ queryKey: ["agent-harness", "sessions"],
303
+ queryFn: ({ signal }) => transport.listSessions(signal)
304
+ });
305
+
306
+ const queryClient = useQueryClient();
307
+ const sessions = createQuery(sessionsQuery);
308
+ const start = createMutation(() => ({
309
+ mutationFn: () => startSession(transport, TicTacToeAgent),
310
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: sessionsQuery().queryKey })
311
+ }));
312
+ </script>
313
+
314
+ <button onclick={() => start.mutate()} disabled={start.isPending}>New session</button>
315
+ {#each sessions.data ?? [] as s (s.workflow_id)}
316
+ <a href="?session={s.workflow_id}">{s.label}</a>
317
+ {/each}
318
+ ```
31
319
 
32
320
  ## Development
33
321
 
34
- This package links `../client` and resolves it through that package's build:
322
+ This package is part of the pnpm workspace in `typescript/`, and builds and tests against the
323
+ `@temporalio/agent-harness-client` in the working tree; its published dependency is the
324
+ `^<version>` range in `package.json`. `bazel test //typescript/packages/svelte/...` builds and
325
+ tests it. Without Bazel, build the client first, then run these commands from
326
+ `typescript/packages/svelte` (see
327
+ [Testing local changes](../README.md#testing-local-changes-without-publishing)):
35
328
 
36
329
  ```sh
37
- (cd ../client && npm ci && npm run build)
38
- npm ci
39
- npm run check && npm test && npm run build
330
+ pnpm install
331
+ pnpm run check && pnpm test && pnpm run build
40
332
  ```
@@ -1,11 +1,14 @@
1
1
  import { type AgentSchema, type AgentSseFrame, type AgentUpdate, type AgentView, type ConnectionStatus, type HarnessMessage, type SessionState, type UntypedAgent } from "@temporalio/agent-harness-client";
2
2
  export declare class SvelteSessionState<A extends AgentSchema = UntypedAgent> implements SessionState<A> {
3
+ #private;
3
4
  connection: ConnectionStatus;
4
5
  error: Error | undefined;
5
6
  rootAgentId: string | null;
6
7
  agents: Readonly<Record<string, AgentView<UntypedAgent>>>;
7
8
  pending: readonly HarnessMessage<A>[];
8
- frames: readonly AgentSseFrame[];
9
+ /** Every frame received. Copied when read after new frames arrive, not on every flush, so a
10
+ * long session costs nothing extra per flush unless something reads its frames. */
11
+ get frames(): readonly AgentSseFrame[];
9
12
  updateAgents(updates: ReadonlyArray<readonly [string, AgentUpdate]>): void;
10
13
  appendFrames(frames: readonly AgentSseFrame[]): void;
11
14
  }
@@ -8,7 +8,16 @@ export class SvelteSessionState {
8
8
  rootAgentId = $state(null);
9
9
  agents = $state.raw({});
10
10
  pending = $state.raw([]);
11
- frames = $state.raw([]);
11
+ #frameCount = $state(0);
12
+ #frames = [];
13
+ #framesCopy = [];
14
+ /** Every frame received. Copied when read after new frames arrive, not on every flush, so a
15
+ * long session costs nothing extra per flush unless something reads its frames. */
16
+ get frames() {
17
+ if (this.#framesCopy.length !== this.#frameCount)
18
+ this.#framesCopy = this.#frames.slice();
19
+ return this.#framesCopy;
20
+ }
12
21
  updateAgents(updates) {
13
22
  const next = { ...this.agents };
14
23
  for (const [id, update] of updates)
@@ -16,6 +25,8 @@ export class SvelteSessionState {
16
25
  this.agents = next;
17
26
  }
18
27
  appendFrames(frames) {
19
- this.frames = this.frames.concat(frames);
28
+ for (const frame of frames)
29
+ this.#frames.push(frame);
30
+ this.#frameCount = this.#frames.length;
20
31
  }
21
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@temporalio/agent-harness-svelte",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Svelte 5 binding for @temporalio/agent-harness-client: a reactive AgentSession over a temporal-agent-harness agent's event stream.",
@@ -18,26 +18,31 @@
18
18
  "files": [
19
19
  "dist"
20
20
  ],
21
- "scripts": {
22
- "build": "svelte-package",
23
- "check": "svelte-check --tsconfig ./tsconfig.json",
24
- "test": "vitest run"
25
- },
26
21
  "peerDependencies": {
27
22
  "svelte": "^5.7.0"
28
23
  },
29
24
  "dependencies": {
30
- "@temporalio/agent-harness-client": "^0.1.0"
25
+ "@temporalio/agent-harness-client": "^0.2.0"
31
26
  },
32
27
  "devDependencies": {
33
- "@sveltejs/package": "^2.5.8",
34
- "@sveltejs/vite-plugin-svelte": "^7.3.0",
28
+ "@sveltejs/package": "^3.0.1",
29
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
35
30
  "@types/node": "^22.20.4",
36
- "happy-dom": "^20.14.5",
37
- "svelte": "^5.57.1",
38
- "svelte-check": "^4.7.6",
31
+ "happy-dom": "^21.0.4",
32
+ "svelte": "^5.57.2",
33
+ "svelte-check": "^4.7.7",
39
34
  "typescript": "^6.0.3",
40
- "vite": "^8.3.0",
35
+ "vite": "^7.3.7",
41
36
  "vitest": "^5.0.1"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/temporal-community/temporal-agent-harness.git",
41
+ "directory": "typescript/packages/svelte"
42
+ },
43
+ "scripts": {
44
+ "build": "svelte-package",
45
+ "check": "svelte-check --tsconfig ./tsconfig.json",
46
+ "test": "vitest run"
42
47
  }
43
- }
48
+ }