@gencode/uni 0.3.1 → 0.4.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +104 -0
  3. package/dist/shared/src/index.d.ts +1 -1
  4. package/dist/shared/src/index.d.ts.map +1 -1
  5. package/dist/shared/src/index.js.map +1 -1
  6. package/dist/shared/src/session.d.ts +13 -0
  7. package/dist/shared/src/session.d.ts.map +1 -1
  8. package/dist/uni/src/artifacts/accumulator.d.ts +17 -0
  9. package/dist/uni/src/artifacts/accumulator.d.ts.map +1 -0
  10. package/dist/uni/src/artifacts/accumulator.js +73 -0
  11. package/dist/uni/src/artifacts/accumulator.js.map +1 -0
  12. package/dist/uni/src/artifacts/parser.d.ts +14 -0
  13. package/dist/uni/src/artifacts/parser.d.ts.map +1 -0
  14. package/dist/uni/src/artifacts/parser.js +151 -0
  15. package/dist/uni/src/artifacts/parser.js.map +1 -0
  16. package/dist/uni/src/artifacts/stream-filter.d.ts +16 -0
  17. package/dist/uni/src/artifacts/stream-filter.d.ts.map +1 -0
  18. package/dist/uni/src/artifacts/stream-filter.js +93 -0
  19. package/dist/uni/src/artifacts/stream-filter.js.map +1 -0
  20. package/dist/uni/src/artifacts/tool-inference.d.ts +11 -0
  21. package/dist/uni/src/artifacts/tool-inference.d.ts.map +1 -0
  22. package/dist/uni/src/artifacts/tool-inference.js +257 -0
  23. package/dist/uni/src/artifacts/tool-inference.js.map +1 -0
  24. package/dist/uni/src/events/normalizer.d.ts.map +1 -1
  25. package/dist/uni/src/events/normalizer.js +8 -1
  26. package/dist/uni/src/events/normalizer.js.map +1 -1
  27. package/dist/uni/src/events/provider-events.d.ts.map +1 -1
  28. package/dist/uni/src/events/provider-events.js +353 -56
  29. package/dist/uni/src/events/provider-events.js.map +1 -1
  30. package/dist/uni/src/program.d.ts.map +1 -1
  31. package/dist/uni/src/program.js +8 -4
  32. package/dist/uni/src/program.js.map +1 -1
  33. package/dist/uni/src/providers/claude-code.d.ts.map +1 -1
  34. package/dist/uni/src/providers/claude-code.js +6 -2
  35. package/dist/uni/src/providers/claude-code.js.map +1 -1
  36. package/dist/uni/src/providers/codex.d.ts.map +1 -1
  37. package/dist/uni/src/providers/codex.js +13 -7
  38. package/dist/uni/src/providers/codex.js.map +1 -1
  39. package/dist/uni/src/providers/opencode.d.ts.map +1 -1
  40. package/dist/uni/src/providers/opencode.js +6 -2
  41. package/dist/uni/src/providers/opencode.js.map +1 -1
  42. package/dist/uni/src/providers/pi.d.ts.map +1 -1
  43. package/dist/uni/src/providers/pi.js +14 -5
  44. package/dist/uni/src/providers/pi.js.map +1 -1
  45. package/dist/uni/src/providers/provider-state.d.ts +10 -0
  46. package/dist/uni/src/providers/provider-state.d.ts.map +1 -0
  47. package/dist/uni/src/providers/provider-state.js +50 -0
  48. package/dist/uni/src/providers/provider-state.js.map +1 -0
  49. package/dist/uni/src/providers/spawned-provider.d.ts.map +1 -1
  50. package/dist/uni/src/providers/spawned-provider.js +36 -4
  51. package/dist/uni/src/providers/spawned-provider.js.map +1 -1
  52. package/dist/uni/src/run.d.ts.map +1 -1
  53. package/dist/uni/src/run.js +852 -336
  54. package/dist/uni/src/run.js.map +1 -1
  55. package/dist/uni/src/session/artifact-operation-store.d.ts +66 -0
  56. package/dist/uni/src/session/artifact-operation-store.d.ts.map +1 -0
  57. package/dist/uni/src/session/artifact-operation-store.js +370 -0
  58. package/dist/uni/src/session/artifact-operation-store.js.map +1 -0
  59. package/dist/uni/src/session/context-store.d.ts +37 -0
  60. package/dist/uni/src/session/context-store.d.ts.map +1 -0
  61. package/dist/uni/src/session/context-store.js +222 -0
  62. package/dist/uni/src/session/context-store.js.map +1 -0
  63. package/dist/uni/src/session/session-store.d.ts +57 -2
  64. package/dist/uni/src/session/session-store.d.ts.map +1 -1
  65. package/dist/uni/src/session/session-store.js +484 -15
  66. package/dist/uni/src/session/session-store.js.map +1 -1
  67. package/dist/uni/src/transcript/turn-accumulator.d.ts +57 -0
  68. package/dist/uni/src/transcript/turn-accumulator.d.ts.map +1 -0
  69. package/dist/uni/src/transcript/turn-accumulator.js +136 -0
  70. package/dist/uni/src/transcript/turn-accumulator.js.map +1 -0
  71. package/dist/uni/src/types.d.ts +25 -1
  72. package/dist/uni/src/types.d.ts.map +1 -1
  73. package/package.json +2 -2
@@ -1,17 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
1
3
  import { isAgentDiagnosticEvent } from "@gencode/shared";
4
+ import { ArtifactAccumulator } from "./artifacts/accumulator.js";
5
+ import { ARTIFACTS_START_TAG, parseAndStripArtifacts, } from "./artifacts/parser.js";
6
+ import { ArtifactStreamFilter } from "./artifacts/stream-filter.js";
7
+ import { inferArtifactsFromToolExecution } from "./artifacts/tool-inference.js";
2
8
  import { generateApiKey } from "./auth/api-key.js";
3
9
  import { normalizeProviderEvent } from "./events/normalizer.js";
4
10
  import { UniHookDispatcher } from "./hooks.js";
11
+ import { providerStateDir, providerStateRoot } from "./providers/provider-state.js";
5
12
  import { createBuiltInProviderRegistry } from "./providers/registry.js";
13
+ import { createArtifactOperationRecorder } from "./session/artifact-operation-store.js";
14
+ import { createUniContextStore, } from "./session/context-store.js";
6
15
  import { resolveRunInput } from "./session/input.js";
7
16
  import { initializeUniLogger, uniLogger } from "./session/logger.js";
8
17
  import { createSessionState } from "./session/session-state.js";
9
- import { appendUniTranscriptEntry, buildUniSessionMetadata, saveUniSessionMetadata, } from "./session/session-store.js";
18
+ import { appendUniTranscriptEntries, appendUniTranscriptEntry, bindUniProviderSession, buildUniSessionMetadata, loadUniSessionMetadata, prepareUniSession, saveUniSessionMetadata, UniSessionError, updateUniSessionMetadata, } from "./session/session-store.js";
10
19
  import { CompositeSink } from "./transports/composite-sink.js";
11
20
  import { HttpCallbackSink } from "./transports/http-callback-sink.js";
12
21
  import { WebSocketSink } from "./transports/websocket-sink.js";
22
+ import { UniTurnAccumulator } from "./transcript/turn-accumulator.js";
13
23
  const DEFAULT_STREAM_EVENTS = ["start", "text", "done", "error"];
14
24
  const DEFAULT_TIMEOUT_MS = 600_000;
25
+ const ZERO_USAGE = { input: 0, output: 0, total: 0 };
26
+ const ALIGNED_RUNTIME_AGENTS = new Set([
27
+ "claude-code",
28
+ "codex",
29
+ "opencode",
30
+ "pi",
31
+ ]);
32
+ const UNI_ARTIFACT_DECLARATION_PROMPT = [
33
+ "## Artifact Declaration",
34
+ "Before your final answer, audit the current turn for user-relevant persistent files and user-openable URLs.",
35
+ "If this turn produced, modified, exported, downloaded, uploaded, shared, or published any such artifact, append exactly one JSON array block in this form:",
36
+ "<aimax_artifacts>",
37
+ '[{"kind":"file","file":"/absolute/or/workspace-relative/path","label":"short description"},{"kind":"url","url":"https://example.test/result","label":"short description"}]',
38
+ "</aimax_artifacts>",
39
+ "Audit successful write/edit/apply_patch operations, command outputs and redirections, plugin/skill results, online resources, and explicit child-agent artifacts.",
40
+ "Do not declare deleted, failed, read-only, temporary/cache, or internal AIMax session files.",
41
+ "If there are no artifacts, omit the block. Do not duplicate paths or URLs.",
42
+ "The block is for the runtime parser. It must be the last content in the answer, with no text after </aimax_artifacts>.",
43
+ ].join("\n");
15
44
  export const UNSUPPORTED_UNI_RUN_OPTION_MESSAGES = {
16
45
  agent: "--agent is not supported by @gencode/uni yet",
17
46
  skillsLoadPaths: "--skillsLoadPaths is not supported by @gencode/uni yet",
@@ -22,73 +51,62 @@ export const UNSUPPORTED_UNI_RUN_OPTION_MESSAGES = {
22
51
  };
23
52
  const UNSUPPORTED_UNI_RUN_OPTION_KEYS = Object.keys(UNSUPPORTED_UNI_RUN_OPTION_MESSAGES);
24
53
  function hasUnsupportedOptionValue(value) {
25
- if (value === undefined || value === null) {
54
+ if (value === undefined || value === null)
26
55
  return false;
27
- }
28
- if (typeof value === "string") {
56
+ if (typeof value === "string")
29
57
  return value.trim().length > 0;
30
- }
31
- if (Array.isArray(value)) {
58
+ if (Array.isArray(value))
32
59
  return value.length > 0;
33
- }
34
60
  return true;
35
61
  }
36
62
  function collectUnsupportedOptionKeys(options) {
37
63
  const record = options;
38
- const unsupported = [];
39
- for (const key of UNSUPPORTED_UNI_RUN_OPTION_KEYS) {
40
- if (hasUnsupportedOptionValue(record[key])) {
41
- unsupported.push(key);
42
- }
43
- }
44
- return unsupported;
64
+ return UNSUPPORTED_UNI_RUN_OPTION_KEYS.filter((key) => hasUnsupportedOptionValue(record[key]));
45
65
  }
46
66
  export function omitUnsupportedUniRunOptions(options) {
47
67
  const omittedKeys = collectUnsupportedOptionKeys(options);
48
- if (omittedKeys.length === 0) {
68
+ if (omittedKeys.length === 0)
49
69
  return { options, omittedKeys };
50
- }
51
70
  const nextOptions = { ...options };
52
- for (const key of omittedKeys) {
71
+ for (const key of omittedKeys)
53
72
  delete nextOptions[key];
54
- }
55
73
  return {
56
74
  options: nextOptions,
57
75
  omittedKeys,
58
76
  };
59
77
  }
60
78
  function parseStreamEvents(value) {
61
- if (!value?.trim()) {
79
+ if (!value?.trim())
62
80
  return new Set(DEFAULT_STREAM_EVENTS);
63
- }
64
- const parsed = value
65
- .split(",")
66
- .map((entry) => entry.trim().toLowerCase())
67
- .filter(Boolean);
68
- const allowed = new Set(["start", "text", "progress", "done", "error", "hitl", "title_updated"]);
81
+ const parsed = value.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
82
+ const allowed = new Set([
83
+ "start",
84
+ "text",
85
+ "progress",
86
+ "done",
87
+ "error",
88
+ "hitl",
89
+ "title_updated",
90
+ ]);
69
91
  const resolved = new Set();
70
92
  for (const item of parsed) {
71
- if (!allowed.has(item)) {
93
+ if (!allowed.has(item))
72
94
  throw new Error(`Invalid stream event: ${item}`);
73
- }
74
95
  resolved.add(item);
75
96
  }
76
97
  return resolved.size > 0 ? resolved : new Set(DEFAULT_STREAM_EVENTS);
77
98
  }
78
99
  function coerceTimeout(value) {
79
- if (value === undefined) {
100
+ if (value === undefined)
80
101
  return DEFAULT_TIMEOUT_MS;
81
- }
82
102
  const parsed = typeof value === "number" ? value : Number(value);
83
- if (!Number.isFinite(parsed) || parsed <= 0) {
103
+ if (!Number.isFinite(parsed) || parsed <= 0)
84
104
  throw new Error(`Invalid timeout: ${String(value)}`);
85
- }
86
105
  return parsed;
87
106
  }
88
107
  function coerceContextWindow(value) {
89
- if (value === undefined) {
108
+ if (value === undefined)
90
109
  return undefined;
91
- }
92
110
  const parsed = typeof value === "number" ? value : Number(value);
93
111
  if (!Number.isFinite(parsed) || parsed <= 0) {
94
112
  throw new Error(`Invalid context window: ${String(value)}`);
@@ -97,18 +115,16 @@ function coerceContextWindow(value) {
97
115
  }
98
116
  function ensureDataDir(value) {
99
117
  const dataDir = value ?? process.env.AIMAX_DATA_DIR;
100
- if (!dataDir) {
118
+ if (!dataDir)
101
119
  throw new Error("Data directory must be specified via dataDir or AIMAX_DATA_DIR");
102
- }
103
120
  return dataDir;
104
121
  }
105
122
  function ensureChannel(value) {
106
123
  return value ?? "WEB";
107
124
  }
108
125
  function ensureApiKey(options) {
109
- if (options.authToken) {
126
+ if (options.authToken)
110
127
  return generateApiKey(options.authToken);
111
- }
112
128
  return options.apiKey ?? process.env.AIMAX_API_KEY ?? "";
113
129
  }
114
130
  function assertSupportedOptions(options) {
@@ -117,6 +133,32 @@ function assertSupportedOptions(options) {
117
133
  throw new Error(unsupported.map((key) => UNSUPPORTED_UNI_RUN_OPTION_MESSAGES[key]).join("; "));
118
134
  }
119
135
  }
136
+ function parseArtifactUrlWhitelist(value) {
137
+ const rawValues = Array.isArray(value) ? value : value?.split(",") ?? [];
138
+ const hostnames = rawValues.flatMap((raw) => {
139
+ const candidate = raw.trim();
140
+ if (!candidate)
141
+ return [];
142
+ try {
143
+ return [new URL(candidate).hostname.toLowerCase()];
144
+ }
145
+ catch {
146
+ return [candidate.toLowerCase()];
147
+ }
148
+ }).filter(Boolean);
149
+ return hostnames.length > 0 ? [...new Set(hostnames)] : undefined;
150
+ }
151
+ function accumulateUsage(current, next) {
152
+ if (!next)
153
+ return current;
154
+ if (!current)
155
+ return { ...next };
156
+ return {
157
+ input: current.input + next.input,
158
+ output: current.output + next.output,
159
+ total: current.total + next.total,
160
+ };
161
+ }
120
162
  async function resolveRunConfig(options) {
121
163
  assertSupportedOptions(options);
122
164
  const input = await resolveRunInput(options);
@@ -124,7 +166,7 @@ async function resolveRunConfig(options) {
124
166
  runtimeAgent: options.runtimeAgent,
125
167
  dataDir: ensureDataDir(options.dataDir),
126
168
  projectDir: options.projectDir?.trim() || undefined,
127
- sessionId: options.sessionId?.trim() || undefined,
169
+ sessionId: options.sessionId?.trim() || randomUUID(),
128
170
  sessionStore: options.sessionStore?.trim() || "sessions",
129
171
  messageId: options.messageId?.trim() || undefined,
130
172
  channel: ensureChannel(options.channel),
@@ -138,6 +180,7 @@ async function resolveRunConfig(options) {
138
180
  streamUrl: options.streamUrl?.trim() || undefined,
139
181
  streamAuthToken: options.streamAuthToken?.trim() || undefined,
140
182
  streamEvents: parseStreamEvents(options.streamEvents),
183
+ artifactsUrlWhitelist: parseArtifactUrlWhitelist(options.artifactsUrlWhitelist),
141
184
  timeoutMs: coerceTimeout(options.timeout),
142
185
  output: options.output === "json" || options.output === "jsonl" ? options.output : "text",
143
186
  runtimeConfig: options.runtimeConfig ?? {},
@@ -148,32 +191,25 @@ async function resolveRunConfig(options) {
148
191
  function createExternalSink(config) {
149
192
  const sinks = [];
150
193
  let websocketSink = null;
151
- if (config.callbackUrl) {
194
+ if (config.callbackUrl)
152
195
  sinks.push(new HttpCallbackSink(config.callbackUrl));
153
- }
154
196
  if (config.streamUrl) {
155
197
  websocketSink = new WebSocketSink(config.streamUrl, config.streamEvents, config.streamAuthToken);
156
198
  sinks.push(websocketSink);
157
199
  }
158
- return {
159
- sink: new CompositeSink(sinks),
160
- websocketSink,
161
- };
200
+ return { sink: new CompositeSink(sinks), websocketSink };
162
201
  }
163
202
  async function emitExternalEvent(sink, event, state, onExternalEvent) {
164
203
  state.externalEvents.push(event);
165
204
  await onExternalEvent?.(event);
166
205
  await sink.send(event);
167
206
  }
168
- function eventMessageId(event, defaultMessageId) {
169
- return event.messageId ?? defaultMessageId;
207
+ function eventMessageId(event, fallback) {
208
+ return event.messageId ?? fallback;
170
209
  }
171
210
  async function emitProgressEvent(params) {
172
211
  const { progressEvent, state, sink, websocketSink, onProgress, onExternalEvent } = params;
173
212
  await onProgress?.(progressEvent);
174
- if (progressEvent.sessionId) {
175
- state.sessionId = progressEvent.sessionId;
176
- }
177
213
  if (progressEvent.type === "stream_text_delta") {
178
214
  state.finalText += progressEvent.text;
179
215
  await websocketSink?.sendTextDelta({
@@ -184,12 +220,11 @@ async function emitProgressEvent(params) {
184
220
  text: progressEvent.text,
185
221
  });
186
222
  }
187
- if (progressEvent.type === "text") {
223
+ else if (progressEvent.type === "text") {
188
224
  state.finalText += progressEvent.text;
189
225
  }
190
- if (isAgentDiagnosticEvent(progressEvent)) {
226
+ if (isAgentDiagnosticEvent(progressEvent))
191
227
  return;
192
- }
193
228
  if (progressEvent.type === "start") {
194
229
  await emitExternalEvent(sink, {
195
230
  type: "start",
@@ -234,14 +269,411 @@ async function emitProgressEvent(params) {
234
269
  event: progressEvent,
235
270
  }, state, onExternalEvent);
236
271
  }
272
+ function promptText(input) {
273
+ if (input.kind === "text")
274
+ return input.message;
275
+ return input.singleUserText ?? JSON.stringify(input.messages);
276
+ }
277
+ function providerInput(input, aligned) {
278
+ if (!aligned)
279
+ return input;
280
+ if (input.kind === "text") {
281
+ return { kind: "text", message: `${input.message}\n\n${UNI_ARTIFACT_DECLARATION_PROMPT}` };
282
+ }
283
+ const messages = [
284
+ ...input.messages,
285
+ { role: "user", content: UNI_ARTIFACT_DECLARATION_PROMPT },
286
+ ];
287
+ return {
288
+ kind: "messages",
289
+ messages,
290
+ ...(input.singleUserText
291
+ ? { singleUserText: `${input.singleUserText}\n\n${UNI_ARTIFACT_DECLARATION_PROMPT}` }
292
+ : {}),
293
+ };
294
+ }
295
+ function titleForPrompt(value) {
296
+ return value.length > 80 ? `${value.slice(0, 77)}...` : value;
297
+ }
298
+ function nonEmpty(value) {
299
+ return value?.trim() ? value.trim() : undefined;
300
+ }
301
+ function redactKnownProviderIds(value, providerIds) {
302
+ let redacted = value;
303
+ for (const providerId of providerIds) {
304
+ if (providerId) {
305
+ redacted = redacted.replaceAll(providerId, "[provider-session]");
306
+ }
307
+ }
308
+ return redacted;
309
+ }
310
+ function redactKnownProviderIdsInValue(value, providerIds, depth = 0) {
311
+ if (typeof value === "string") {
312
+ return redactKnownProviderIds(value, providerIds);
313
+ }
314
+ if (!value || typeof value !== "object" || depth > 12) {
315
+ return value;
316
+ }
317
+ if (Array.isArray(value)) {
318
+ return value.map((item) => redactKnownProviderIdsInValue(item, providerIds, depth + 1));
319
+ }
320
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
321
+ key,
322
+ redactKnownProviderIdsInValue(nested, providerIds, depth + 1),
323
+ ]));
324
+ }
325
+ function redactProviderEventForPublicBoundary(event, providerIds) {
326
+ return redactKnownProviderIdsInValue(event, providerIds);
327
+ }
328
+ function providerStateRoots(config) {
329
+ const commonEnv = config.runtimeConfig.env ?? {};
330
+ switch (config.runtimeAgent) {
331
+ case "claude-code": {
332
+ const runtime = config.runtimeConfig.claudeCode;
333
+ return [
334
+ nonEmpty(runtime?.env?.CLAUDE_CONFIG_DIR)
335
+ ?? nonEmpty(runtime?.provider?.env?.CLAUDE_CONFIG_DIR)
336
+ ?? nonEmpty(commonEnv.CLAUDE_CONFIG_DIR)
337
+ ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR)
338
+ ?? providerStateDir(config.dataDir, "claude-code"),
339
+ ];
340
+ }
341
+ case "codex": {
342
+ const runtime = config.runtimeConfig.codex;
343
+ return [
344
+ nonEmpty(runtime?.codexHome)
345
+ ?? nonEmpty(runtime?.env?.CODEX_HOME)
346
+ ?? nonEmpty(runtime?.provider?.env?.CODEX_HOME)
347
+ ?? nonEmpty(commonEnv.CODEX_HOME)
348
+ ?? nonEmpty(process.env.CODEX_HOME)
349
+ ?? providerStateDir(config.dataDir, "codex"),
350
+ ];
351
+ }
352
+ case "opencode": {
353
+ const runtime = config.runtimeConfig.opencode;
354
+ const root = nonEmpty(runtime?.env?.XDG_DATA_HOME)
355
+ ?? nonEmpty(runtime?.provider?.env?.XDG_DATA_HOME)
356
+ ?? nonEmpty(commonEnv.XDG_DATA_HOME)
357
+ ?? nonEmpty(process.env.XDG_DATA_HOME)
358
+ ?? providerStateRoot(config.dataDir);
359
+ return [path.join(root, "opencode")];
360
+ }
361
+ case "pi": {
362
+ const runtime = config.runtimeConfig.pi;
363
+ const agentDir = nonEmpty(runtime?.env?.PI_CODING_AGENT_DIR)
364
+ ?? nonEmpty(commonEnv.PI_CODING_AGENT_DIR)
365
+ ?? nonEmpty(process.env.PI_CODING_AGENT_DIR)
366
+ ?? providerStateDir(config.dataDir, "pi");
367
+ const sessionsDir = nonEmpty(runtime?.env?.PI_CODING_AGENT_SESSION_DIR)
368
+ ?? nonEmpty(commonEnv.PI_CODING_AGENT_SESSION_DIR)
369
+ ?? nonEmpty(process.env.PI_CODING_AGENT_SESSION_DIR)
370
+ ?? path.join(agentDir, "sessions");
371
+ return [agentDir, sessionsDir];
372
+ }
373
+ case "acp":
374
+ return [];
375
+ }
376
+ }
377
+ function configuredProviderModel(config) {
378
+ if (config.model)
379
+ return config.model;
380
+ switch (config.runtimeAgent) {
381
+ case "claude-code":
382
+ return nonEmpty(config.runtimeConfig.claudeCode?.provider?.model);
383
+ case "codex":
384
+ return nonEmpty(config.runtimeConfig.codex?.provider?.model);
385
+ case "opencode":
386
+ return nonEmpty(config.runtimeConfig.opencode?.provider?.model);
387
+ case "pi":
388
+ case "acp":
389
+ return undefined;
390
+ }
391
+ }
392
+ async function prepareAcpCompatibilitySession(params) {
393
+ const existing = await loadUniSessionMetadata(params.config.dataDir, params.config.sessionId, params.config.sessionStore);
394
+ if (!existing) {
395
+ await saveUniSessionMetadata(params.config.dataDir, buildUniSessionMetadata({
396
+ sessionId: params.config.sessionId,
397
+ title: params.title,
398
+ channel: params.config.channel,
399
+ }), params.config.sessionStore);
400
+ }
401
+ }
402
+ function toRecord(value) {
403
+ return value && typeof value === "object" && !Array.isArray(value)
404
+ ? value
405
+ : { input: value };
406
+ }
407
+ function canonicalToolName(name) {
408
+ const normalized = name.trim().toLowerCase().replaceAll("-", "_");
409
+ if (normalized === "write")
410
+ return "write_file";
411
+ if (normalized === "edit")
412
+ return "edit_file";
413
+ if (normalized === "command_execution" || normalized === "shell" || normalized === "bash_command") {
414
+ return "exec";
415
+ }
416
+ if (normalized === "file_change")
417
+ return "apply_patch";
418
+ return normalized;
419
+ }
420
+ function hasStringField(record, keys) {
421
+ return keys.some((key) => typeof record[key] === "string");
422
+ }
423
+ function isTrustedOpenCodeCompletedOnlyFileTool(event) {
424
+ if (event.isError || !event.output.trim())
425
+ return false;
426
+ const input = toRecord(event.input);
427
+ if (!firstString(input, ["path", "file_path", "filePath"]))
428
+ return false;
429
+ const name = canonicalToolName(event.name);
430
+ if (name === "write_file") {
431
+ return hasStringField(input, ["content"]);
432
+ }
433
+ if (name === "edit_file") {
434
+ return hasStringField(input, ["oldString", "old_string", "oldText", "old_text"])
435
+ && hasStringField(input, ["newString", "new_string", "newText", "new_text"]);
436
+ }
437
+ return false;
438
+ }
439
+ function firstString(record, keys) {
440
+ for (const key of keys) {
441
+ const value = record[key];
442
+ if (typeof value === "string" && value.trim())
443
+ return value.trim();
444
+ }
445
+ return undefined;
446
+ }
447
+ function patchSummary(input) {
448
+ const added = [];
449
+ const modified = [];
450
+ const deleted = [];
451
+ const changes = input.changes;
452
+ if (Array.isArray(changes)) {
453
+ for (const raw of changes) {
454
+ if (!raw || typeof raw !== "object")
455
+ continue;
456
+ const change = raw;
457
+ const file = firstString(change, ["path", "file", "file_path"]);
458
+ if (!file)
459
+ continue;
460
+ const kind = firstString(change, ["kind", "type", "operation"])?.toLowerCase();
461
+ if (kind === "add" || kind === "create")
462
+ added.push(file);
463
+ else if (kind === "delete" || kind === "remove")
464
+ deleted.push(file);
465
+ else
466
+ modified.push(file);
467
+ }
468
+ }
469
+ const patch = firstString(input, ["patch", "input"]);
470
+ if (patch) {
471
+ for (const line of patch.split(/\r?\n/)) {
472
+ const match = line.match(/^\*\*\* (Add|Update|Delete) File:\s*(.+)$/);
473
+ if (!match?.[2])
474
+ continue;
475
+ if (match[1] === "Add")
476
+ added.push(match[2].trim());
477
+ else if (match[1] === "Delete")
478
+ deleted.push(match[2].trim());
479
+ else
480
+ modified.push(match[2].trim());
481
+ }
482
+ }
483
+ if (added.length + modified.length + deleted.length === 0)
484
+ return undefined;
485
+ return { added, modified, deleted };
486
+ }
487
+ function structuredFileChangesPatch(input) {
488
+ const changes = toRecord(input).changes;
489
+ if (!Array.isArray(changes))
490
+ return undefined;
491
+ const markers = ["*** Begin Patch"];
492
+ for (const raw of changes) {
493
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
494
+ continue;
495
+ const change = raw;
496
+ const kind = firstString(change, ["kind", "type", "operation"])?.toLowerCase();
497
+ const file = firstString(change, ["path", "file", "file_path", "target"]);
498
+ const source = firstString(change, ["source", "source_path", "from", "old_path"]);
499
+ if ((kind === "move" || kind === "rename") && file && source) {
500
+ markers.push(`*** Update File: ${source}`, `*** Move to: ${file}`);
501
+ }
502
+ else if ((kind === "add" || kind === "create") && file) {
503
+ markers.push(`*** Add File: ${file}`);
504
+ }
505
+ else if ((kind === "delete" || kind === "remove") && file) {
506
+ markers.push(`*** Delete File: ${file}`);
507
+ }
508
+ else if (file) {
509
+ markers.push(`*** Update File: ${file}`);
510
+ }
511
+ }
512
+ markers.push("*** End Patch");
513
+ return markers.length > 2 ? markers.join("\n") : undefined;
514
+ }
515
+ function artifactAuditStartEvent(event) {
516
+ if (canonicalToolName(event.name) !== "apply_patch")
517
+ return event;
518
+ const patch = structuredFileChangesPatch(event.input);
519
+ return patch ? { ...event, name: "apply_patch", input: { patch } } : event;
520
+ }
521
+ function artifactAuditEndEvent(event) {
522
+ if (canonicalToolName(event.name) !== "apply_patch")
523
+ return event;
524
+ const patch = structuredFileChangesPatch(event.input);
525
+ return patch ? { ...event, name: "apply_patch", input: { patch } } : event;
526
+ }
527
+ function artifactAuditFileOperation(event) {
528
+ const name = canonicalToolName(event.name);
529
+ if (name !== "apply_patch")
530
+ return { ...event, name };
531
+ const patch = structuredFileChangesPatch(event.input);
532
+ return {
533
+ ...event,
534
+ name,
535
+ ...(patch ? { input: { patch } } : {}),
536
+ };
537
+ }
538
+ function inferenceResultForTool(params) {
539
+ const input = toRecord(params.input);
540
+ const name = canonicalToolName(params.name);
541
+ const content = [{ type: "text", text: params.output }];
542
+ if (name === "write_file") {
543
+ return { content, details: { path: firstString(input, ["path", "file_path", "filePath"]) } };
544
+ }
545
+ if (name === "edit_file") {
546
+ return {
547
+ content,
548
+ details: {
549
+ path: firstString(input, ["path", "file_path", "filePath"]),
550
+ occurrences: params.isError ? 0 : 1,
551
+ },
552
+ };
553
+ }
554
+ if (name === "apply_patch")
555
+ return { content, details: { summary: patchSummary(input) } };
556
+ if (name === "exec" || name === "bash" || name === "process") {
557
+ return {
558
+ content,
559
+ details: {
560
+ status: params.isError ? "failed" : "completed",
561
+ cwd: firstString(input, ["cwd"]) ?? undefined,
562
+ },
563
+ };
564
+ }
565
+ return params.output;
566
+ }
567
+ function inferenceResult(event) {
568
+ return inferenceResultForTool(event);
569
+ }
570
+ function isInternalArtifact(artifact, dataDir) {
571
+ if (artifact.kind !== "file")
572
+ return false;
573
+ const internalRoot = path.resolve(dataDir, ".aimax");
574
+ const candidate = path.resolve(artifact.file);
575
+ return candidate === internalRoot || candidate.startsWith(`${internalRoot}${path.sep}`);
576
+ }
577
+ function parseArtifactArray(value, workspaceDir) {
578
+ if (!Array.isArray(value))
579
+ return [];
580
+ const now = new Date().toISOString();
581
+ return value.flatMap((raw) => {
582
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
583
+ return [];
584
+ const record = raw;
585
+ const label = typeof record.label === "string" && record.label.trim()
586
+ ? record.label.trim()
587
+ : undefined;
588
+ if (record.kind === "file" && typeof record.file === "string" && record.file.trim() && label) {
589
+ const file = path.isAbsolute(record.file) ? path.normalize(record.file) : path.resolve(workspaceDir, record.file);
590
+ return [{ kind: "file", file, label, timestamp: now }];
591
+ }
592
+ if (record.kind === "url" && typeof record.url === "string" && record.url.trim() && label) {
593
+ try {
594
+ const url = new URL(record.url).toString();
595
+ return [{ kind: "url", url, label, timestamp: now }];
596
+ }
597
+ catch {
598
+ return [];
599
+ }
600
+ }
601
+ return [];
602
+ });
603
+ }
604
+ function extractChildArtifacts(toolName, output, workspaceDir) {
605
+ const normalized = toolName.trim().toLowerCase().replaceAll("-", "_");
606
+ if (!normalized.includes("subagent") && normalized !== "task" && normalized !== "batch")
607
+ return [];
608
+ let parsed;
609
+ try {
610
+ parsed = JSON.parse(output);
611
+ }
612
+ catch {
613
+ return [];
614
+ }
615
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
616
+ return [];
617
+ const record = parsed;
618
+ const direct = parseArtifactArray(record.artifacts, workspaceDir);
619
+ if (direct.length > 0)
620
+ return direct;
621
+ const result = record.result;
622
+ return result && typeof result === "object" && !Array.isArray(result)
623
+ ? parseArtifactArray(result.artifacts, workspaceDir)
624
+ : [];
625
+ }
237
626
  export async function runUni(options) {
238
627
  const config = await resolveRunConfig(options);
239
628
  initializeUniLogger(config.dataDir);
240
629
  const registry = createBuiltInProviderRegistry();
241
630
  const provider = options.runtimeProviderFactory?.(config.runtimeAgent) ?? registry.get(config.runtimeAgent);
242
- if (!provider) {
631
+ if (!provider)
243
632
  throw new Error(`Unsupported runtime agent: ${config.runtimeAgent}`);
633
+ const aligned = ALIGNED_RUNTIME_AGENTS.has(config.runtimeAgent);
634
+ const rawPrompt = promptText(config.input);
635
+ const metadataTitle = titleForPrompt(rawPrompt);
636
+ let prepared;
637
+ let contextStore;
638
+ if (aligned) {
639
+ try {
640
+ prepared = await prepareUniSession({
641
+ dataDir: config.dataDir,
642
+ logicalSessionId: config.sessionId,
643
+ runtimeAgent: config.runtimeAgent,
644
+ title: metadataTitle,
645
+ channel: config.channel,
646
+ storeName: config.sessionStore,
647
+ providerStateRoots: providerStateRoots(config),
648
+ });
649
+ }
650
+ catch (error) {
651
+ if (error instanceof UniSessionError && error.code === "legacy_unresolved") {
652
+ await createUniContextStore({
653
+ dataDir: config.dataDir,
654
+ logicalSessionId: config.sessionId,
655
+ storeName: config.sessionStore,
656
+ });
657
+ }
658
+ throw error;
659
+ }
660
+ contextStore = await createUniContextStore({
661
+ dataDir: config.dataDir,
662
+ logicalSessionId: config.sessionId,
663
+ storeName: config.sessionStore,
664
+ });
244
665
  }
666
+ else {
667
+ await prepareAcpCompatibilitySession({ config, title: metadataTitle });
668
+ }
669
+ await appendUniTranscriptEntry(config.dataDir, config.sessionId, {
670
+ role: "user",
671
+ content: rawPrompt,
672
+ timestamp: new Date().toISOString(),
673
+ runtimeAgent: config.runtimeAgent,
674
+ ...(config.messageId ? { messageId: config.messageId } : {}),
675
+ }, config.sessionStore);
676
+ const coveredTranscriptEntryCount = (prepared?.existingTranscriptEntryCount ?? 0) + 1;
245
677
  const state = createSessionState({
246
678
  runtimeAgent: config.runtimeAgent,
247
679
  sessionId: config.sessionId,
@@ -255,344 +687,428 @@ export async function runUni(options) {
255
687
  const abortController = new AbortController();
256
688
  const timeout = setTimeout(() => abortController.abort(), config.timeoutMs);
257
689
  const hookDispatcher = new UniHookDispatcher(options.runtimeHooks);
690
+ const turn = new UniTurnAccumulator({
691
+ runtimeAgent: config.runtimeAgent,
692
+ messageId: config.messageId,
693
+ });
694
+ const streamFilter = aligned ? new ArtifactStreamFilter() : undefined;
695
+ const artifactAccumulator = aligned
696
+ ? new ArtifactAccumulator({ urlWhitelist: config.artifactsUrlWhitelist })
697
+ : undefined;
698
+ const workspaceDir = config.projectDir ?? config.dataDir;
699
+ const operationRecorder = aligned && config.channel !== "CRON"
700
+ ? createArtifactOperationRecorder({
701
+ dataDir: config.dataDir,
702
+ logicalSessionId: config.sessionId,
703
+ storeName: config.sessionStore,
704
+ workspaceDir,
705
+ })
706
+ : undefined;
258
707
  const activeToolCalls = new Map();
259
- const pendingTranscriptToolCalls = [];
260
- const pendingTranscriptToolCallIds = new Set();
261
- const pendingTranscriptToolResults = [];
262
- const pendingTranscriptToolResultIds = new Set();
263
- let pendingAssistantContent = "";
264
- let persistenceSessionId = config.sessionId;
265
- let metadataSaved = false;
266
- let metadataTitle = "";
267
- const pendingTranscriptEntries = [];
268
- const ensureSessionPersistence = async (sessionId) => {
269
- if (!metadataSaved) {
270
- await saveUniSessionMetadata(config.dataDir, buildUniSessionMetadata({
271
- sessionId,
272
- title: metadataTitle,
273
- channel: config.channel,
274
- }), config.sessionStore).catch((err) => { uniLogger.warn("failed to save session metadata", { error: String(err) }); });
275
- metadataSaved = true;
276
- }
277
- while (pendingTranscriptEntries.length > 0) {
278
- const entry = pendingTranscriptEntries.shift();
279
- if (!entry) {
280
- continue;
281
- }
282
- await appendUniTranscriptEntry(config.dataDir, sessionId, entry, config.sessionStore).catch((err) => { uniLogger.warn("failed to append buffered transcript", { error: String(err) }); });
283
- }
284
- };
285
- const setPersistenceSessionId = async (sessionId) => {
286
- if (!persistenceSessionId) {
287
- persistenceSessionId = sessionId;
288
- await ensureSessionPersistence(sessionId);
289
- return;
290
- }
291
- if (persistenceSessionId !== sessionId) {
292
- uniLogger.warn("provider session id differed from transcript session id", {
293
- transcriptSessionId: persistenceSessionId,
294
- providerSessionId: sessionId,
295
- });
296
- }
708
+ const observedToolStartIds = new Set();
709
+ const recordedCompletedOnlyToolIds = new Set();
710
+ const observedProviderSessionIds = new Set(prepared?.providerSessionId ? [prepared.providerSessionId] : []);
711
+ let rawProviderText = "";
712
+ let observedDoneText;
713
+ let observedUsage;
714
+ let observedModel;
715
+ let observedError;
716
+ let observedTitle;
717
+ const emitProgress = async (event) => {
718
+ progressEvents.push(event);
719
+ await emitProgressEvent({
720
+ progressEvent: event,
721
+ state,
722
+ sink,
723
+ websocketSink,
724
+ onProgress: options.onProgress,
725
+ onExternalEvent: options.onExternalEvent,
726
+ });
297
727
  };
298
- const appendTranscriptEntry = async (entry) => {
299
- if (!persistenceSessionId) {
300
- pendingTranscriptEntries.push(entry);
301
- return;
302
- }
303
- await ensureSessionPersistence(persistenceSessionId);
304
- await appendUniTranscriptEntry(config.dataDir, persistenceSessionId, entry, config.sessionStore).catch((err) => { uniLogger.warn("failed to append transcript", { error: String(err) }); });
728
+ const appendTurnEntries = async (entries) => {
729
+ await appendUniTranscriptEntries(config.dataDir, config.sessionId, entries, config.sessionStore);
305
730
  };
306
- const ensureFinalPersistenceSession = async () => {
307
- if (!persistenceSessionId) {
308
- persistenceSessionId = state.sessionId;
309
- }
310
- await ensureSessionPersistence(persistenceSessionId);
311
- return persistenceSessionId;
731
+ const flushCompletedTools = async () => {
732
+ if (turn.hasCompletedToolBatch())
733
+ await appendTurnEntries(turn.flushToolBatch());
312
734
  };
313
735
  const createToolHookContext = () => ({
314
736
  runtimeAgent: config.runtimeAgent,
315
- sessionId: state.sessionId,
316
- messageId: state.messageId,
317
- channel: state.channel,
737
+ sessionId: config.sessionId,
738
+ messageId: config.messageId,
739
+ channel: config.channel,
318
740
  projectDir: config.projectDir,
319
741
  });
320
- const recordPendingTranscriptToolCall = (toolCallId, toolName, params) => {
321
- if (pendingTranscriptToolCallIds.has(toolCallId)) {
322
- return;
323
- }
324
- pendingTranscriptToolCallIds.add(toolCallId);
325
- pendingTranscriptToolCalls.push({
326
- id: toolCallId,
327
- name: toolName,
328
- arguments: params,
329
- });
330
- };
331
- const flushPendingToolBatch = async () => {
332
- if (pendingTranscriptToolCalls.length === 0) {
333
- pendingTranscriptToolResults.length = 0;
334
- pendingTranscriptToolResultIds.clear();
742
+ const emitVisibleText = async (type, text) => {
743
+ rawProviderText += text;
744
+ const visible = streamFilter ? streamFilter.push(text) : text;
745
+ if (!visible)
335
746
  return;
336
- }
337
- await appendTranscriptEntry({
338
- role: "assistant",
339
- content: pendingAssistantContent,
340
- toolCalls: [...pendingTranscriptToolCalls],
341
- timestamp: new Date().toISOString(),
342
- runtimeAgent: config.runtimeAgent,
343
- messageId: state.messageId,
344
- });
345
- pendingAssistantContent = "";
346
- pendingTranscriptToolCalls.length = 0;
347
- pendingTranscriptToolCallIds.clear();
348
- for (const entry of pendingTranscriptToolResults) {
349
- await appendTranscriptEntry(entry);
350
- }
351
- pendingTranscriptToolResults.length = 0;
352
- pendingTranscriptToolResultIds.clear();
353
- };
354
- const recordPendingTranscriptToolResult = (params) => {
355
- if (pendingTranscriptToolResultIds.has(params.toolCallId)) {
356
- return;
357
- }
358
- pendingTranscriptToolResultIds.add(params.toolCallId);
359
- pendingTranscriptToolResults.push({
360
- role: "tool_result",
361
- toolCallId: params.toolCallId,
362
- toolName: params.toolName,
363
- content: params.output,
364
- isError: params.isError,
365
- timestamp: new Date().toISOString(),
366
- runtimeAgent: config.runtimeAgent,
367
- messageId: state.messageId,
368
- });
747
+ await flushCompletedTools();
748
+ turn.addText(visible);
749
+ const normalized = normalizeProviderEvent(config.sessionId, config.messageId, { type, text: visible });
750
+ for (const event of normalized)
751
+ await emitProgress(event);
369
752
  };
370
- const appendPendingAssistantContentEntry = async (fallbackContent, metadata) => {
371
- await flushPendingToolBatch();
372
- const content = pendingAssistantContent || fallbackContent || "";
373
- if (!content && !fallbackContent) {
753
+ const bindProviderSession = async (providerSessionId) => {
754
+ if (!aligned)
374
755
  return;
375
- }
376
- await appendTranscriptEntry({
377
- role: "assistant",
378
- content,
379
- timestamp: new Date().toISOString(),
756
+ await bindUniProviderSession({
757
+ dataDir: config.dataDir,
758
+ logicalSessionId: config.sessionId,
380
759
  runtimeAgent: config.runtimeAgent,
381
- messageId: state.messageId,
382
- ...metadata,
760
+ providerSessionId,
761
+ storeName: config.sessionStore,
383
762
  });
384
- pendingAssistantContent = "";
385
763
  };
386
764
  try {
387
- const promptText = config.input.kind === "text"
388
- ? config.input.message
389
- : config.input.singleUserText ?? `Loaded ${config.input.messages.length} structured messages`;
390
- metadataTitle = promptText.length > 80 ? promptText.slice(0, 77) + "..." : promptText;
391
765
  uniLogger.info("uni run started", {
392
766
  runtimeAgent: config.runtimeAgent,
393
- sessionId: state.sessionId,
394
- messageId: state.messageId,
767
+ sessionId: config.sessionId,
768
+ messageId: config.messageId,
395
769
  channel: config.channel,
396
770
  sessionStore: config.sessionStore,
397
771
  });
398
- await appendTranscriptEntry({
399
- role: "user",
400
- content: promptText,
401
- timestamp: new Date().toISOString(),
402
- runtimeAgent: config.runtimeAgent,
403
- messageId: state.messageId,
404
- });
405
- const startEvent = {
772
+ await emitProgress({
406
773
  type: "start",
407
- sessionId: state.sessionId,
408
- messageId: state.messageId,
409
- message: promptText,
410
- };
411
- progressEvents.push(startEvent);
412
- await emitProgressEvent({
413
- progressEvent: startEvent,
414
- state,
415
- sink,
416
- websocketSink,
417
- onProgress: options.onProgress,
418
- onExternalEvent: options.onExternalEvent,
419
- });
420
- const providerResult = await provider.run({
421
- runtimeAgent: config.runtimeAgent,
422
- dataDir: config.dataDir,
423
- projectDir: config.projectDir,
424
774
  sessionId: config.sessionId,
425
775
  messageId: config.messageId,
426
- channel: config.channel,
427
- baseUrl: config.baseUrl,
428
- apiKey: config.apiKey,
429
- authToken: config.authToken,
430
- model: config.model,
431
- contextWindow: config.contextWindow,
432
- flashModel: config.flashModel,
433
- timeoutMs: config.timeoutMs,
434
- runtimeConfig: config.runtimeConfig,
435
- input: config.input,
436
- signal: abortController.signal,
437
- }, async (providerEvent) => {
438
- if (providerEvent.type === "session_started") {
439
- await setPersistenceSessionId(providerEvent.sessionId);
440
- if (providerEvent.sessionId === state.sessionId) {
776
+ message: rawPrompt,
777
+ });
778
+ if (aligned && prepared?.isNew) {
779
+ await emitProgress({
780
+ type: "session_reset",
781
+ sessionId: config.sessionId,
782
+ messageId: config.messageId,
783
+ action: "new",
784
+ message: "Logical session started",
785
+ });
786
+ }
787
+ let providerResult;
788
+ try {
789
+ providerResult = await provider.run({
790
+ runtimeAgent: config.runtimeAgent,
791
+ logicalSessionId: config.sessionId,
792
+ dataDir: config.dataDir,
793
+ projectDir: config.projectDir,
794
+ providerSessionId: aligned ? prepared?.providerSessionId : nonEmpty(options.sessionId),
795
+ sessionId: aligned ? prepared?.providerSessionId : nonEmpty(options.sessionId),
796
+ messageId: config.messageId,
797
+ channel: config.channel,
798
+ baseUrl: config.baseUrl,
799
+ apiKey: config.apiKey,
800
+ authToken: config.authToken,
801
+ model: config.model,
802
+ contextWindow: config.contextWindow,
803
+ flashModel: config.flashModel,
804
+ timeoutMs: config.timeoutMs,
805
+ runtimeConfig: config.runtimeConfig,
806
+ input: providerInput(config.input, aligned),
807
+ signal: abortController.signal,
808
+ }, async (providerEvent) => {
809
+ let eventForProgress = providerEvent;
810
+ if (providerEvent.type === "session_started") {
811
+ observedModel = providerEvent.model ?? observedModel;
812
+ if (aligned) {
813
+ if (!observedProviderSessionIds.has(providerEvent.sessionId)) {
814
+ await bindProviderSession(providerEvent.sessionId);
815
+ observedProviderSessionIds.add(providerEvent.sessionId);
816
+ }
817
+ }
818
+ else {
819
+ for (const event of normalizeProviderEvent(config.sessionId, config.messageId, providerEvent)) {
820
+ await emitProgress(event);
821
+ }
822
+ }
441
823
  return;
442
824
  }
443
- }
444
- if (providerEvent.type === "step_finished") {
445
- await flushPendingToolBatch();
446
- }
447
- const normalized = normalizeProviderEvent(state.sessionId, state.messageId, providerEvent);
448
- for (const event of normalized) {
449
- if (event.type === "tool_start") {
450
- const params = event.input && typeof event.input === "object" && !Array.isArray(event.input)
451
- ? event.input
452
- : { input: event.input };
453
- activeToolCalls.set(event.toolCallId, {
454
- toolName: event.name,
825
+ if (aligned) {
826
+ providerEvent = redactProviderEventForPublicBoundary(providerEvent, observedProviderSessionIds);
827
+ eventForProgress = providerEvent;
828
+ }
829
+ if (providerEvent.type === "text_delta" || providerEvent.type === "message") {
830
+ await emitVisibleText(providerEvent.type, providerEvent.text);
831
+ return;
832
+ }
833
+ if (providerEvent.type === "thinking_delta") {
834
+ if (aligned) {
835
+ await flushCompletedTools();
836
+ turn.addThinking(providerEvent.text);
837
+ }
838
+ return;
839
+ }
840
+ if (providerEvent.type === "file_operation") {
841
+ const auditEvent = artifactAuditFileOperation(providerEvent);
842
+ await Promise.resolve(operationRecorder?.recordObservedFileOperation(auditEvent))
843
+ .catch(() => undefined);
844
+ if (!providerEvent.isError && artifactAccumulator) {
845
+ artifactAccumulator.addInferred(inferArtifactsFromToolExecution({
846
+ toolName: canonicalToolName(providerEvent.name),
847
+ input: toRecord(providerEvent.input),
848
+ result: inferenceResultForTool(providerEvent),
849
+ isError: false,
850
+ workspaceDir,
851
+ }).filter((artifact) => !isInternalArtifact(artifact, config.dataDir)));
852
+ }
853
+ return;
854
+ }
855
+ if (providerEvent.type === "tool_start") {
856
+ const params = toRecord(providerEvent.input);
857
+ observedToolStartIds.add(providerEvent.toolCallId);
858
+ activeToolCalls.set(providerEvent.toolCallId, {
859
+ toolName: providerEvent.name,
455
860
  params,
456
861
  startedAt: Date.now(),
457
862
  });
458
- recordPendingTranscriptToolCall(event.toolCallId, event.name, params);
863
+ turn.startTool(providerEvent);
864
+ await Promise.resolve(operationRecorder?.onToolStart(artifactAuditStartEvent(providerEvent)))
865
+ .catch(() => undefined);
459
866
  await hookDispatcher.dispatchBeforeToolCall({
460
- toolCallId: event.toolCallId,
461
- toolName: event.name,
867
+ toolCallId: providerEvent.toolCallId,
868
+ toolName: providerEvent.name,
462
869
  params,
463
870
  }, createToolHookContext());
464
871
  }
465
- if (event.type === "tool_end") {
466
- const active = activeToolCalls.get(event.toolCallId);
467
- const params = active?.params
468
- ?? (event.input && typeof event.input === "object" && !Array.isArray(event.input)
469
- ? event.input
470
- : { input: event.input });
471
- recordPendingTranscriptToolCall(event.toolCallId, event.name, params);
472
- recordPendingTranscriptToolResult({
473
- toolCallId: event.toolCallId,
474
- toolName: event.name,
475
- output: event.output,
476
- isError: event.isError,
872
+ else if (providerEvent.type === "tool_end") {
873
+ const active = activeToolCalls.get(providerEvent.toolCallId);
874
+ const params = active?.params ?? toRecord(providerEvent.input);
875
+ const toolEndEvent = active
876
+ ? {
877
+ ...providerEvent,
878
+ name: active.toolName,
879
+ input: active.params,
880
+ }
881
+ : providerEvent;
882
+ eventForProgress = toolEndEvent;
883
+ const paired = turn.endTool(toolEndEvent);
884
+ await Promise.resolve(operationRecorder?.onToolEnd(artifactAuditEndEvent(toolEndEvent)))
885
+ .catch(() => undefined);
886
+ if (config.runtimeAgent === "opencode"
887
+ && !observedToolStartIds.has(toolEndEvent.toolCallId)
888
+ && !recordedCompletedOnlyToolIds.has(toolEndEvent.toolCallId)
889
+ && isTrustedOpenCodeCompletedOnlyFileTool(toolEndEvent)) {
890
+ recordedCompletedOnlyToolIds.add(toolEndEvent.toolCallId);
891
+ await Promise.resolve(operationRecorder?.recordObservedFileOperation({
892
+ operationId: toolEndEvent.toolCallId,
893
+ name: toolEndEvent.name,
894
+ input: toolEndEvent.input,
895
+ output: toolEndEvent.output,
896
+ isError: toolEndEvent.isError,
897
+ })).catch(() => undefined);
898
+ }
899
+ await contextStore?.persistToolResult({
900
+ toolCallId: toolEndEvent.toolCallId,
901
+ toolName: toolEndEvent.name,
902
+ content: toolEndEvent.output,
903
+ }).catch((error) => {
904
+ uniLogger.warn("failed to persist observable tool result", { error: String(error) });
477
905
  });
906
+ if (aligned && paired && !providerEvent.isError && artifactAccumulator) {
907
+ artifactAccumulator.addChild(extractChildArtifacts(toolEndEvent.name, toolEndEvent.output, workspaceDir)
908
+ .filter((artifact) => !isInternalArtifact(artifact, config.dataDir)));
909
+ artifactAccumulator.addInferred(inferArtifactsFromToolExecution({
910
+ toolName: canonicalToolName(toolEndEvent.name),
911
+ input: params,
912
+ result: inferenceResult(toolEndEvent),
913
+ isError: toolEndEvent.isError,
914
+ workspaceDir,
915
+ }).filter((artifact) => !isInternalArtifact(artifact, config.dataDir)));
916
+ }
478
917
  await hookDispatcher.dispatchAfterToolCall({
479
- toolCallId: event.toolCallId,
480
- toolName: event.name,
918
+ toolCallId: providerEvent.toolCallId,
919
+ toolName: toolEndEvent.name,
481
920
  params,
482
- result: event.output,
483
- error: event.isError ? event.output : undefined,
921
+ result: toolEndEvent.output,
922
+ error: toolEndEvent.isError ? toolEndEvent.output : undefined,
484
923
  durationMs: active ? Date.now() - active.startedAt : undefined,
485
924
  }, createToolHookContext());
486
- activeToolCalls.delete(event.toolCallId);
925
+ activeToolCalls.delete(providerEvent.toolCallId);
487
926
  }
488
- if (event.type === "stream_text_delta" || event.type === "text") {
489
- await flushPendingToolBatch();
490
- pendingAssistantContent += event.text;
927
+ else if (providerEvent.type === "step_finished") {
928
+ await flushCompletedTools();
929
+ return;
491
930
  }
492
- progressEvents.push(event);
493
- await emitProgressEvent({
494
- progressEvent: event,
495
- state,
496
- sink,
497
- websocketSink,
498
- onProgress: options.onProgress,
499
- onExternalEvent: options.onExternalEvent,
500
- });
501
- }
502
- });
931
+ else if (providerEvent.type === "done") {
932
+ observedDoneText = providerEvent.text ?? observedDoneText;
933
+ observedUsage = accumulateUsage(observedUsage, providerEvent.usage);
934
+ observedModel = providerEvent.model ?? observedModel;
935
+ await flushCompletedTools();
936
+ return;
937
+ }
938
+ else if (providerEvent.type === "error") {
939
+ observedError = providerEvent.message;
940
+ observedUsage = accumulateUsage(observedUsage, providerEvent.usage);
941
+ observedModel = providerEvent.model ?? observedModel;
942
+ }
943
+ else if (providerEvent.type === "title") {
944
+ observedTitle = providerEvent.title;
945
+ }
946
+ for (const event of normalizeProviderEvent(config.sessionId, config.messageId, eventForProgress)) {
947
+ await emitProgress(event);
948
+ }
949
+ });
950
+ }
951
+ catch (error) {
952
+ if (error instanceof UniSessionError)
953
+ throw error;
954
+ providerResult = { error: error instanceof Error ? error.message : String(error) };
955
+ }
503
956
  if (providerResult.sessionId) {
504
- await setPersistenceSessionId(providerResult.sessionId);
505
- state.sessionId = providerResult.sessionId;
957
+ await bindProviderSession(providerResult.sessionId);
958
+ observedProviderSessionIds.add(providerResult.sessionId);
959
+ }
960
+ const filterTail = streamFilter?.finish() ?? "";
961
+ if (filterTail) {
962
+ await flushCompletedTools();
963
+ turn.addText(filterTail);
964
+ for (const event of normalizeProviderEvent(config.sessionId, config.messageId, {
965
+ type: "text_delta",
966
+ text: filterTail,
967
+ }))
968
+ await emitProgress(event);
969
+ }
970
+ const hasExplicitTerminalText = providerResult.text !== undefined || observedDoneText !== undefined;
971
+ const rawProviderFinalText = providerResult.text ?? observedDoneText ?? rawProviderText;
972
+ const providerFinalText = aligned
973
+ ? redactKnownProviderIds(rawProviderFinalText, observedProviderSessionIds)
974
+ : rawProviderFinalText;
975
+ const parsedArtifacts = aligned
976
+ ? parseAndStripArtifacts(providerFinalText, { workspaceDir })
977
+ : { cleanedText: providerFinalText, artifacts: [], warnings: [] };
978
+ const streamedArtifactParse = aligned
979
+ && rawProviderText !== providerFinalText
980
+ && rawProviderText.includes(ARTIFACTS_START_TAG)
981
+ ? parseAndStripArtifacts(rawProviderText, { workspaceDir })
982
+ : undefined;
983
+ artifactAccumulator?.addOwn(parsedArtifacts.artifacts.filter((artifact) => !isInternalArtifact(artifact, config.dataDir)));
984
+ artifactAccumulator?.addOwn((streamedArtifactParse?.artifacts ?? [])
985
+ .filter((artifact) => !isInternalArtifact(artifact, config.dataDir)));
986
+ for (const warning of new Set([
987
+ ...parsedArtifacts.warnings,
988
+ ...(streamedArtifactParse?.warnings ?? []),
989
+ ])) {
990
+ await emitProgress({
991
+ type: "diagnostic",
992
+ sessionId: config.sessionId,
993
+ messageId: config.messageId,
994
+ level: "warn",
995
+ scope: "runner",
996
+ phase: "artifact_declaration",
997
+ message: warning,
998
+ });
506
999
  }
507
- if (providerResult.text) {
508
- state.finalText = providerResult.text;
1000
+ if (rawProviderText && state.finalText !== parsedArtifacts.cleanedText) {
1001
+ await emitProgress({
1002
+ type: "diagnostic",
1003
+ sessionId: config.sessionId,
1004
+ messageId: config.messageId,
1005
+ level: "warn",
1006
+ scope: "runner",
1007
+ phase: "provider_text_mismatch",
1008
+ message: "Provider terminal text differed from streamed text; the terminal result is authoritative",
1009
+ });
1010
+ }
1011
+ const usage = providerResult.usage ?? observedUsage;
1012
+ const rawError = providerResult.error ?? observedError;
1013
+ const error = rawError
1014
+ ? redactKnownProviderIds(rawError, observedProviderSessionIds)
1015
+ : undefined;
1016
+ const durationMs = Date.now() - startedAt;
1017
+ const artifacts = artifactAccumulator?.dump() ?? [];
1018
+ state.finalText = parsedArtifacts.cleanedText;
1019
+ if (!usage && aligned) {
1020
+ await emitProgress({
1021
+ type: "diagnostic",
1022
+ sessionId: config.sessionId,
1023
+ messageId: config.messageId,
1024
+ level: "warn",
1025
+ scope: "runner",
1026
+ phase: "provider_usage_unavailable",
1027
+ message: `${config.runtimeAgent} did not expose token usage; transport compatibility uses zero without persisting it`,
1028
+ });
509
1029
  }
510
- if (providerResult.title) {
511
- const event = {
1030
+ const finalEntries = turn.finalize({
1031
+ fallbackContent: parsedArtifacts.cleanedText,
1032
+ forceFallbackContent: hasExplicitTerminalText,
1033
+ usage,
1034
+ durationMs,
1035
+ error,
1036
+ artifacts: artifacts.length > 0 ? artifacts : undefined,
1037
+ });
1038
+ await appendTurnEntries(finalEntries);
1039
+ if (usage && contextStore) {
1040
+ const rawModel = providerResult.model
1041
+ ?? observedModel
1042
+ ?? configuredProviderModel(config)
1043
+ ?? `runtime:${config.runtimeAgent}`;
1044
+ await contextStore.recordModelUsage({
1045
+ model: aligned
1046
+ ? redactKnownProviderIds(rawModel, observedProviderSessionIds)
1047
+ : rawModel,
1048
+ usage,
1049
+ coveredTranscriptEntryCount,
1050
+ }).catch((contextError) => {
1051
+ uniLogger.warn("failed to update model usage context", { error: String(contextError) });
1052
+ });
1053
+ }
1054
+ const rawFinalTitle = providerResult.title ?? observedTitle;
1055
+ const finalTitle = rawFinalTitle && aligned
1056
+ ? redactKnownProviderIds(rawFinalTitle, observedProviderSessionIds)
1057
+ : rawFinalTitle;
1058
+ await updateUniSessionMetadata(config.dataDir, config.sessionId, {
1059
+ ...(finalTitle ? { title: finalTitle } : {}),
1060
+ }, config.sessionStore);
1061
+ if (finalTitle) {
1062
+ await emitProgress({
512
1063
  type: "title_updated",
513
- sessionId: state.sessionId,
514
- messageId: state.messageId,
515
- title: providerResult.title,
516
- };
517
- progressEvents.push(event);
518
- await emitProgressEvent({
519
- progressEvent: event,
520
- state,
521
- sink,
522
- websocketSink,
523
- onProgress: options.onProgress,
524
- onExternalEvent: options.onExternalEvent,
1064
+ sessionId: config.sessionId,
1065
+ messageId: config.messageId,
1066
+ title: finalTitle,
525
1067
  });
526
1068
  }
527
- if (providerResult.error) {
1069
+ const resultPayload = {
1070
+ text: parsedArtifacts.cleanedText,
1071
+ usage: usage ?? { ...ZERO_USAGE },
1072
+ durationMs,
1073
+ ...(error ? { error } : {}),
1074
+ ...(aligned && artifacts.length > 0 ? { artifacts } : {}),
1075
+ };
1076
+ if (error) {
528
1077
  uniLogger.error("uni run failed", {
529
1078
  runtimeAgent: config.runtimeAgent,
530
- sessionId: state.sessionId,
531
- durationMs: Date.now() - startedAt,
532
- error: providerResult.error,
533
- });
534
- // Persist assistant error transcript entry
535
- await ensureFinalPersistenceSession();
536
- await flushPendingToolBatch();
537
- await appendTranscriptEntry({
538
- role: "assistant",
539
- content: state.finalText,
540
- timestamp: new Date().toISOString(),
541
- runtimeAgent: config.runtimeAgent,
542
- messageId: state.messageId,
543
- usage: providerResult.usage,
544
- durationMs: Date.now() - startedAt,
545
- error: providerResult.error,
1079
+ sessionId: config.sessionId,
1080
+ durationMs,
1081
+ error,
546
1082
  });
547
1083
  await emitExternalEvent(sink, {
548
1084
  type: "error",
549
- sessionId: state.sessionId,
550
- messageId: state.messageId,
551
- channel: state.channel,
552
- user: state.user,
553
- message: providerResult.error,
1085
+ sessionId: config.sessionId,
1086
+ messageId: config.messageId,
1087
+ channel: config.channel,
1088
+ user: config.user,
1089
+ message: error,
554
1090
  }, state, options.onExternalEvent);
555
- return {
1091
+ }
1092
+ else {
1093
+ uniLogger.info("uni run completed", {
556
1094
  runtimeAgent: config.runtimeAgent,
557
- sessionId: state.sessionId,
558
- text: state.finalText,
559
- usage: providerResult.usage ?? { input: 0, output: 0, total: 0 },
560
- durationMs: Date.now() - startedAt,
561
- error: providerResult.error,
562
- progressEvents,
563
- externalEvents: state.externalEvents,
564
- };
1095
+ sessionId: config.sessionId,
1096
+ durationMs,
1097
+ ...(usage ? { inputTokens: usage.input, outputTokens: usage.output } : {}),
1098
+ });
1099
+ await emitExternalEvent(sink, {
1100
+ type: "done",
1101
+ sessionId: config.sessionId,
1102
+ messageId: config.messageId,
1103
+ channel: config.channel,
1104
+ user: config.user,
1105
+ result: resultPayload,
1106
+ }, state, options.onExternalEvent);
565
1107
  }
566
- const result = {
567
- text: state.finalText,
568
- usage: providerResult.usage ?? { input: 0, output: 0, total: 0 },
569
- durationMs: Date.now() - startedAt,
570
- };
571
- uniLogger.info("uni run completed", {
572
- runtimeAgent: config.runtimeAgent,
573
- sessionId: state.sessionId,
574
- durationMs: result.durationMs,
575
- inputTokens: result.usage.input,
576
- outputTokens: result.usage.output,
577
- });
578
- // Persist assistant transcript entry
579
- await ensureFinalPersistenceSession();
580
- await appendPendingAssistantContentEntry(state.finalText, {
581
- usage: result.usage,
582
- durationMs: result.durationMs,
583
- });
584
- await emitExternalEvent(sink, {
585
- type: "done",
586
- sessionId: state.sessionId,
587
- messageId: state.messageId,
588
- channel: state.channel,
589
- user: state.user,
590
- result,
591
- }, state, options.onExternalEvent);
592
1108
  return {
593
1109
  runtimeAgent: config.runtimeAgent,
594
- sessionId: state.sessionId,
595
- ...result,
1110
+ sessionId: config.sessionId,
1111
+ ...resultPayload,
596
1112
  progressEvents,
597
1113
  externalEvents: state.externalEvents,
598
1114
  };