@yeaft/webchat-agent 0.1.478 → 0.1.479

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.478",
3
+ "version": "0.1.479",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -24,6 +24,7 @@ import { recall } from './memory/recall.js';
24
24
  import { shouldConsolidate, consolidate } from './memory/consolidate.js';
25
25
  import { buildMemoryInjection } from './memory/layout.js';
26
26
  import { runStopHooks } from './stop-hooks.js';
27
+ import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
27
28
 
28
29
  /** Maximum number of turns before the engine stops to prevent infinite loops. */
29
30
  const MAX_TURNS = 25;
@@ -269,11 +270,22 @@ export class Engine {
269
270
  if (!this.#conversationStore) return;
270
271
  if (this.#config._readOnly) return;
271
272
 
273
+ // task-299 Phase 1: tag persisted messages with the current thread.
274
+ // getThreadStore() lazily seeds a default 'main' thread if not yet init'd.
275
+ let threadId = MAIN_THREAD_ID;
276
+ try {
277
+ threadId = getThreadStore().currentId || MAIN_THREAD_ID;
278
+ } catch {
279
+ // Defensive: any store failure falls back to 'main' so persistence
280
+ // never breaks because of thread bookkeeping.
281
+ }
282
+
272
283
  // Persist user message
273
284
  this.#conversationStore.append({
274
285
  role: 'user',
275
286
  content: userContent,
276
287
  mode,
288
+ threadId,
277
289
  });
278
290
 
279
291
  // Persist assistant message
@@ -282,6 +294,7 @@ export class Engine {
282
294
  content: assistantContent,
283
295
  mode,
284
296
  model: this.#config.model,
297
+ threadId,
285
298
  };
286
299
  if (toolCalls && toolCalls.length > 0) {
287
300
  assistantMsg.toolCalls = toolCalls;
@@ -601,21 +614,21 @@ export class Engine {
601
614
  if (!hasTool) {
602
615
  output = `Error: unknown tool "${tc.name}"`;
603
616
  isError = true;
604
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
617
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
605
618
  } else {
606
619
  try {
607
- yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input };
620
+ yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
608
621
  if (this.#toolRegistry) {
609
622
  output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
610
623
  } else {
611
624
  const tool = this.#tools.get(tc.name);
612
625
  output = await tool.execute(tc.input, { signal });
613
626
  }
614
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false };
627
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
615
628
  } catch (err) {
616
629
  output = `Error: ${err.message}`;
617
630
  isError = true;
618
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
631
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
619
632
  }
620
633
  }
621
634
 
@@ -687,6 +700,19 @@ export class Engine {
687
700
  /** @returns {import('./mcp.js').MCPManager|null} */
688
701
  get mcpManager() { return this.#mcpManager; }
689
702
 
703
+ /**
704
+ * task-299 Phase 1: the engine's current thread marker.
705
+ * Defaults to 'main' if the thread store is unreachable for any reason.
706
+ * @returns {string}
707
+ */
708
+ get currentThreadId() {
709
+ try {
710
+ return getThreadStore().currentId || MAIN_THREAD_ID;
711
+ } catch {
712
+ return MAIN_THREAD_ID;
713
+ }
714
+ }
715
+
690
716
  /** @returns {string|null} */
691
717
  get yeaftDir() { return this.#yeaftDir; }
692
718
 
package/unify/session.js CHANGED
@@ -23,6 +23,7 @@ import { SkillManager, createSkillManager } from './skills.js';
23
23
  import { MCPManager } from './mcp.js';
24
24
  import { createFullRegistry } from './tools/index.js';
25
25
  import { initTaskStore } from './tools/task-tools.js';
26
+ import { initThreadStore } from './threads/store.js';
26
27
  import { Engine } from './engine.js';
27
28
  import { join } from 'path';
28
29
 
@@ -121,6 +122,11 @@ export async function loadSession(options = {}) {
121
122
  // ─── 5a. Initialize task store ─────────────────────────
122
123
  initTaskStore(yeaftDir, { readOnly: config._readOnly || false });
123
124
 
125
+ // ─── 5b. Initialize thread store (task-299 Phase 1) ────
126
+ // In-memory only for Phase 1; replaced by a file-backed store
127
+ // when task-298's data layer merges.
128
+ initThreadStore();
129
+
124
130
  // ─── 6. Load skills ────────────────────────────────────
125
131
  let skillManager;
126
132
  if (skipSkills) {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * store.js — In-memory ThreadStore for Yeaft Unify (Phase 1 mock).
3
+ *
4
+ * Phase 1 intentionally keeps the implementation in-memory only: it lets the
5
+ * thread/task spawn tools ship and be tested before task-298's real
6
+ * filesystem layer is merged. When task-298 merges, this module will be
7
+ * replaced (or promoted to a shim) by a file-backed store with the same API.
8
+ *
9
+ * Responsibilities (Phase 1):
10
+ * - Maintain a map of threadId → thread metadata.
11
+ * - Track a "currentThreadId" marker for the engine.
12
+ * - Maintain attachments from threadId → taskId.
13
+ *
14
+ * A single "main" thread is created on construction so that pre-spawn
15
+ * messages have a valid threadId to carry.
16
+ */
17
+
18
+ import { randomUUID } from 'crypto';
19
+
20
+ /** Default / root thread id — every fresh ThreadStore has one. */
21
+ export const MAIN_THREAD_ID = 'main';
22
+
23
+ /**
24
+ * @typedef {Object} Thread
25
+ * @property {string} id
26
+ * @property {string} name
27
+ * @property {string} [goal]
28
+ * @property {string|null} parentThreadId
29
+ * @property {number} createdAt
30
+ * @property {number} updatedAt
31
+ */
32
+
33
+ export class ThreadStore {
34
+ /** @type {Map<string, Thread>} */
35
+ #threads;
36
+
37
+ /** @type {string} */
38
+ #currentId;
39
+
40
+ /** @type {Map<string, string>} threadId → taskId */
41
+ #attachments;
42
+
43
+ constructor() {
44
+ this.#threads = new Map();
45
+ this.#attachments = new Map();
46
+
47
+ const now = Date.now();
48
+ const main = {
49
+ id: MAIN_THREAD_ID,
50
+ name: 'main',
51
+ goal: '',
52
+ parentThreadId: null,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ };
56
+ this.#threads.set(main.id, main);
57
+ this.#currentId = main.id;
58
+ }
59
+
60
+ /** Get current thread id (defaults to 'main'). */
61
+ get currentId() {
62
+ return this.#currentId;
63
+ }
64
+
65
+ /** Total thread count (including 'main'). */
66
+ get size() {
67
+ return this.#threads.size;
68
+ }
69
+
70
+ /**
71
+ * Create a new thread.
72
+ * @param {{ name: string, goal?: string, parentThreadId?: string }} spec
73
+ * @returns {Thread}
74
+ */
75
+ create({ name, goal = '', parentThreadId = null } = {}) {
76
+ if (!name || typeof name !== 'string' || !name.trim()) {
77
+ throw new Error('thread name is required');
78
+ }
79
+ if (parentThreadId && !this.#threads.has(parentThreadId)) {
80
+ throw new Error(`parent thread not found: ${parentThreadId}`);
81
+ }
82
+ const id = `thr-${randomUUID().slice(0, 8)}`;
83
+ const now = Date.now();
84
+ const thread = {
85
+ id,
86
+ name: name.trim(),
87
+ goal: goal || '',
88
+ parentThreadId: parentThreadId || null,
89
+ createdAt: now,
90
+ updatedAt: now,
91
+ };
92
+ this.#threads.set(id, thread);
93
+ return thread;
94
+ }
95
+
96
+ /** @param {string} id */
97
+ get(id) {
98
+ return this.#threads.get(id) || null;
99
+ }
100
+
101
+ /** @returns {Thread[]} */
102
+ list() {
103
+ return [...this.#threads.values()];
104
+ }
105
+
106
+ /** @param {string} id */
107
+ has(id) {
108
+ return this.#threads.has(id);
109
+ }
110
+
111
+ /**
112
+ * Set the current thread marker. Throws if unknown.
113
+ * @param {string} id
114
+ */
115
+ switch(id) {
116
+ if (!this.#threads.has(id)) {
117
+ throw new Error(`thread not found: ${id}`);
118
+ }
119
+ this.#currentId = id;
120
+ const t = this.#threads.get(id);
121
+ t.updatedAt = Date.now();
122
+ }
123
+
124
+ /**
125
+ * Attach a task to a thread. Overwrites any existing attachment.
126
+ * @param {string} threadId
127
+ * @param {string} taskId
128
+ */
129
+ attachTask(threadId, taskId) {
130
+ if (!this.#threads.has(threadId)) {
131
+ throw new Error(`thread not found: ${threadId}`);
132
+ }
133
+ if (!taskId || typeof taskId !== 'string') {
134
+ throw new Error('taskId is required');
135
+ }
136
+ this.#attachments.set(threadId, taskId);
137
+ }
138
+
139
+ /**
140
+ * Get the taskId attached to a thread, if any.
141
+ * @param {string} threadId
142
+ * @returns {string|null}
143
+ */
144
+ attachedTask(threadId) {
145
+ return this.#attachments.get(threadId) || null;
146
+ }
147
+
148
+ /** @returns {Array<{ threadId: string, taskId: string }>} */
149
+ listAttachments() {
150
+ return [...this.#attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId }));
151
+ }
152
+ }
153
+
154
+ /** @type {ThreadStore|null} */
155
+ let threadStore = null;
156
+
157
+ /**
158
+ * Initialize the thread store. Safe to call multiple times — subsequent calls
159
+ * replace the store only if `force` is true (primarily for tests).
160
+ * @param {{ force?: boolean }} [opts]
161
+ * @returns {ThreadStore}
162
+ */
163
+ export function initThreadStore(opts = {}) {
164
+ if (!threadStore || opts.force) {
165
+ threadStore = new ThreadStore();
166
+ }
167
+ return threadStore;
168
+ }
169
+
170
+ /** @returns {ThreadStore} */
171
+ export function getThreadStore() {
172
+ if (!threadStore) {
173
+ threadStore = new ThreadStore();
174
+ }
175
+ return threadStore;
176
+ }
177
+
178
+ /** Test-only reset helper. */
179
+ export function _resetThreadStoreForTests() {
180
+ threadStore = null;
181
+ }
@@ -55,6 +55,15 @@ import {
55
55
  updatePlan,
56
56
  } from './task-tools.js';
57
57
 
58
+ // --- P1 Thread tools (task-299 Phase 1) ---
59
+ import {
60
+ spawnThread,
61
+ switchThread,
62
+ listThreads,
63
+ attachThreadToTask,
64
+ } from './thread-tools.js';
65
+ import { spawnTask, spawnSubtask } from './spawn-task-tools.js';
66
+
58
67
  // --- P2 Auxiliary tools ---
59
68
  import { jsRepl, jsReplReset } from './js-repl.js';
60
69
  import notebookEdit from './notebook-edit.js';
@@ -113,6 +122,14 @@ export const allTools = [
113
122
  followupTask,
114
123
  updatePlan,
115
124
 
125
+ // P1 Thread (task-299 Phase 1)
126
+ spawnThread,
127
+ switchThread,
128
+ listThreads,
129
+ attachThreadToTask,
130
+ spawnTask,
131
+ spawnSubtask,
132
+
116
133
  // P2 Auxiliary
117
134
  jsRepl,
118
135
  jsReplReset,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * spawn-task-tools.js — Thin semantic-sugar wrappers over TaskCreate.
3
+ *
4
+ * Phase 1 of task-299: expose `SpawnTask` and `SpawnSubtask` as first-class
5
+ * tool names so the LLM can "spawn" work items in the same vocabulary it
6
+ * uses to spawn threads. Both delegate to the existing TaskStore (shared
7
+ * with TaskCreate), so there is a single source of truth for task data.
8
+ */
9
+
10
+ import { defineTool } from './types.js';
11
+ import { randomUUID } from 'crypto';
12
+ import { getTaskStore } from './task-tools.js';
13
+
14
+ function requireStore() {
15
+ const store = getTaskStore();
16
+ if (!store) return { error: 'Task store not initialized. Session may still be loading.' };
17
+ return { store };
18
+ }
19
+
20
+ function newTaskId() {
21
+ return `task-${randomUUID().slice(0, 8)}`;
22
+ }
23
+
24
+ // ─── SpawnTask ──────────────────────────────────────────
25
+
26
+ export const spawnTask = defineTool({
27
+ name: 'SpawnTask',
28
+ description: `Spawn a new top-level task.
29
+
30
+ Equivalent to TaskCreate without a parent. Provided as a first-class
31
+ name so tasks and threads can be "spawned" with a uniform vocabulary.`,
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ title: { type: 'string' },
36
+ description: { type: 'string' },
37
+ priority: {
38
+ type: 'string',
39
+ enum: ['low', 'medium', 'high', 'critical'],
40
+ },
41
+ },
42
+ required: ['title'],
43
+ },
44
+ modes: ['work'],
45
+ isConcurrencySafe: () => false,
46
+ isReadOnly: () => false,
47
+ async execute(input) {
48
+ const got = requireStore();
49
+ if (got.error) return JSON.stringify({ error: got.error });
50
+ const { title, description = '', priority = 'medium' } = input || {};
51
+ if (!title) return JSON.stringify({ error: 'title is required' });
52
+
53
+ const id = newTaskId();
54
+ const now = Date.now();
55
+ got.store.create({
56
+ id,
57
+ title,
58
+ description,
59
+ priority,
60
+ status: 'pending',
61
+ parentId: null,
62
+ createdAt: now,
63
+ updatedAt: now,
64
+ });
65
+ return JSON.stringify({
66
+ success: true,
67
+ task: { id, title, priority, status: 'pending' },
68
+ message: `Task spawned: ${title} (${id})`,
69
+ });
70
+ },
71
+ });
72
+
73
+ // ─── SpawnSubtask ───────────────────────────────────────
74
+
75
+ export const spawnSubtask = defineTool({
76
+ name: 'SpawnSubtask',
77
+ description: `Spawn a subtask under an existing parent task.
78
+
79
+ Requires parent_task_id. The parent must exist. Use for breaking a
80
+ larger task into executable pieces.`,
81
+ parameters: {
82
+ type: 'object',
83
+ properties: {
84
+ parent_task_id: { type: 'string' },
85
+ title: { type: 'string' },
86
+ description: { type: 'string' },
87
+ priority: {
88
+ type: 'string',
89
+ enum: ['low', 'medium', 'high', 'critical'],
90
+ },
91
+ },
92
+ required: ['parent_task_id', 'title'],
93
+ },
94
+ modes: ['work'],
95
+ isConcurrencySafe: () => false,
96
+ isReadOnly: () => false,
97
+ async execute(input) {
98
+ const got = requireStore();
99
+ if (got.error) return JSON.stringify({ error: got.error });
100
+ const { parent_task_id, title, description = '', priority = 'medium' } = input || {};
101
+ if (!parent_task_id) return JSON.stringify({ error: 'parent_task_id is required' });
102
+ if (!title) return JSON.stringify({ error: 'title is required' });
103
+
104
+ const parent = got.store.get(parent_task_id);
105
+ if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
106
+
107
+ const id = newTaskId();
108
+ const now = Date.now();
109
+ got.store.create({
110
+ id,
111
+ title,
112
+ description: description || `Subtask of: ${parent.title}`,
113
+ priority,
114
+ status: 'pending',
115
+ parentId: parent_task_id,
116
+ createdAt: now,
117
+ updatedAt: now,
118
+ });
119
+ return JSON.stringify({
120
+ success: true,
121
+ task: { id, title, priority, status: 'pending', parentId: parent_task_id },
122
+ message: `Subtask spawned: ${title} (${id}) under ${parent_task_id}`,
123
+ });
124
+ },
125
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * thread-tools.js — Thread-spawning tools for Unify Engine (Phase 1).
3
+ *
4
+ * Phase 1 scope (task-299):
5
+ * - SpawnThread — create a new thread
6
+ * - SwitchThread — set the engine's currentThreadId marker
7
+ * - ListThreads — list threads + current marker
8
+ * - AttachThreadToTask — bind a thread to an existing task
9
+ *
10
+ * Phase 1 uses the in-memory ThreadStore (agent/unify/threads/store.js)
11
+ * so these tools can ship and be tested before task-298's file-backed
12
+ * data layer merges. The tool surface is designed to remain stable when
13
+ * the store is replaced.
14
+ */
15
+
16
+ import { defineTool } from './types.js';
17
+ import { getThreadStore } from '../threads/store.js';
18
+ import { getTaskStore } from './task-tools.js';
19
+
20
+ // ─── SpawnThread ─────────────────────────────────────────
21
+
22
+ export const spawnThread = defineTool({
23
+ name: 'SpawnThread',
24
+ description: `Create a new thread (conversation track) for parallel work.
25
+
26
+ A thread is a named conversation track that groups related messages and
27
+ tool calls under a single goal. Use when the work needs a fresh focus
28
+ track separate from the current conversation.
29
+
30
+ Returns the new threadId (format: "thr-xxxxxxxx"). Does NOT switch the
31
+ engine to the new thread — call SwitchThread to activate it.`,
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ name: { type: 'string', description: 'Short human-readable name' },
36
+ goal: { type: 'string', description: 'Optional one-sentence goal' },
37
+ parent_thread_id: {
38
+ type: 'string',
39
+ description: 'Optional parent threadId for hierarchy',
40
+ },
41
+ },
42
+ required: ['name'],
43
+ },
44
+ modes: ['work'],
45
+ isConcurrencySafe: () => false,
46
+ isReadOnly: () => false,
47
+ async execute(input) {
48
+ const { name, goal, parent_thread_id } = input || {};
49
+ if (!name) return JSON.stringify({ error: 'name is required' });
50
+ try {
51
+ const store = getThreadStore();
52
+ const t = store.create({ name, goal, parentThreadId: parent_thread_id || null });
53
+ return JSON.stringify({
54
+ success: true,
55
+ thread: {
56
+ id: t.id,
57
+ name: t.name,
58
+ goal: t.goal,
59
+ parentThreadId: t.parentThreadId,
60
+ },
61
+ message: `Thread created: ${t.name} (${t.id})`,
62
+ });
63
+ } catch (err) {
64
+ return JSON.stringify({ error: err.message || String(err) });
65
+ }
66
+ },
67
+ });
68
+
69
+ // ─── SwitchThread ────────────────────────────────────────
70
+
71
+ export const switchThread = defineTool({
72
+ name: 'SwitchThread',
73
+ description: `Switch the engine's active thread marker.
74
+
75
+ All messages and tool calls persisted after this call will carry the new
76
+ threadId (Phase 1: marker only — the Engine reads it when persisting and
77
+ the web-bridge forwards it to the UI). Use 'main' to return to the root
78
+ thread.`,
79
+ parameters: {
80
+ type: 'object',
81
+ properties: {
82
+ thread_id: { type: 'string', description: 'Thread id to switch to' },
83
+ },
84
+ required: ['thread_id'],
85
+ },
86
+ modes: ['work'],
87
+ isConcurrencySafe: () => false,
88
+ isReadOnly: () => false,
89
+ async execute(input) {
90
+ const { thread_id } = input || {};
91
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
92
+ try {
93
+ const store = getThreadStore();
94
+ store.switch(thread_id);
95
+ return JSON.stringify({
96
+ success: true,
97
+ currentThreadId: store.currentId,
98
+ message: `Switched to thread ${thread_id}`,
99
+ });
100
+ } catch (err) {
101
+ return JSON.stringify({ error: err.message || String(err) });
102
+ }
103
+ },
104
+ });
105
+
106
+ // ─── ListThreads ─────────────────────────────────────────
107
+
108
+ export const listThreads = defineTool({
109
+ name: 'ListThreads',
110
+ description: `List all threads and the engine's current thread marker.`,
111
+ parameters: { type: 'object', properties: {} },
112
+ modes: ['work'],
113
+ isConcurrencySafe: () => true,
114
+ isReadOnly: () => true,
115
+ async execute() {
116
+ const store = getThreadStore();
117
+ const threads = store.list().map(t => ({
118
+ id: t.id,
119
+ name: t.name,
120
+ goal: t.goal,
121
+ parentThreadId: t.parentThreadId,
122
+ attachedTaskId: store.attachedTask(t.id),
123
+ }));
124
+ return JSON.stringify(
125
+ {
126
+ currentThreadId: store.currentId,
127
+ threads,
128
+ totalCount: threads.length,
129
+ },
130
+ null,
131
+ 2,
132
+ );
133
+ },
134
+ });
135
+
136
+ // ─── AttachThreadToTask ──────────────────────────────────
137
+
138
+ export const attachThreadToTask = defineTool({
139
+ name: 'AttachThreadToTask',
140
+ description: `Link an existing thread to an existing task.
141
+
142
+ Use to record which thread is responsible for which task. The thread and
143
+ the task must both already exist. Overwrites any previous attachment for
144
+ the same thread.`,
145
+ parameters: {
146
+ type: 'object',
147
+ properties: {
148
+ thread_id: { type: 'string' },
149
+ task_id: { type: 'string' },
150
+ },
151
+ required: ['thread_id', 'task_id'],
152
+ },
153
+ modes: ['work'],
154
+ isConcurrencySafe: () => false,
155
+ isReadOnly: () => false,
156
+ async execute(input) {
157
+ const { thread_id, task_id } = input || {};
158
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
159
+ if (!task_id) return JSON.stringify({ error: 'task_id is required' });
160
+
161
+ const taskStore = getTaskStore();
162
+ // If the task store is initialized, validate the task exists; if not
163
+ // initialized (e.g. Phase 1 unit tests running before session bootstrap),
164
+ // skip the task existence check — we still enforce thread existence.
165
+ if (taskStore) {
166
+ const task = taskStore.get(task_id);
167
+ if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
168
+ }
169
+
170
+ try {
171
+ const store = getThreadStore();
172
+ store.attachTask(thread_id, task_id);
173
+ return JSON.stringify({
174
+ success: true,
175
+ threadId: thread_id,
176
+ taskId: task_id,
177
+ message: `Thread ${thread_id} attached to task ${task_id}`,
178
+ });
179
+ } catch (err) {
180
+ return JSON.stringify({ error: err.message || String(err) });
181
+ }
182
+ },
183
+ });
@@ -192,12 +192,15 @@ export async function handleUnifyChat(msg) {
192
192
  input: event.input,
193
193
  }],
194
194
  },
195
+ threadId: event.threadId,
195
196
  });
196
197
  break;
197
198
 
198
199
  // ── Tool execution started ──
199
200
  case 'tool_start':
200
- // Tool is running — the UI already shows it from tool_use block above
201
+ // Tool is running — the UI already shows it from tool_use block above.
202
+ // Forward threadId so the UI can group tool activity by thread (Phase 1).
203
+ sendUnifyEvent({ type: 'tool_start', id: event.id, name: event.name, threadId: event.threadId });
201
204
  break;
202
205
 
203
206
  // ── Tool execution completed ──
@@ -211,6 +214,7 @@ export async function handleUnifyChat(msg) {
211
214
  content: event.output || '',
212
215
  is_error: event.isError || false,
213
216
  }],
217
+ threadId: event.threadId,
214
218
  });
215
219
  break;
216
220