@jskit-ai/assistant-core 0.1.208 → 0.1.211

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.
@@ -14,6 +14,43 @@ import { validateConversationConfiguration, validateConnectionModel } from "../c
14
14
  const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
15
  const nativeMessageId = id => `msg_jskit_${hash(id).slice(0, 40)}`;
16
16
 
17
+ // Original standalone native-use validation and authored admission, shared by
18
+ // explicitly supplied bound tools without transferring native process ownership.
19
+ async function executeOpenCodeApplicationTool({ active, turn, target, sessionId, assertCurrent }, input, signal) {
20
+ if (!active?.tools || input.sessionId !== sessionId ||
21
+ [input.id, input.name, input.messageId].some(value => typeof value !== "string" || !value || value.length > 256)) {
22
+ throw new Error("This OpenCode tool call does not belong to the active conversation.");
23
+ }
24
+ if (!turn?.abortController) {
25
+ throw new Error("This OpenCode tool call does not belong to an admitted conversation.");
26
+ }
27
+ assertCurrent?.();
28
+ const observed = AbortSignal.any([signal, turn.abortController.signal]);
29
+ await turn.admission?.promise;
30
+ observed.throwIfAborted();
31
+ const messages = await target.server.client.messages(sessionId, { limit: 100 }, { signal: observed });
32
+ let message;
33
+ let owner;
34
+ for (const [nativeId, authored] of active.messageIds) {
35
+ message = openCodeRowsForInput(messages, nativeId).find(message => message.id === input.messageId && message.type === "assistant");
36
+ if (message) { owner = authored; break; }
37
+ }
38
+ const tool = message?.content.find(part => part.type === "tool" && part.callID === input.id);
39
+ if (!tool || tool.tool !== input.name || !isDeepStrictEqual(tool.state?.input, input.input)) {
40
+ throw new Error("OpenCode's application call does not match an admitted native tool use.");
41
+ }
42
+ if (!owner.committed) throw new Error("This OpenCode tool call has no durable authored admission.");
43
+ await owner.committed;
44
+ observed.throwIfAborted();
45
+ assertCurrent?.(true, owner);
46
+ try {
47
+ return await active.tools.execute({ id: input.id, name: input.name, arguments: JSON.stringify(input.input) }, { signal: observed, messageId: owner.messageId });
48
+ } catch (error) {
49
+ if (!assertCurrent || assertCurrent(false, owner)) turn.abortController.abort(error);
50
+ throw error;
51
+ }
52
+ }
53
+
17
54
  /** Uses the application's authorized API connection; native state stays server-side. */
18
55
  export function createOpenCodeConversationDriver({ connections, host = {}, limits = {} } = {}) {
19
56
  if (!host.conversation && typeof connections?.resolve !== "function") throw new TypeError("OpenCode conversations require an authorized AI connection resolver.");
@@ -167,10 +204,12 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
167
204
  let current;
168
205
  let disposed = false;
169
206
  let cleanupFailure;
207
+ let toolBridge;
170
208
  async function interrupt(context) {
171
209
  try {
172
210
  const acquired = await supplied.acquire(context || current?.context);
173
211
  const value = await supplied.owner.interruptPreparedTurn(await supplied.preparation.interruption(acquired.options));
212
+ if (toolBridge) { await toolBridge.close(); toolBridge = null; }
174
213
  if (cleanupFailure) {
175
214
  await reportFailure(cleanupFailure, { recovered: true });
176
215
  cleanupFailure = null;
@@ -184,16 +223,61 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
184
223
  throw error;
185
224
  }
186
225
  }
187
- async function send({ input, context, signal, beforeDispatch, accept }) {
226
+ async function send(command) {
227
+ const { input, context, signal, beforeDispatch, accept } = command;
188
228
  signal.throwIfAborted();
189
229
  const acquired = await supplied.acquire(context);
190
- const prepared = await supplied.preparation.message({ ...input.nativeMessage, onPromptSending: beforeDispatch }, acquired.options);
191
- const value = await supplied.owner.sendPreparedMessage(prepared, acquired.options);
230
+ const active = current;
231
+ if (active?.tools) signal.throwIfAborted();
232
+ if (active?.tools && !toolBridge) {
233
+ toolBridge = await createOpenCodeToolBridge({ schemas: active.tools.schemas,
234
+ maxArgumentBytes: active.tools.maximumArgumentBytes, execute(input, signal) {
235
+ const turn = supplied.owner.turns.get(acquired.key);
236
+ const target = supplied.owner.processes.get(acquired.key);
237
+ return executeOpenCodeApplicationTool({ active: current, turn, target,
238
+ sessionId: target?.upstreamSessionId,
239
+ assertCurrent(throwFailure = true, authored) {
240
+ const owned = !disposed && current === active && active.turn === turn && turn?.active &&
241
+ (!authored || active.messageIds.get(turn.inputMessageId) === authored) &&
242
+ supplied.owner.turns.get(acquired.key) === turn && supplied.owner.processes.get(acquired.key) === target;
243
+ if (!owned && throwFailure) throw new Error("This OpenCode tool call belongs to a retired native turn.");
244
+ return owned;
245
+ }
246
+ }, input, signal);
247
+ }
248
+ });
249
+ }
250
+ const options = active?.tools ? { ...acquired.options, applicationTools: toolBridge.configuration } : acquired.options;
251
+ let prepared;
252
+ const dispatch = active?.tools ? async identity => {
253
+ await beforeDispatch(identity);
254
+ signal.throwIfAborted();
255
+ active.turn = supplied.owner.turns.get(acquired.key);
256
+ active.messageIds.set(prepared.input.id, { messageId: input.messageId, committed: command.deliveryCommitted });
257
+ } : beforeDispatch;
258
+ prepared = await supplied.preparation.message({ ...input.nativeMessage, onPromptSending: dispatch }, options);
259
+ if (active?.tools && prepared.application?.monitor) {
260
+ const monitor = prepared.application.monitor;
261
+ const create = monitor.create;
262
+ prepared.application = { ...prepared.application, monitor: { ...monitor,
263
+ create(...args) {
264
+ const projection = create(...args);
265
+ const finalResponse = projection.finalResponse;
266
+ return { ...projection, finalResponse: { ...finalResponse,
267
+ async beforeRecovery(completion) {
268
+ active.messageIds.set(finalResponse.recoveryMessageId, active.messageIds.get(completion.inputMessageId));
269
+ await finalResponse.beforeRecovery?.(completion);
270
+ }
271
+ } };
272
+ }
273
+ } };
274
+ }
275
+ const value = await supplied.owner.sendPreparedMessage(prepared, options);
192
276
  const delivered = { value, completion: value.conversationTurn ? supplied.owner.monitors.get(acquired.key) : null };
193
277
  if (value.delivered === true && value.conversationTurn) {
194
278
  if (current) current.delivery = delivered;
195
279
  await accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
196
- }
280
+ } else if (active?.tools) active.messageIds.delete(prepared.input.id);
197
281
  return delivered;
198
282
  }
199
283
  return Object.freeze({
@@ -205,7 +289,8 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
205
289
  },
206
290
  async run(command) {
207
291
  if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.");
208
- const active = { context: command.context, failure: Promise.withResolvers() };
292
+ const active = { context: command.context, failure: Promise.withResolvers(),
293
+ ...(command.tools ? { tools: command.tools, messageIds: new Map() } : {}) };
209
294
  let failure;
210
295
  active.failure.promise.catch(error => { failure = error; });
211
296
  current = active;
@@ -248,6 +333,7 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
248
333
  } finally {
249
334
  command.signal.removeEventListener("abort", abort);
250
335
  await cancellation?.catch(() => {});
336
+ if (toolBridge) { await toolBridge.close(); toolBridge = null; }
251
337
  if (current === active) current = null;
252
338
  }
253
339
  },
@@ -260,6 +346,7 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
260
346
  try {
261
347
  const result = await disposeNative({ native: supplied, options });
262
348
  if (result?.ok === false) throw Object.assign(new Error(result.error || "OpenCode cleanup could not be confirmed."), result);
349
+ if (toolBridge) { await toolBridge.close(); toolBridge = null; }
263
350
  disposed = true;
264
351
  return result;
265
352
  } catch (error) {
@@ -388,38 +475,8 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
388
475
  toolBridge = await createOpenCodeToolBridge({
389
476
  schemas: current.tools.schemas, maxArgumentBytes: current.tools.maximumArgumentBytes,
390
477
  async execute(input, signal) {
391
- const active = current;
392
- if (!active?.tools || input.sessionId !== binding.sessionId ||
393
- [input.id, input.name, input.messageId].some(value => typeof value !== "string" || !value || value.length > 256)) {
394
- throw new Error("This OpenCode tool call does not belong to the active conversation.");
395
- }
396
- const turn = facilities.runtime.turns.get(binding.directory);
397
- if (!turn?.abortController) {
398
- throw new Error("This OpenCode tool call does not belong to an admitted conversation.");
399
- }
400
- const observed = AbortSignal.any([signal, turn.abortController.signal]);
401
- await turn.admission?.promise;
402
- observed.throwIfAborted();
403
- const messages = await native.server.client.messages(binding.sessionId, { limit: 100 }, { signal: observed });
404
- let message;
405
- let owner;
406
- for (const [nativeId, authored] of active.messageIds) {
407
- message = openCodeRowsForInput(messages, nativeId).find(message => message.id === input.messageId && message.type === "assistant");
408
- if (message) { owner = authored; break; }
409
- }
410
- const tool = message?.content.find(part => part.type === "tool" && part.callID === input.id);
411
- if (!tool || tool.tool !== input.name || !isDeepStrictEqual(tool.state?.input, input.input)) {
412
- throw new Error("OpenCode's application call does not match an admitted native tool use.");
413
- }
414
- if (!owner.committed) throw new Error("This OpenCode tool call has no durable authored admission.");
415
- await owner.committed;
416
- observed.throwIfAborted();
417
- try {
418
- return await active.tools.execute({ id: input.id, name: input.name, arguments: JSON.stringify(input.input) }, { signal: observed, messageId: owner.messageId });
419
- } catch (error) {
420
- turn.abortController.abort(error);
421
- throw error;
422
- }
478
+ return executeOpenCodeApplicationTool({ active: current,
479
+ turn: facilities.runtime.turns.get(binding.directory), target: native, sessionId: binding.sessionId }, input, signal);
423
480
  }
424
481
  });
425
482
  }
@@ -50,6 +50,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
50
50
  if (!Number.isSafeInteger(maximumToolCalls) || maximumToolCalls < 1) throw new TypeError("Invalid application tool-call limit.");
51
51
  const maximumInput = limits.maxInputCharacters ?? 32_000;
52
52
  if (!Number.isSafeInteger(maximumInput) || maximumInput < 1) throw new TypeError("Invalid conversation input limit.");
53
+ const maximumFinalReply = limits.maxFinalReplyCharacters ?? Infinity;
54
+ if (limits.maxFinalReplyCharacters !== undefined && (!Number.isSafeInteger(maximumFinalReply) || maximumFinalReply < 1)) {
55
+ throw new TypeError("Invalid conversation final reply limit.");
56
+ }
53
57
  const maximumContinuity = limits.maxContinuityCharacters ?? 128_000;
54
58
  if (!Number.isSafeInteger(maximumContinuity) || maximumContinuity < 1) throw new TypeError("Invalid conversation continuity limit.");
55
59
  const maximumAttachmentBytes = limits.maxAttachmentBytes ?? 8 * 1024 * 1024;
@@ -371,6 +375,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
371
375
 
372
376
  async function initialize(entry, configuration, requestedEngine) {
373
377
  if (entry.conversation) {
378
+ entry.applicationTools = boundApplicationTools(entry.conversation);
374
379
  entry.engine = entry.conversation.engine;
375
380
  if (requestedEngine && requestedEngine !== entry.engine) throw failure("The selected native engine changed.", "conversation_engine_mismatch");
376
381
  entry.driver = createDriver(entry.engine, entry.host);
@@ -434,6 +439,15 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
434
439
  entry.provider = await openProvider(entry, entry.driver, binding, entry.segmentId);
435
440
  }
436
441
 
442
+ function boundApplicationTools(conversation) {
443
+ const facilities = conversation.applicationTools;
444
+ if (facilities === undefined) return null;
445
+ if (!catalog || conversation.commands || conversation.native?.scoped || typeof facilities?.prepareContext !== "function") {
446
+ throw new TypeError("Bound application tools require a catalogue, an admitted conversation and a server-owned context mapper.");
447
+ }
448
+ return { storage: createReentrantConversationStorage(facilities.storage), prepareContext: facilities.prepareContext };
449
+ }
450
+
437
451
  async function perform(entry, active, configuration) {
438
452
  const { driver, engine, segmentId } = entry;
439
453
  const initial = active.request;
@@ -445,15 +459,39 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
445
459
  const rejectionSaves = new Map();
446
460
  let toolWork = Promise.resolve();
447
461
  let toolFailure;
462
+ const applicationTools = entry.applicationTools;
463
+ const toolStorage = applicationTools?.storage || storage;
464
+
465
+ async function saveTools(transaction, request, calls) {
466
+ if (entry.conversation) {
467
+ const saved = await transaction.readTurn(request.turnId);
468
+ if (submittedMessage(saved || {})?.messageId !== request.input.messageId) {
469
+ throw new Error("The application tool receipt does not belong to this authored request.");
470
+ }
471
+ }
472
+ await transaction.updateTurnMetadata(request.turnId, { applicationTools: calls });
473
+ }
448
474
 
449
475
  function prepareTools(request) {
450
- if (!catalog || entry.conversation) return;
476
+ if (!catalog || entry.conversation && !applicationTools) return;
451
477
  request.tools = createConversationTools({ catalog, context: request.context, signal: active.controller.signal,
478
+ ...(applicationTools ? { prepareContext() {
479
+ if (!request.accepted) throw new Error("The application tool does not belong to an admitted message.");
480
+ return applicationTools.prepareContext(request.context, Object.freeze({ conversationId: entry.id,
481
+ turnId: request.turnId, messageId: request.input.messageId, nativeTurnId: request.nativeTurnId,
482
+ nativeThreadId: request.nativeThreadId, origin: request.input.origin,
483
+ assertCurrent() {
484
+ active.controller.signal.throwIfAborted();
485
+ if (entry.active !== active || current !== request || !request.accepted || request.finished) {
486
+ throw failure("This application tool's admitted message is no longer current.", "conversation_tool_request_retired");
487
+ }
488
+ } }));
489
+ } } : {}),
452
490
  maximumCalls: maximumToolCalls, discoveryOnly: driver.toolDiscovery === true,
453
491
  authorize: () => access(request.context, entry.id, "tool", entry),
454
492
  save: calls => {
455
493
  if (!request.accepted) throw new Error("The engine requested an application tool before admitting the message.");
456
- return storage.write(entry.id, transaction => transaction.updateTurnMetadata(request.turnId, { applicationTools: calls }));
494
+ return toolStorage.write(entry.id, transaction => saveTools(transaction, request, calls));
457
495
  },
458
496
  emit: event => emit(entry, { ...event, turnId: request.turnId })
459
497
  });
@@ -612,6 +650,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
612
650
  await setPhase(entry, "working", request.turnId);
613
651
  if (request.sealed) return;
614
652
  const messageId = `${request.turnId}:${message.id}`;
653
+ if (message.role === "assistant" && message.complete && message.text.length > maximumFinalReply) {
654
+ if (request.answer?.messageId === messageId) request.answer = null;
655
+ throw new Error("The assistant exceeded the configured final reply limit.");
656
+ }
615
657
  const previous = request.answer?.messageId === messageId ? request.answer : request.progress.get(messageId);
616
658
  const outputOwner = message.outputId && messageOwners.get(message.outputId) || request;
617
659
  const outputId = previous?.outputId || (message.outputId ? `${outputOwner.turnId}:${message.outputId}` : "");
@@ -715,6 +757,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
715
757
  async accept(native) {
716
758
  if (entry.conversation) nativeResult = native.nativeResult;
717
759
  if (native?.nativeTurnId) active.nativeTurnId = native.nativeTurnId;
760
+ request.nativeThreadId = threadId;
718
761
  await admit(request, undefined, { nativeDelivery: true, conversationTurn: native?.conversationTurn });
719
762
  admitted.resolve(entry.conversation ? nativeResult.value
720
763
  : { ok: true, delivered: true, messageId: request.input.messageId, threadId });
@@ -970,8 +1013,13 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
970
1013
  : status === "cancelled" ? "Work stopped." : problem?.message || active.steerError || initial.recovered?.error || "");
971
1014
  entry.saveProgress = async () => {
972
1015
  if (entry.conversation) {
973
- // Native output, checkpoints and receipt durability are already owned
974
- // by the original run owner and sender; do not annotate old rows.
1016
+ if (applicationTools) await toolStorage.write(entry.id, async transaction => {
1017
+ for (const request of active.requests.values()) {
1018
+ if (request.accepted && request.tools?.records().length) await saveTools(transaction, request, request.tools.records());
1019
+ }
1020
+ });
1021
+ // Only opted-in application receipts are saved above. Native output,
1022
+ // checkpoints and status remain with the original run owner and sender.
975
1023
  entry.error = error;
976
1024
  entry.storageFailure = null;
977
1025
  entry.saveProgress = null;
@@ -1342,6 +1390,13 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1342
1390
  }
1343
1391
  const metadata = { runtime: await readState(entry) };
1344
1392
  if (metadata.runtime.replacement) throw failure("Finish the native replacement before recovering output.", "conversation_replacement_pending");
1393
+ const savedRequests = [...metadata.runtime.predecessors, metadata.runtime].filter(segment => segment.request?.messageId === messageId);
1394
+ if (!existing && savedRequests.length && savedRequests.every(segment =>
1395
+ ["codex", "claude", "opencode"].includes(segment.engine) && segment.request.attempted === false && !segment.request.inspectionOnly &&
1396
+ isDeepStrictEqual(segment.request, savedRequests[0].request))) {
1397
+ await access(context, entry.id, "inspectDelivery", entry);
1398
+ return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." };
1399
+ }
1345
1400
  const request = existing ? submittedMessage(existing) : metadata.runtime.request;
1346
1401
  if (!request || request.messageId !== messageId || !entry.provider.inspectAdmission) return receipt || { status: "unknown", messageId };
1347
1402
  const pending = metadata.runtime.request?.messageId === messageId ? metadata.runtime.request : null;
@@ -1415,13 +1470,17 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1415
1470
  }
1416
1471
  const nativeReplies = (native.messages || []).filter(message => message.role === "assistant");
1417
1472
  const outputId = nativeReplies.length === 1 && nativeReplies[0].outputId;
1418
- if (replies.length) await transaction.replaceAssistant(turn.turnId, { messageId: turn.assistant?.messageId || `${turn.turnId}:assistant`,
1473
+ const replyText = replies.join("\n\n");
1474
+ const oversizedReply = replyText.length > maximumFinalReply;
1475
+ if (replies.length && !oversizedReply) await transaction.replaceAssistant(turn.turnId, { messageId: turn.assistant?.messageId || `${turn.turnId}:assistant`,
1419
1476
  ...(!turn.assistant?.outputId && outputId ? { outputId: `${turn.turnId}:${outputId}` } : {}),
1420
- role: "assistant", text: replies.join("\n\n"), at: submittedMessage(turn).at });
1477
+ role: "assistant", text: replyText, at: submittedMessage(turn).at });
1421
1478
  const unknownTool = turn.metadata.applicationTools?.some(call => !call.result || call.status === "unknown");
1422
- const status = ["cancelled", "failed"].includes(runtime.status) ? runtime.status : unknownTool ? "interrupted" : native.status || "interrupted";
1479
+ const retainedFailure = ["cancelled", "failed"].includes(runtime.status);
1480
+ const status = retainedFailure ? runtime.status : oversizedReply ? "failed" : unknownTool ? "interrupted" : native.status || "interrupted";
1423
1481
  const { error: previousError, ...saved } = runtime;
1424
1482
  const error = unknownTool ? "Native output was recovered, but an application tool has no verified result. Inspect its target before requesting another execution."
1483
+ : oversizedReply ? retainedFailure && (previousError || native.error) || "The assistant exceeded the configured final reply limit."
1425
1484
  : status === "complete" ? "" : previousError || native.error || "Delivery was confirmed from native history. Completion was not confirmed; this request was not replayed.";
1426
1485
  await transaction.updateTurnMetadata(turn.turnId, { runtime: { ...saved, status,
1427
1486
  ...(request.goal || goalRequest ? { goalMessageId: messageId } : {}),
@@ -1531,6 +1590,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1531
1590
  }
1532
1591
  const next = await entry.host.conversation({ id: entry.id, context });
1533
1592
  entry.conversation = next;
1593
+ entry.applicationTools = boundApplicationTools(next);
1534
1594
  entry.engine = next.engine;
1535
1595
  entry.driver = createDriver(entry.engine, entry.host);
1536
1596
  const current = await readState(entry);
@@ -3,7 +3,7 @@ import { isDeepStrictEqual } from "node:util";
3
3
  const unknownOutcome = "The application operation did not return a verified result. Inspect its target before requesting another execution.";
4
4
 
5
5
  /** One turn's application calls. Native/API adapters only transport these calls. */
6
- export function createConversationTools({ catalog, context, signal: turnSignal, previousCalls = [], authorize, save, emit,
6
+ export function createConversationTools({ catalog, context, prepareContext, signal: turnSignal, previousCalls = [], authorize, save, emit,
7
7
  maximumCalls = 32, discoveryOnly = false, transient = false, propagateErrors = false }) {
8
8
  if (transient !== true && [authorize, save, emit].some(hook => typeof hook !== "function")) {
9
9
  throw new TypeError("Conversation tools require authorization, durable save and event facilities.");
@@ -11,6 +11,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
11
11
  if (transient === true && previousCalls.length) {
12
12
  throw new TypeError("Transient application tools cannot restore a previous execution.");
13
13
  }
14
+ if (prepareContext !== undefined && typeof prepareContext !== "function") throw new TypeError("Application tool context requires a server-owned mapper.");
14
15
  const toolSet = catalog.resolveToolSet(context, { discoveryOnly });
15
16
  const calls = new Map(structuredClone(previousCalls).map(call => [call.id, call]));
16
17
  let pending = Promise.resolve();
@@ -27,6 +28,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
27
28
 
28
29
  async function run({ id, name, arguments: argumentsText }, signal) {
29
30
  await authorize?.();
31
+ let executionContext = prepareContext ? await prepareContext() : context;
30
32
  signal.throwIfAborted();
31
33
  if (typeof id !== "string" || !id || id.length > 256 || typeof name !== "string" || !name || name.length > 256 ||
32
34
  typeof argumentsText !== "string") throw new TypeError("An application tool requires its call id, name and JSON arguments.");
@@ -52,6 +54,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
52
54
  await emit?.({ type: "tool", call: structuredClone(call) });
53
55
  try {
54
56
  await authorize?.();
57
+ if (prepareContext) executionContext = await prepareContext();
55
58
  signal.throwIfAborted();
56
59
  } catch (error) {
57
60
  call.status = "not-executed";
@@ -61,7 +64,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
61
64
  }
62
65
  let failed = false;
63
66
  let failure;
64
- call.result = await catalog.executeToolCall({ toolName: name, argumentsText, toolSet, context: { ...context, signal },
67
+ call.result = await catalog.executeToolCall({ toolName: name, argumentsText, toolSet, context: { ...executionContext, signal },
65
68
  ...(propagateErrors ? { onFailure(error) { failed = true; failure = error; } } : {}) });
66
69
  call.status = !call.result.ok && call.result.error?.status >= 500 ? "unknown" : "complete";
67
70
  // Keep the result in memory as well: retrySave can persist it after a failed
@@ -44,7 +44,7 @@ export function createConversationTranscript({ storage, clock = () => new Date()
44
44
  return ids.length ? transaction.readTurn(ids.at(-1)) : null;
45
45
  }
46
46
 
47
- async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], turnMetadata = null, requireOpenTurn = false, outputId = "" } = {}) {
47
+ async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], data, turnMetadata = null, requireOpenTurn = false, outputId = "" } = {}) {
48
48
  const messageText = normalizeText(text);
49
49
  const id = normalizeText(messageId);
50
50
  if (!messageText && !(role === "user" && Array.isArray(attachments) && attachments.length)) return null;
@@ -61,7 +61,7 @@ export function createConversationTranscript({ storage, clock = () => new Date()
61
61
  await transaction.appendMessage(turnId, {
62
62
  role, text: messageText, messageId: id, at: createdAt.toISOString(),
63
63
  ...(["assistant", "commentary", "thinking"].includes(role) && normalizeText(outputId) ? { outputId: normalizeText(outputId) } : {}),
64
- ...(role === "user" ? { attachments, turnMetadata } : {})
64
+ ...(role === "user" ? { attachments, turnMetadata, ...(data !== undefined ? { data: structuredClone(data) } : {}) } : {})
65
65
  });
66
66
  return transaction.readTurn(turnId);
67
67
  });
@@ -9,6 +9,7 @@ export {
9
9
  ensureOpenCodeSession,
10
10
  limitOpenCodeModelOutput,
11
11
  openCodeApplicationToolSchemas,
12
+ openCodeConversationAgent,
12
13
  openCodeConversationAgents,
13
14
  openCodeEnvironmentForDirectory,
14
15
  openCodeEnvironmentForSession,
@@ -1277,3 +1277,52 @@ for (const change of ["model", "effort"]) test(`Claude honors a consumer's fresh
1277
1277
  assert.match(restoredPrompt, /Continue after the change/);
1278
1278
  assert.match(restoredPrompt, /Continue after restart/);
1279
1279
  });
1280
+
1281
+ for (const maxFinalReplyCharacters of [12, 11]) {
1282
+ test("Claude recovered reply enforces configured final limit of " + maxFinalReplyCharacters, async t => {
1283
+ const f = await fixture(t, { limits: { maxFinalReplyCharacters } });
1284
+ await assert.rejects(f.conversation.send({ messageId: "bounded-lost", text: "lost" }));
1285
+ assert.equal((await f.conversation.read()).status, "unconfirmed");
1286
+ const receipt = await f.conversation.inspectDelivery({ messageId: "bounded-lost" });
1287
+ assert.equal(receipt.status, "accepted", "A reply rejection must retain the proven user admission");
1288
+ assert.equal(receipt.recovered, true);
1289
+ const state = await f.conversation.read();
1290
+ const turn = state.conversationLog[0];
1291
+ assert.equal(turn.user.text, "lost");
1292
+ assert.equal(turn.metadata.runtime.status, maxFinalReplyCharacters === 12 ? "interrupted" : "failed");
1293
+ assert.equal(turn.assistant?.text, maxFinalReplyCharacters === 12 ? "Answer: lost" : undefined);
1294
+ if (maxFinalReplyCharacters === 11) assert.match(state.error, /final reply limit/);
1295
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1, "Recovery cannot repeat inference");
1296
+ });
1297
+ }
1298
+
1299
+ for (const savedStatus of ["cancelled", "failed", "unknown-tool", "interrupted"]) {
1300
+ test("Claude oversized recovery preserves " + savedStatus + " evidence", async t => {
1301
+ const limits = { maxFinalReplyCharacters: 13 };
1302
+ const f = await fixture(t, { limits });
1303
+ await f.conversation.send(input);
1304
+ assert.equal((await f.conversation.wait()).conversationLog[0].assistant.text, "Answer: Hello");
1305
+ await f.first.close();
1306
+ await f.storage.write("conversation", async tx => {
1307
+ const turn = await tx.readTurn("000001");
1308
+ await tx.replaceAssistant(turn.turnId, { ...turn.assistant, text: "" });
1309
+ await tx.updateTurnMetadata(turn.turnId, { runtime: { ...turn.metadata.runtime,
1310
+ status: savedStatus === "unknown-tool" ? "interrupted" : savedStatus,
1311
+ error: savedStatus === "unknown-tool" ? "" : "Exact prior " + savedStatus + " cause." },
1312
+ ...(savedStatus === "unknown-tool" ? { applicationTools: [{ id: "unresolved-tool", status: "unknown" }] } : {}) });
1313
+ });
1314
+ limits.maxFinalReplyCharacters = 11;
1315
+ const reopened = await f.runtime().open({ id: "conversation" });
1316
+ const receipt = await reopened.inspectDelivery({ messageId: input.messageId });
1317
+ assert.equal(receipt.status, "accepted");
1318
+ const state = await reopened.read();
1319
+ assert.equal(state.conversationLog[0].metadata.runtime.status, ["unknown-tool", "interrupted"].includes(savedStatus) ? "failed" : savedStatus);
1320
+ assert.notEqual(state.conversationLog[0].assistant?.text, "Answer: Hello");
1321
+ if (savedStatus === "unknown-tool") {
1322
+ assert.match(state.error, /application tool has no verified result.*Inspect its target/);
1323
+ assert.deepEqual(state.conversationLog[0].metadata.applicationTools, [{ id: "unresolved-tool", status: "unknown" }]);
1324
+ } else if (savedStatus === "interrupted") assert.match(state.error, /final reply limit/);
1325
+ else assert.equal(state.error, "Exact prior " + savedStatus + " cause.");
1326
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
1327
+ });
1328
+ }