u-foo 3.0.24 → 3.0.26

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.
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { randomUUID, createHash } = require("crypto");
6
+ const { stripVisionBase64, degradeVisionContent } = require("../providers/visionBlocks");
7
+
8
+ const JOURNAL_VERSION = 3;
9
+
10
+ function getJournalDir(workspaceRoot = process.cwd()) {
11
+ return path.join(path.resolve(workspaceRoot || process.cwd()), ".ufoo", "agent", "ucode", "journal");
12
+ }
13
+
14
+ function getJournalPath(workspaceRoot = process.cwd(), sessionId = "") {
15
+ const id = String(sessionId || "").trim();
16
+ return id ? path.join(getJournalDir(workspaceRoot), `${id}.jsonl`) : "";
17
+ }
18
+
19
+ function getLegacyTranscriptPath(workspaceRoot = process.cwd(), sessionId = "") {
20
+ const id = String(sessionId || "").trim();
21
+ return id
22
+ ? path.join(path.resolve(workspaceRoot || process.cwd()), ".ufoo", "agent", "ucode", "transcripts", `${id}.jsonl`)
23
+ : "";
24
+ }
25
+
26
+ function deleteSessionJournal(workspaceRoot = process.cwd(), sessionId = "") {
27
+ const filePath = getJournalPath(workspaceRoot, sessionId);
28
+ if (!filePath || !fs.existsSync(filePath)) return { ok: true, error: "" };
29
+ try {
30
+ fs.unlinkSync(filePath);
31
+ return { ok: true, error: "" };
32
+ } catch (err) {
33
+ return {
34
+ ok: false,
35
+ error: err && err.message ? err.message : "failed to delete session journal",
36
+ };
37
+ }
38
+ }
39
+
40
+ function readJsonl(filePath = "") {
41
+ if (!filePath || !fs.existsSync(filePath)) return [];
42
+ try {
43
+ return fs.readFileSync(filePath, "utf8")
44
+ .split(/\r?\n/)
45
+ .map((line) => line.trim())
46
+ .filter(Boolean)
47
+ .flatMap((line) => {
48
+ try { return [JSON.parse(line)]; } catch { return []; }
49
+ });
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function stablePayloadFingerprint(value) {
56
+ try {
57
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
58
+ } catch {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ function contentForStorage(content) {
64
+ if (typeof content === "string") return content;
65
+ if (Array.isArray(content)) {
66
+ const hasVision = content.some((block) => {
67
+ if (!block || typeof block !== "object") return false;
68
+ const type = String(block.type || "").trim().toLowerCase();
69
+ return type === "image" || type === "image_url"
70
+ || (type === "tool_result" && Array.isArray(block.content));
71
+ });
72
+ return hasVision
73
+ ? degradeVisionContent(stripVisionBase64(content))
74
+ : stripVisionBase64(content);
75
+ }
76
+ if (content && typeof content === "object") return stripVisionBase64(content);
77
+ return content;
78
+ }
79
+
80
+ function parseArtifactMessage(message = {}) {
81
+ if (typeof message.content !== "string" || !message.content.trim()) return null;
82
+ try {
83
+ const parsed = JSON.parse(message.content);
84
+ const artifactId = String(parsed && parsed.artifactId || "").trim();
85
+ if (!artifactId) return null;
86
+ return { artifactId, preview: String(parsed.preview || "").trim() };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function messageEventType(message = {}) {
93
+ const role = String(message.role || "").trim().toLowerCase();
94
+ if (
95
+ role === "user"
96
+ && Array.isArray(message.content)
97
+ && message.content.some((block) => String(block && block.type || "").toLowerCase() === "tool_result")
98
+ ) {
99
+ return "tool.result";
100
+ }
101
+ if (role === "user") return "user.message";
102
+ if (role === "assistant" && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
103
+ return "assistant.tool_calls";
104
+ }
105
+ if (role === "assistant") return "assistant.message";
106
+ if (role === "tool") return "tool.result";
107
+ return "conversation.item";
108
+ }
109
+
110
+ function messageToJournalEvent(message = {}, options = {}) {
111
+ const role = String(message && message.role || "").trim().toLowerCase();
112
+ if (!role || role === "system") return null;
113
+ const turnId = String(options.turnId || "").trim();
114
+ const index = Number.isFinite(options.index) ? Math.max(0, Math.floor(options.index)) : 0;
115
+ const toolCallId = String(message.tool_call_id || "").trim();
116
+ const parsedArtifact = role === "tool" ? parseArtifactMessage(message) : null;
117
+ const artifactId = String(message.artifactId || (parsedArtifact && parsedArtifact.artifactId) || "").trim();
118
+ const artifactPreview = String(message.preview || (parsedArtifact && parsedArtifact.preview) || "").trim();
119
+ const payload = {
120
+ role,
121
+ content: artifactId ? undefined : contentForStorage(message.content),
122
+ toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : undefined,
123
+ toolCallId: toolCallId || undefined,
124
+ artifactId: artifactId || undefined,
125
+ preview: artifactId ? artifactPreview : undefined,
126
+ };
127
+ const itemId = String(options.itemId || toolCallId || `${turnId || "turn"}:${index}`).trim();
128
+ return {
129
+ version: JOURNAL_VERSION,
130
+ eventId: String(options.eventId || `evt_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`),
131
+ idempotencyKey: String(options.idempotencyKey || `${turnId || "legacy"}:${index}:${messageEventType(message)}`),
132
+ sessionId: String(options.sessionId || "").trim(),
133
+ turnId,
134
+ itemId,
135
+ type: messageEventType(message),
136
+ createdAt: String(options.createdAt || new Date().toISOString()),
137
+ payload,
138
+ };
139
+ }
140
+
141
+ function normalizeJournalEvent(event = {}, sessionId = "", seq = 0) {
142
+ const source = event && typeof event === "object" ? event : {};
143
+ return {
144
+ version: JOURNAL_VERSION,
145
+ seq: Number.isFinite(source.seq) ? Math.max(1, Math.floor(source.seq)) : Math.max(1, seq),
146
+ eventId: String(source.eventId || `evt_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`),
147
+ idempotencyKey: String(source.idempotencyKey || ""),
148
+ sessionId: String(source.sessionId || sessionId || "").trim(),
149
+ turnId: String(source.turnId || "").trim(),
150
+ itemId: String(source.itemId || "").trim(),
151
+ type: String(source.type || "conversation.item").trim(),
152
+ createdAt: String(source.createdAt || new Date().toISOString()),
153
+ payload: source.payload && typeof source.payload === "object" ? source.payload : {},
154
+ };
155
+ }
156
+
157
+ function loadJournal(workspaceRoot = process.cwd(), sessionId = "") {
158
+ const filePath = getJournalPath(workspaceRoot, sessionId);
159
+ const events = readJsonl(filePath)
160
+ .filter((event) => Number(event && event.version) === JOURNAL_VERSION)
161
+ .map((event, index) => normalizeJournalEvent(event, sessionId, index + 1))
162
+ .sort((left, right) => left.seq - right.seq);
163
+ return { filePath, events };
164
+ }
165
+
166
+ function appendJournalEvents(workspaceRoot = process.cwd(), sessionId = "", events = []) {
167
+ const filePath = getJournalPath(workspaceRoot, sessionId);
168
+ if (!filePath) return { ok: false, error: "invalid session id", events: [] };
169
+ const existing = loadJournal(workspaceRoot, sessionId).events;
170
+ const eventIds = new Set(existing.map((event) => event.eventId).filter(Boolean));
171
+ const keys = new Set(existing.map((event) => event.idempotencyKey).filter(Boolean));
172
+ let seq = existing.reduce((max, event) => Math.max(max, Number(event.seq) || 0), 0);
173
+ const appended = [];
174
+ for (const candidate of Array.isArray(events) ? events : []) {
175
+ const normalized = normalizeJournalEvent(candidate, sessionId, seq + 1);
176
+ if (eventIds.has(normalized.eventId)) continue;
177
+ if (normalized.idempotencyKey && keys.has(normalized.idempotencyKey)) continue;
178
+ seq += 1;
179
+ normalized.seq = seq;
180
+ eventIds.add(normalized.eventId);
181
+ if (normalized.idempotencyKey) keys.add(normalized.idempotencyKey);
182
+ appended.push(normalized);
183
+ }
184
+ if (appended.length === 0) return { ok: true, error: "", events: [], filePath };
185
+ try {
186
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
187
+ fs.appendFileSync(filePath, `${appended.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
188
+ return { ok: true, error: "", events: appended, filePath };
189
+ } catch (err) {
190
+ return {
191
+ ok: false,
192
+ error: err && err.message ? err.message : "failed to append session journal",
193
+ events: [],
194
+ filePath,
195
+ };
196
+ }
197
+ }
198
+
199
+ function sameLegacyAssistant(left = {}, right = {}) {
200
+ if (String(left.role || "") !== "assistant" || String(right.role || "") !== "assistant") return false;
201
+ return stablePayloadFingerprint({ content: left.content, toolCalls: left.toolCalls })
202
+ === stablePayloadFingerprint({ content: right.content, toolCalls: right.toolCalls });
203
+ }
204
+
205
+ function legacyDuplicateIndexes(events = []) {
206
+ const duplicates = new Set();
207
+ for (let index = 1; index < events.length - 1; index += 1) {
208
+ const prior = events[index - 1] || {};
209
+ const candidate = events[index] || {};
210
+ const next = events[index + 1] || {};
211
+ if (!sameLegacyAssistant(prior, candidate) || String(next.role || "") !== "user") continue;
212
+ const candidateAt = Date.parse(String(candidate.createdAt || ""));
213
+ const priorAt = Date.parse(String(prior.createdAt || ""));
214
+ const nextAt = Date.parse(String(next.createdAt || ""));
215
+ if (
216
+ !Number.isFinite(priorAt)
217
+ || !Number.isFinite(candidateAt)
218
+ || !Number.isFinite(nextAt)
219
+ || candidateAt - priorAt <= 2000
220
+ || Math.abs(nextAt - candidateAt) > 2000
221
+ ) {
222
+ continue;
223
+ }
224
+ duplicates.add(index);
225
+ }
226
+ return duplicates;
227
+ }
228
+
229
+ function readLegacyProjection(workspaceRoot = process.cwd(), sessionId = "") {
230
+ const filePath = getLegacyTranscriptPath(workspaceRoot, sessionId);
231
+ const events = readJsonl(filePath);
232
+ const duplicates = legacyDuplicateIndexes(events);
233
+ return {
234
+ filePath,
235
+ events: events.filter((_event, index) => !duplicates.has(index)),
236
+ suppressed: [...duplicates].map((index) => ({ duplicate: events[index], original: events[index - 1] })),
237
+ };
238
+ }
239
+
240
+ function journalEventToTranscriptEvent(event = {}) {
241
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
242
+ if (!payload.role || event.type === "history.corrected") return null;
243
+ return {
244
+ id: event.eventId,
245
+ role: payload.role,
246
+ content: payload.content,
247
+ toolCalls: payload.toolCalls,
248
+ toolCallId: payload.toolCallId,
249
+ artifactId: payload.artifactId,
250
+ preview: payload.preview,
251
+ segmentId: payload.segmentId,
252
+ turnId: event.turnId,
253
+ itemId: event.itemId,
254
+ createdAt: event.createdAt,
255
+ };
256
+ }
257
+
258
+ function loadTranscriptProjection(workspaceRoot = process.cwd(), sessionId = "") {
259
+ const journal = loadJournal(workspaceRoot, sessionId);
260
+ if (journal.events.length > 0) {
261
+ return {
262
+ filePath: journal.filePath,
263
+ events: journal.events.map(journalEventToTranscriptEvent).filter(Boolean),
264
+ source: "journal-v3",
265
+ suppressed: [],
266
+ };
267
+ }
268
+ const legacy = readLegacyProjection(workspaceRoot, sessionId);
269
+ return { ...legacy, source: "transcript-v2" };
270
+ }
271
+
272
+ function migrateLegacyJournal(workspaceRoot = process.cwd(), sessionId = "") {
273
+ const current = loadJournal(workspaceRoot, sessionId);
274
+ if (current.events.length > 0) return { ok: true, migrated: false, ...current };
275
+ const legacy = readLegacyProjection(workspaceRoot, sessionId);
276
+ if (legacy.events.length === 0 && legacy.suppressed.length === 0) {
277
+ return { ok: true, migrated: false, filePath: current.filePath, events: [] };
278
+ }
279
+ const imported = legacy.events.flatMap((message, index) => {
280
+ const event = messageToJournalEvent({
281
+ role: message.role,
282
+ content: message.content,
283
+ tool_calls: message.toolCalls,
284
+ tool_call_id: message.toolCallId,
285
+ artifactId: message.artifactId,
286
+ preview: message.preview,
287
+ }, {
288
+ sessionId,
289
+ turnId: String(message.turnId || message.segmentId || "legacy"),
290
+ index,
291
+ eventId: `migrated_${String(message.id || index)}`,
292
+ idempotencyKey: `legacy:${String(message.id || index)}`,
293
+ createdAt: message.createdAt,
294
+ });
295
+ return event ? [event] : [];
296
+ });
297
+ const corrections = legacy.suppressed.map(({ duplicate, original }, index) => ({
298
+ version: JOURNAL_VERSION,
299
+ eventId: `correction_${String(duplicate && duplicate.id || index)}`,
300
+ idempotencyKey: `correction:${String(duplicate && duplicate.id || index)}`,
301
+ sessionId,
302
+ turnId: "migration",
303
+ itemId: "",
304
+ type: "history.corrected",
305
+ createdAt: new Date().toISOString(),
306
+ payload: {
307
+ suppressedEventId: String(duplicate && duplicate.id || ""),
308
+ duplicateOf: String(original && original.id || ""),
309
+ reason: "legacy_system_baseline_shift",
310
+ },
311
+ }));
312
+ const result = appendJournalEvents(workspaceRoot, sessionId, imported.concat(corrections));
313
+ return { ...result, migrated: result.ok, suppressed: legacy.suppressed.length };
314
+ }
315
+
316
+ function appendTurnMessages(
317
+ workspaceRoot = process.cwd(),
318
+ sessionId = "",
319
+ turnId = "",
320
+ messages = [],
321
+ options = {},
322
+ ) {
323
+ const migration = migrateLegacyJournal(workspaceRoot, sessionId);
324
+ if (!migration.ok) return migration;
325
+ const scope = String(options.scope || "items").trim() || "items";
326
+ const events = (Array.isArray(messages) ? messages : []).flatMap((message, index) => {
327
+ const event = messageToJournalEvent(message, {
328
+ sessionId,
329
+ turnId,
330
+ index,
331
+ idempotencyKey: `${turnId || "turn"}:${scope}:${index}:${messageEventType(message)}`,
332
+ });
333
+ return event ? [event] : [];
334
+ });
335
+ return appendJournalEvents(workspaceRoot, sessionId, events);
336
+ }
337
+
338
+ module.exports = {
339
+ JOURNAL_VERSION,
340
+ getJournalDir,
341
+ getJournalPath,
342
+ deleteSessionJournal,
343
+ loadJournal,
344
+ appendJournalEvents,
345
+ appendTurnMessages,
346
+ loadTranscriptProjection,
347
+ migrateLegacyJournal,
348
+ legacyDuplicateIndexes,
349
+ messageToJournalEvent,
350
+ journalEventToTranscriptEvent,
351
+ };
@@ -1780,6 +1780,27 @@ async function runNativeLoop({
1780
1780
  if (!resume) {
1781
1781
  transport.prepareMessages({ messages, systemPrompt, prompt });
1782
1782
  }
1783
+ // Request history is provider-local. Capture the boundary only after the
1784
+ // provider has prepared system/user input, then expose new model/tool items
1785
+ // as an explicit current-turn delta. Durable history never infers a delta
1786
+ // by comparing this mutable wire array with a persisted transcript.
1787
+ const turnOutputStart = messages.length;
1788
+ const isProviderToolResultUserMessage = (message) => (
1789
+ String(message && message.role || "").toLowerCase() === "user"
1790
+ && Array.isArray(message.content)
1791
+ && message.content.some((block) => {
1792
+ const type = String(block && block.type || "").toLowerCase();
1793
+ return type === "tool_result" || type === "image" || type === "image_url";
1794
+ })
1795
+ );
1796
+ const currentTurnItems = () => cloneMessageList(messages.slice(turnOutputStart))
1797
+ // External user input is committed by the turn coordinator before this
1798
+ // loop starts. Any later user-role messages are runtime nudges/mailbox
1799
+ // controls for the provider and must not appear as user chat history.
1800
+ .filter((message) => (
1801
+ String(message && message.role || "").toLowerCase() !== "user"
1802
+ || isProviderToolResultUserMessage(message)
1803
+ ));
1783
1804
 
1784
1805
  let aggregated = "";
1785
1806
  let streamed = false;
@@ -2002,6 +2023,7 @@ async function runNativeLoop({
2002
2023
  streamed,
2003
2024
  toolCallsExecuted,
2004
2025
  messages,
2026
+ turnItems: currentTurnItems(),
2005
2027
  usage,
2006
2028
  executionState,
2007
2029
  protocolLedger: lastProtocolLedger || snapshotLedger(activeLedger),
@@ -2023,6 +2045,7 @@ async function runNativeLoop({
2023
2045
  streamed,
2024
2046
  toolCallsExecuted,
2025
2047
  messages,
2048
+ turnItems: currentTurnItems(),
2026
2049
  usage,
2027
2050
  executionState,
2028
2051
  protocolLedger: lastProtocolLedger || snapshotLedger(activeLedger),
@@ -2202,6 +2225,7 @@ async function runNativeLoop({
2202
2225
  streamed,
2203
2226
  toolCallsExecuted,
2204
2227
  messages,
2228
+ turnItems: currentTurnItems(),
2205
2229
  usage,
2206
2230
  executionState,
2207
2231
  waitingUserInteraction: true,
@@ -2217,6 +2241,7 @@ async function runNativeLoop({
2217
2241
  streamed,
2218
2242
  toolCallsExecuted,
2219
2243
  messages,
2244
+ turnItems: currentTurnItems(),
2220
2245
  usage,
2221
2246
  executionState,
2222
2247
  waitingUserInteraction: true,
@@ -2376,6 +2401,7 @@ async function runNativeAgentTask({
2376
2401
  error: "",
2377
2402
  output: outputText,
2378
2403
  messages: cloneMessageList(runResult.messages),
2404
+ turnItems: cloneMessageList(runResult.turnItems),
2379
2405
  sessionId: nextSessionId,
2380
2406
  usage,
2381
2407
  contextMeter,
@@ -6,10 +6,9 @@ const {
6
6
  getTranscriptFilePath,
7
7
  loadTranscript,
8
8
  migrateNlMessagesToTranscript,
9
- appendTranscriptMessages,
10
- transcriptEventsToMessages,
11
9
  deleteTranscript,
12
10
  } = require("./context/transcript");
11
+ const { getJournalPath, deleteSessionJournal } = require("./conversation/sessionJournal");
13
12
  const { deleteSessionArtifacts } = require("./context/artifacts");
14
13
  const { deleteSessionCommitLog, maybeGcSessionArtifacts } = require("./context/artifactGc");
15
14
  const { defaultContextPolicy } = require("./context/assembler");
@@ -84,8 +83,11 @@ function buildSessionSnapshot(input = {}) {
84
83
  };
85
84
 
86
85
  return {
87
- version: 2,
86
+ version: 3,
88
87
  ...base,
88
+ journal: {
89
+ path: getJournalPath(base.workspaceRoot, sessionId),
90
+ },
89
91
  transcript: {
90
92
  path: getTranscriptFilePath(base.workspaceRoot, sessionId),
91
93
  },
@@ -150,22 +152,6 @@ function hydrateSessionFromDisk(snapshot = {}, workspaceRoot = process.cwd()) {
150
152
  return payload;
151
153
  }
152
154
 
153
- function syncTranscriptFromNlMessages(workspaceRoot = process.cwd(), sessionId = "", nlMessages = []) {
154
- const messages = Array.isArray(nlMessages) ? nlMessages : [];
155
- if (!sessionId || messages.length === 0) return;
156
- const existing = loadTranscript(workspaceRoot, sessionId);
157
- if (existing.events.length === 0) {
158
- migrateNlMessagesToTranscript(workspaceRoot, sessionId, messages);
159
- return;
160
- }
161
- const { matchTranscriptBaseline } = require("./context/assembler");
162
- const priorMessages = transcriptEventsToMessages(existing.events);
163
- const baseline = matchTranscriptBaseline(priorMessages, messages);
164
- if (messages.length > baseline) {
165
- appendTranscriptMessages(workspaceRoot, sessionId, messages.slice(baseline));
166
- }
167
- }
168
-
169
155
  function listSessionSummaries(workspaceRoot = process.cwd(), { limit = 40 } = {}) {
170
156
  const dir = getSessionsDir(workspaceRoot);
171
157
  const cap = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 40;
@@ -250,8 +236,9 @@ function saveSessionSnapshot(workspaceRoot = process.cwd(), snapshot = {}) {
250
236
 
251
237
  const toWrite = { ...payload };
252
238
  if (toWrite.version >= 2) {
253
- syncTranscriptFromNlMessages(normalizedRoot, payload.sessionId, payload.nlMessages);
254
- // nlMessages live in transcript.jsonl; keep session.json light
239
+ // Conversation content is committed explicitly by the turn coordinator.
240
+ // Session snapshot saves projections/metadata only and never tries to infer
241
+ // missing events from a mutable provider message array.
255
242
  delete toWrite.nlMessages;
256
243
  }
257
244
 
@@ -349,6 +336,7 @@ function deleteSessionData(workspaceRoot = process.cwd(), sessionId = "") {
349
336
  const filePath = getSessionFilePath(workspaceRoot, normalizedId);
350
337
  try {
351
338
  if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
339
+ deleteSessionJournal(workspaceRoot, normalizedId);
352
340
  deleteTranscript(workspaceRoot, normalizedId);
353
341
  deleteSessionArtifacts(workspaceRoot, normalizedId);
354
342
  deleteSessionCommitLog(workspaceRoot, normalizedId);
@@ -165,6 +165,7 @@ async function runDecomposedTask({
165
165
  }) {
166
166
  const steps = decomposeBugFixTask(task);
167
167
  const results = [];
168
+ const turnItems = [];
168
169
  let aborted = false;
169
170
 
170
171
  // Check if already aborted
@@ -227,9 +228,8 @@ async function runDecomposedTask({
227
228
  : null,
228
229
  });
229
230
 
230
- if (state && stepResult && Array.isArray(stepResult.messages)) {
231
- const { syncMessagesToTranscript } = require("./context/assembler");
232
- syncMessagesToTranscript(state, stepResult.messages, workspaceRoot);
231
+ if (stepResult && Array.isArray(stepResult.turnItems)) {
232
+ turnItems.push(...stepResult.turnItems);
233
233
  }
234
234
 
235
235
  results.push({
@@ -258,6 +258,7 @@ async function runDecomposedTask({
258
258
  ok: false,
259
259
  error: `Failed at ${step.name}: ${stepResult.error}`,
260
260
  results,
261
+ turnItems,
261
262
  };
262
263
  }
263
264
 
@@ -274,6 +275,7 @@ async function runDecomposedTask({
274
275
  ok: false,
275
276
  error: `Error at ${step.name}: ${err.message}`,
276
277
  results,
278
+ turnItems,
277
279
  };
278
280
  }
279
281
  }
@@ -283,6 +285,7 @@ async function runDecomposedTask({
283
285
  ok: false,
284
286
  error: "Task aborted by user",
285
287
  results,
288
+ turnItems,
286
289
  };
287
290
  }
288
291
 
@@ -293,6 +296,7 @@ async function runDecomposedTask({
293
296
  ok: true,
294
297
  summary,
295
298
  results,
299
+ turnItems,
296
300
  };
297
301
  }
298
302
 
@@ -175,7 +175,7 @@ async function dispatchUcodeSlashCommand(result, ports = {}) {
175
175
  const banner = Array.isArray(ports.bannerLines) ? ports.bannerLines : [];
176
176
  const bannerEntries = banner.concat([""]).map((line, idx) => ({
177
177
  id: `b-${idx}`,
178
- kind: "system",
178
+ kind: idx < banner.length ? "banner" : "spacer",
179
179
  text: String(line || ""),
180
180
  speaker: "",
181
181
  }));
@@ -58,6 +58,8 @@ const EVENT_NAMES = Object.freeze([
58
58
  "stream.start",
59
59
  "stream.delta",
60
60
  "stream.done",
61
+ "thinking.start",
62
+ "thinking.delta",
61
63
  "status.set",
62
64
  "agents.snapshot",
63
65
  "agents.patch",