@jskit-ai/assistant-core 0.1.221 → 0.1.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/conversation/package.json +5 -5
- package/package.json +5 -5
- package/src/server/conversation/claudeTurn.js +9 -4
- package/src/server/conversation/codexMessageCommands.js +2 -0
- package/src/server/conversation/codexMessageDelivery.js +2 -0
- package/src/server/conversation/deliveryTiming.js +23 -0
- package/src/server/conversation/providers/codexDriver.js +9 -5
- package/src/server/conversation/providers/opencodeDriver.js +11 -0
- package/src/server/conversation/runtime.js +49 -18
- package/src/server/conversation/transcript.js +24 -10
- package/src/shared/conversation/pagination.js +93 -2
- package/src/shared/conversation/patches.js +7 -4
- package/test/claudeTurn.test.js +21 -2
- package/test/codexConversationSession.test.js +13 -0
- package/test/codexTurn.test.js +5 -0
- package/test/conversation.test.js +62 -0
- package/test/conversationDeliveryTiming.test.js +50 -0
- package/test/opencodeConversationSession.test.js +14 -0
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.223",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.242",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.244",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.1.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.61",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.61"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.223",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
48
48
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
49
49
|
"@ai-sdk/xai": "^4.0.58",
|
|
50
|
-
"@jskit-ai/resource-core": "0.1.
|
|
51
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
50
|
+
"@jskit-ai/resource-core": "0.1.186",
|
|
51
|
+
"@jskit-ai/resource-crud-core": "0.1.186",
|
|
52
52
|
"@mdi/js": "^7.4.47",
|
|
53
53
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
54
54
|
"ai": "^7.0.101",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"ws": "^8.20.1"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
61
|
-
"@jskit-ai/kernel": "0.1.
|
|
60
|
+
"@jskit-ai/http-runtime": "0.1.242",
|
|
61
|
+
"@jskit-ai/kernel": "0.1.244",
|
|
62
62
|
"vue": "^3.5.13",
|
|
63
63
|
"vuetify": "^4.0.0"
|
|
64
64
|
},
|
|
@@ -449,6 +449,7 @@ export function createClaudeConversationOwner({
|
|
|
449
449
|
thread: { id: entry.id }, turn: snapshot(entry), workdir: context.workdir, conversationTurn };
|
|
450
450
|
}
|
|
451
451
|
async function accept() {
|
|
452
|
+
(command || input).onNativeReceipt?.();
|
|
452
453
|
// The new native ACK retires old effect custody while its canonical
|
|
453
454
|
// admission/commit is awaited; it cannot retag a delayed old failure.
|
|
454
455
|
if (applicationCommand) applicationCommand.active.nativeOwner = null;
|
|
@@ -465,6 +466,7 @@ export function createClaudeConversationOwner({
|
|
|
465
466
|
if (admission) conversationTurn = await admission;
|
|
466
467
|
}
|
|
467
468
|
if (!command) await entry.nativeTurn.updateState("active");
|
|
469
|
+
(command || input).onReceiptPersisted?.();
|
|
468
470
|
if (applicationCommand && conversationTurn) {
|
|
469
471
|
const value = deliveryResult();
|
|
470
472
|
const active = applicationCommand.active;
|
|
@@ -736,7 +738,8 @@ export function createClaudeConversationOwner({
|
|
|
736
738
|
entry.command = active;
|
|
737
739
|
entry.toolSchemas = active.tools?.schemas || [];
|
|
738
740
|
}
|
|
739
|
-
const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch
|
|
741
|
+
const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch,
|
|
742
|
+
onNativeReceipt: command.onNativeReceipt, onReceiptPersisted: command.onReceiptPersisted }, { applicationCommand });
|
|
740
743
|
const completion = value.conversationTurn ? entry.completion?.promise : null;
|
|
741
744
|
const delivered = { value, completion };
|
|
742
745
|
if (!applicationCommand && value.delivered === true && value.conversationTurn) {
|
|
@@ -766,9 +769,10 @@ export function createClaudeConversationOwner({
|
|
|
766
769
|
control.current = active;
|
|
767
770
|
const ready = Promise.withResolvers();
|
|
768
771
|
ready.promise.catch(() => {});
|
|
769
|
-
active.steer = async ({ input, beforeDispatch, accept }) => {
|
|
772
|
+
active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted }) => {
|
|
770
773
|
await ready.promise;
|
|
771
|
-
const nativeDispatch = { active, signal, beforeDispatch, accept,
|
|
774
|
+
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted,
|
|
775
|
+
timeoutMs, steering: true, interrupted: false, attempted: false, admitted: false };
|
|
772
776
|
try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
|
|
773
777
|
catch (error) {
|
|
774
778
|
if (nativeDispatch.attempted && !nativeDispatch.admitted) error.delivery = "uncertain";
|
|
@@ -776,7 +780,8 @@ export function createClaudeConversationOwner({
|
|
|
776
780
|
throw error;
|
|
777
781
|
}
|
|
778
782
|
};
|
|
779
|
-
const nativeDispatch = { active, signal, beforeDispatch, accept,
|
|
783
|
+
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt: command.onNativeReceipt,
|
|
784
|
+
onReceiptPersisted: command.onReceiptPersisted, timeoutMs, maximumOutput, attempted: false, admitted: false };
|
|
780
785
|
let entry;
|
|
781
786
|
let cancellation;
|
|
782
787
|
let problem;
|
|
@@ -499,6 +499,7 @@ export function createCodexMessageCommands({
|
|
|
499
499
|
if (!deliveredTurnId) {
|
|
500
500
|
throw new Error("Codex app-server accepted the prompt without returning a turn id.");
|
|
501
501
|
}
|
|
502
|
+
codexAppServerPendingUserMessages.get(`${promptDeliveryKey}\0${clientUserMessageId}`)?.onNativeReceipt?.();
|
|
502
503
|
await markCodexAppServerTurnActive(sessionId, {
|
|
503
504
|
requireTrackedTurn: true,
|
|
504
505
|
status: "inProgress",
|
|
@@ -624,6 +625,7 @@ export function createCodexMessageCommands({
|
|
|
624
625
|
turnId
|
|
625
626
|
} };
|
|
626
627
|
}
|
|
628
|
+
codexAppServerPendingUserMessages.get(`${namespace(sessionId)}\0${clientUserMessageId}`)?.onNativeReceipt?.();
|
|
627
629
|
const conversationTurn = await writeCodexAppServerDeliveredUserMessage(
|
|
628
630
|
runtime,
|
|
629
631
|
sessionId,
|
|
@@ -95,6 +95,7 @@ export function createCodexAppServerMessageDelivery({
|
|
|
95
95
|
if (!written) {
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
|
+
pendingMessage?.onReceiptPersisted?.();
|
|
98
99
|
await publish(normalizedSessionId, {
|
|
99
100
|
payload: {
|
|
100
101
|
conversationLogPatch: {
|
|
@@ -144,6 +145,7 @@ export function createCodexAppServerMessageDelivery({
|
|
|
144
145
|
Array.isArray(recoveredMessage?.displayAttachments) && recoveredMessage.displayAttachments.length
|
|
145
146
|
))) return;
|
|
146
147
|
if (ownership) {
|
|
148
|
+
pendingMessage?.onNativeReceipt?.();
|
|
147
149
|
// The provider's user-message receipt precedes its answer notifications.
|
|
148
150
|
// Persist the authored message here so answers cannot overtake it while
|
|
149
151
|
// the original HTTP request is still finishing startup bookkeeping.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const stages = new Set([
|
|
2
|
+
"received", "preparation-started", "provider-started", "runtime-ready", "dispatch-started",
|
|
3
|
+
"native-acknowledged", "receipt-persisted", "accepted", "first-response", "failed"
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
// Observer results are never awaited and failures cannot change delivery.
|
|
7
|
+
export function createConversationDeliveryTiming({ engine, conversationId, messageId, report, clock = () => performance.now() }) {
|
|
8
|
+
const startedAt = clock();
|
|
9
|
+
let previousAt = startedAt;
|
|
10
|
+
let previousStage = "";
|
|
11
|
+
const timings = {};
|
|
12
|
+
return stage => {
|
|
13
|
+
if (!report || !stages.has(stage) || Object.hasOwn(timings, stage)) return;
|
|
14
|
+
const at = clock();
|
|
15
|
+
timings[stage] = Math.max(0, at - startedAt);
|
|
16
|
+
const event = { engine, conversationId, messageId, stage, previousStage,
|
|
17
|
+
durationMs: Math.max(0, at - previousAt), elapsedMs: timings[stage], timings: { ...timings } };
|
|
18
|
+
previousAt = at;
|
|
19
|
+
previousStage = stage;
|
|
20
|
+
try { Promise.resolve(report(event)).catch(() => {}); }
|
|
21
|
+
catch { /* Instrumentation never changes admission or provider work. */ }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -700,11 +700,14 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
700
700
|
return saved;
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
-
async function withDelivery(
|
|
703
|
+
async function withDelivery(command, operation) {
|
|
704
|
+
const input = command.input;
|
|
704
705
|
return owner.withMessageDelivery(sessionId, input.messageId, {
|
|
705
706
|
attachments: input.attachments, text: input.authoredInput?.text ?? input.text,
|
|
706
707
|
turnMetadata: input.nativeMessage?.turnMetadata || null, actorContext: input.nativeMessage?.actorContext || null,
|
|
707
|
-
authoredInput: input.authoredInput
|
|
708
|
+
authoredInput: input.authoredInput,
|
|
709
|
+
onNativeReceipt: command.onNativeReceipt,
|
|
710
|
+
onReceiptPersisted: command.onReceiptPersisted
|
|
708
711
|
}, operation);
|
|
709
712
|
}
|
|
710
713
|
|
|
@@ -788,7 +791,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
788
791
|
}
|
|
789
792
|
|
|
790
793
|
async function sendMessage(command, configuration, context, signal, tools) {
|
|
791
|
-
return withDelivery(command
|
|
794
|
+
return withDelivery(command, async () => {
|
|
792
795
|
let attempted = false;
|
|
793
796
|
let rejected = false;
|
|
794
797
|
let accepted = false;
|
|
@@ -1013,7 +1016,8 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
1013
1016
|
if (!current || current.finished && !current.finishIfCurrent) throw new Error("Codex has no active turn to steer.");
|
|
1014
1017
|
return current.steer(command);
|
|
1015
1018
|
},
|
|
1016
|
-
async run({ configuration, context, input, tools, signal, beforeDispatch, accept,
|
|
1019
|
+
async run({ configuration, context, input, tools, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted,
|
|
1020
|
+
onEvent, onNativeTurn, finishIfCurrent }) {
|
|
1017
1021
|
if (disposed || current) throw new Error("This Codex conversation is closed or already working.");
|
|
1018
1022
|
const completion = Promise.withResolvers();
|
|
1019
1023
|
const admission = Promise.withResolvers();
|
|
@@ -1130,7 +1134,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
1130
1134
|
executingProvider = native;
|
|
1131
1135
|
}
|
|
1132
1136
|
if (!supplied) native.setServerRequestHandler(toolHandler(executingProvider));
|
|
1133
|
-
const command = { input, beforeDispatch, accept };
|
|
1137
|
+
const command = { input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted };
|
|
1134
1138
|
delivered = await sendMessage(command, configuration, context, signal, tools);
|
|
1135
1139
|
waitForTurn = !supplied || delivered.value?.delivered === true;
|
|
1136
1140
|
if (!finishIfCurrent && waitForTurn) {
|
|
@@ -257,6 +257,15 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
257
257
|
active.messageIds.set(prepared.input.id, { messageId: input.messageId, committed: command.deliveryCommitted });
|
|
258
258
|
} : beforeDispatch;
|
|
259
259
|
prepared = await supplied.preparation.message({ ...input.nativeMessage, onPromptSending: dispatch }, options);
|
|
260
|
+
if (prepared.application?.commit) {
|
|
261
|
+
const application = prepared.application;
|
|
262
|
+
prepared.application = { ...application, async commit(admitted) {
|
|
263
|
+
command.onNativeReceipt?.();
|
|
264
|
+
const result = await application.commit(admitted);
|
|
265
|
+
command.onReceiptPersisted?.();
|
|
266
|
+
return result;
|
|
267
|
+
} };
|
|
268
|
+
}
|
|
260
269
|
if (active?.tools && prepared.application?.monitor) {
|
|
261
270
|
const monitor = prepared.application.monitor;
|
|
262
271
|
const create = monitor.create;
|
|
@@ -639,7 +648,9 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
639
648
|
};
|
|
640
649
|
},
|
|
641
650
|
async commit() {
|
|
651
|
+
command.onNativeReceipt?.();
|
|
642
652
|
await command.accept();
|
|
653
|
+
command.onReceiptPersisted?.();
|
|
643
654
|
active.deliveryCommitted = command.deliveryCommitted;
|
|
644
655
|
accepted = true;
|
|
645
656
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createConversationProviderFactory } from "./provider.js";
|
|
2
|
+
import { createConversationDeliveryTiming } from "./deliveryTiming.js";
|
|
2
3
|
import { normalizeConversationConfiguration } from "./configuration.js";
|
|
3
4
|
import { runScopedConversationTurn, readPersistentConversation, startPersistentConversationTurn,
|
|
4
5
|
inspectPersistentConversationAdmission, preparePersistentConversationChangeover,
|
|
@@ -36,8 +37,9 @@ const sameRequest = (left, right) => left.text === right.text && (left.origin ||
|
|
|
36
37
|
|
|
37
38
|
/** One application conversation API. Storage and authorization remain host facilities. */
|
|
38
39
|
export function createConversationRuntime({ engine: defaultEngine = "api", defaultIntegrationId, storage, authorize, connections, apiClientFactory, apiHistory,
|
|
39
|
-
actions, toolPolicy, toolCatalog, attachments, fetch, host: defaultHost, persistCommentary = true, completedEnvelope = false, limits = {} } = {}) {
|
|
40
|
+
actions, toolPolicy, toolCatalog, attachments, fetch, host: defaultHost, onDeliveryTiming, persistCommentary = true, completedEnvelope = false, limits = {} } = {}) {
|
|
40
41
|
if (typeof authorize !== "function") throw new TypeError("Conversation access requires an authorize function.");
|
|
42
|
+
if (onDeliveryTiming !== undefined && typeof onDeliveryTiming !== "function") throw new TypeError("Delivery timing requires a server-owned observer.");
|
|
41
43
|
if (typeof completedEnvelope !== "boolean") throw new TypeError("Completed envelope policy requires a server-side boolean.");
|
|
42
44
|
if (typeof persistCommentary !== "boolean") throw new TypeError("Conversation commentary persistence requires an explicit server-side boolean.");
|
|
43
45
|
if (apiHistory !== undefined && typeof apiHistory !== "function") throw new TypeError("API request history requires a server-owned selector.");
|
|
@@ -97,6 +99,19 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
97
99
|
|
|
98
100
|
async function emit(entry, event, isCurrent) {
|
|
99
101
|
const { completedEnvelope: _untrustedMarker, ...payload } = event;
|
|
102
|
+
if (payload.type === "message" && ["assistant", "commentary", "thinking"].includes(payload.role) && payload.text?.trim()) {
|
|
103
|
+
// Bound live streams deliberately omit private native turn identities.
|
|
104
|
+
// Their current admitted request owns them; historical rows retain IDs.
|
|
105
|
+
let request;
|
|
106
|
+
if (payload.turnId) {
|
|
107
|
+
request = [...(entry.active?.requests.values() || [])].find(request =>
|
|
108
|
+
!request.sealed && (request.turnId === payload.turnId || request.nativeTurnId === payload.turnId));
|
|
109
|
+
} else if (entry.conversation) {
|
|
110
|
+
const current = entry.active?.request;
|
|
111
|
+
if (current?.accepted && !current.sealed) request = current;
|
|
112
|
+
}
|
|
113
|
+
request?.deliveryTiming?.("first-response");
|
|
114
|
+
}
|
|
100
115
|
if (completedEnvelope && !entry.conversation) {
|
|
101
116
|
const request = payload.messageId && entry.active?.requests.get(payload.messageId) ||
|
|
102
117
|
payload.turnId && [...(entry.active?.requests.values() || [])].find(request => request.turnId === payload.turnId);
|
|
@@ -777,6 +792,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
777
792
|
}
|
|
778
793
|
|
|
779
794
|
async function publishAdmission(request, nativeResult) {
|
|
795
|
+
request.deliveryTiming?.("accepted");
|
|
780
796
|
const receipt = { status: "accepted", messageId: request.input.messageId, turnId: request.turnId, origin: request.input.origin };
|
|
781
797
|
request.admission.resolve(entry.conversation ? { receipt, nativeResult } : receipt);
|
|
782
798
|
request.admissionSettled = true;
|
|
@@ -856,6 +872,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
856
872
|
if (!driver.canonicalTranscript) streams.clear(entry.id);
|
|
857
873
|
}
|
|
858
874
|
request.accepted = true;
|
|
875
|
+
if (!entry.conversation) request.deliveryTiming?.("receipt-persisted");
|
|
859
876
|
request.nativeTurnId = nativeTurnId;
|
|
860
877
|
if (request.input.goal) goalOwner = request;
|
|
861
878
|
current = active.request = request;
|
|
@@ -1027,6 +1044,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1027
1044
|
committed = Promise.withResolvers();
|
|
1028
1045
|
committed.promise.catch(() => {});
|
|
1029
1046
|
request.deliveryCommitted = committed.promise;
|
|
1047
|
+
request.deliveryTiming?.("provider-started");
|
|
1030
1048
|
completion = Promise.resolve().then(() => dispatch({
|
|
1031
1049
|
context: request.context,
|
|
1032
1050
|
input: { ...request.input, authoredInput: { ...request.input, at: request.at },
|
|
@@ -1034,8 +1052,11 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1034
1052
|
text: input.message, contextText: input.contextText || "",
|
|
1035
1053
|
content: [...contextFiles.content, ...files.content], localFiles, attachmentManifest },
|
|
1036
1054
|
signal: active.controller.signal,
|
|
1055
|
+
onNativeReceipt: () => request.deliveryTiming?.("native-acknowledged"),
|
|
1056
|
+
onReceiptPersisted: () => request.deliveryTiming?.("receipt-persisted"),
|
|
1037
1057
|
...(engine === "opencode" ? { deliveryCommitted: committed.promise } : {}),
|
|
1038
1058
|
async beforeDispatch(native) {
|
|
1059
|
+
request.deliveryTiming?.("runtime-ready");
|
|
1039
1060
|
await beforeDispatch(request);
|
|
1040
1061
|
threadId = native?.threadId;
|
|
1041
1062
|
if (!threadId) throw new Error("Native admission requires the saved conversation identity.");
|
|
@@ -1043,6 +1064,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1043
1064
|
turnMetadata: { runtime: { engine, segmentId, origin: request.input.origin,
|
|
1044
1065
|
...(request.completedEnvelope ? { completedEnvelope: true } : {}),
|
|
1045
1066
|
...(typeof request.nativeToolAttempt === "boolean" ? { nativeToolAttempt: request.nativeToolAttempt } : {}) } } });
|
|
1067
|
+
request.deliveryTiming?.("dispatch-started");
|
|
1046
1068
|
},
|
|
1047
1069
|
async accept(native) {
|
|
1048
1070
|
if (entry.conversation) nativeResult = native.nativeResult;
|
|
@@ -1396,25 +1418,34 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1396
1418
|
}
|
|
1397
1419
|
|
|
1398
1420
|
async function send(entry, context, input, origin = "user", goal, representation, wakeOptions) {
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1421
|
+
const deliveryTiming = createConversationDeliveryTiming({ engine: entry.engine,
|
|
1422
|
+
conversationId: entry.id, messageId: input?.messageId, report: onDeliveryTiming });
|
|
1423
|
+
deliveryTiming("received");
|
|
1424
|
+
try {
|
|
1425
|
+
await access(context, entry.id, goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send", entry);
|
|
1426
|
+
if (entry.conversation?.commands) {
|
|
1427
|
+
if (origin !== "user" || goal) throw failure("This conversation supports its original application Send operation.", "conversation_unsupported", 400);
|
|
1428
|
+
return entry.conversation.commands.send(input, context);
|
|
1429
|
+
}
|
|
1430
|
+
if (entry.conversation?.native?.scoped) return entry.provider.sendNative({ input, context });
|
|
1431
|
+
const originalInput = representation === "native" ? input : undefined;
|
|
1432
|
+
const message = originalInput ? { ...input, text: input.displayMessage ?? input.message ?? input.text ?? "" } : input;
|
|
1433
|
+
const admission = await serial(entry, () => admitSend(entry, context, message, origin, goal, originalInput, wakeOptions, deliveryTiming));
|
|
1434
|
+
const result = await (admission.receipt || admission.promise);
|
|
1435
|
+
if (!entry.conversation) return result;
|
|
1436
|
+
if (representation === "native") return result.nativeResult;
|
|
1437
|
+
if (result.error) throw result.error;
|
|
1438
|
+
return result.receipt;
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
deliveryTiming("failed");
|
|
1441
|
+
throw error;
|
|
1442
|
+
}
|
|
1413
1443
|
}
|
|
1414
1444
|
|
|
1415
1445
|
// Called while holding the conversation queue, including Claude's existing
|
|
1416
1446
|
// stop-then-Send goal commands. All commands retain the ordinary receipt path.
|
|
1417
|
-
async function admitSend(entry, context, input, origin = "user", goal, originalInput, wakeOptions) {
|
|
1447
|
+
async function admitSend(entry, context, input, origin = "user", goal, originalInput, wakeOptions, deliveryTiming) {
|
|
1448
|
+
deliveryTiming?.("preparation-started");
|
|
1418
1449
|
const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
|
|
1419
1450
|
await access(context, entry.id, operation, entry);
|
|
1420
1451
|
await entry.conversation?.admission(context, operation);
|
|
@@ -1484,7 +1515,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1484
1515
|
// No native dispatch has occurred for this request. Finish the previous
|
|
1485
1516
|
// waiter before reserving the same authored request on the ordinary path.
|
|
1486
1517
|
await working.done;
|
|
1487
|
-
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions);
|
|
1518
|
+
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming);
|
|
1488
1519
|
}
|
|
1489
1520
|
const nativeSteering = !working && (nativeState.nativeTurn?.active || entry.driver.canonicalTranscript && nativeState.binding?.codexAppServerRun &&
|
|
1490
1521
|
codexAppServerTurnStateFromAgentRun(nativeState.binding.codexAppServerRun).active);
|
|
@@ -1537,7 +1568,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1537
1568
|
await transaction.writeMetadata(metadata);
|
|
1538
1569
|
return metadata.runtime.configuration;
|
|
1539
1570
|
}); } catch (error) { release(); throw error; }
|
|
1540
|
-
const request = { input: message, originalInput, operation, context, at: requestAt,
|
|
1571
|
+
const request = { input: message, originalInput, operation, context, at: requestAt, deliveryTiming,
|
|
1541
1572
|
...(markedResponse ? { completedEnvelope: true, excludedMessageIds } : {}),
|
|
1542
1573
|
...(typeof nativeToolAttempt === "boolean" ? { nativeToolAttempt } : {}), steering: Boolean(working || input.steer && nativeSteering),
|
|
1543
1574
|
admission: Promise.withResolvers(), progress: new Map() };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeText } from "./normalize.js";
|
|
2
|
+
import { conversationLogPage } from "../../shared/conversation/pagination.js";
|
|
2
3
|
|
|
3
4
|
/** Conversation policy, independent of database, filesystem, identity and HTTP. */
|
|
4
5
|
export function createConversationTranscript({ storage, clock = () => new Date(), applicationTurns = false } = {}) {
|
|
@@ -15,30 +16,43 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false } = {}) {
|
|
19
|
+
async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false, pageUnit = "turns" } = {}) {
|
|
19
20
|
if (typeof presentation !== "boolean") throw new TypeError("Transcript presentation requires an explicit boolean.");
|
|
21
|
+
if (!["turns", "messages"].includes(pageUnit)) throw new TypeError("Transcript pageUnit must be turns or messages.");
|
|
20
22
|
return storage.read(scope, async (transaction) => {
|
|
21
23
|
const allIds = await transaction.listTurnIds();
|
|
22
|
-
const visible = presentation
|
|
23
|
-
.
|
|
24
|
+
const visible = presentation || pageUnit === "messages"
|
|
25
|
+
? (await Promise.all(allIds.map(id => transaction.readTurn(id))))
|
|
26
|
+
.filter(turn => hasMessages(turn) && (!presentation || !isCompletedEnvelopeTurn(turn))) : null;
|
|
24
27
|
const ids = visible ? visible.map(turn => turn.turnId) : allIds;
|
|
25
28
|
const before = normalizeText(beforeTurnId);
|
|
26
29
|
const requestedLimit = Number.parseInt(String(limit || ""), 10);
|
|
27
30
|
const pageLimit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
let page;
|
|
32
|
+
if (pageUnit === "messages") {
|
|
33
|
+
page = conversationLogPage(visible, { beforeTurnId: before, limit: pageLimit });
|
|
34
|
+
} else {
|
|
35
|
+
const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
|
|
36
|
+
const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
|
|
37
|
+
const selected = ids.slice(start, end);
|
|
38
|
+
page = {
|
|
39
|
+
conversationLog: visible ? visible.slice(start, end)
|
|
40
|
+
: (await Promise.all(selected.map(id => transaction.readTurn(id)))).filter(hasMessages),
|
|
41
|
+
hasMoreBefore: start > 0,
|
|
42
|
+
nextBeforeTurnId: start > 0 ? ids[start] : ""
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const turns = page.conversationLog;
|
|
33
46
|
return {
|
|
34
47
|
conversationLog: turns,
|
|
35
48
|
pagination: {
|
|
49
|
+
...(pageUnit === "messages" ? { pageUnit } : {}),
|
|
36
50
|
beforeTurnId: before,
|
|
37
51
|
count: turns.length,
|
|
38
|
-
hasMoreBefore:
|
|
52
|
+
hasMoreBefore: page.hasMoreBefore,
|
|
39
53
|
limit: pageLimit,
|
|
40
54
|
newestTurnId: turns.at(-1)?.turnId || "",
|
|
41
|
-
nextBeforeTurnId:
|
|
55
|
+
nextBeforeTurnId: page.nextBeforeTurnId,
|
|
42
56
|
oldestTurnId: turns[0]?.turnId || "",
|
|
43
57
|
totalTurnCount: ids.length
|
|
44
58
|
}
|
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
// Original paged transcript representation, shared by all browser bindings.
|
|
2
|
-
const CONVERSATION_LOG_PAGE_LIMIT =
|
|
2
|
+
const CONVERSATION_LOG_PAGE_LIMIT = 10;
|
|
3
|
+
|
|
4
|
+
const messageRoles = ["system", "user", "assistant"];
|
|
5
|
+
|
|
6
|
+
// Live updates retain whole boundary turns. Reasoning/progress never uses the
|
|
7
|
+
// message allowance, and never evicts the question it follows.
|
|
8
|
+
function conversationLogPageStart(turns, limit, end = turns.length) {
|
|
9
|
+
if (!limit) return 0;
|
|
10
|
+
let remaining = limit;
|
|
11
|
+
let start = end;
|
|
12
|
+
while (start > 0 && remaining > 0) {
|
|
13
|
+
start -= 1;
|
|
14
|
+
remaining -= messageRoles.filter(role => turns[start]?.[role]).length;
|
|
15
|
+
}
|
|
16
|
+
return turns.slice(0, start).some(turn => messageRoles.some(role => turn?.[role])) ? start : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A cursor can stop between the question and answer of one stored turn. It is
|
|
20
|
+
// read-only presentation state; stored turn and message identities never change.
|
|
21
|
+
function conversationLogPage(turns, { beforeTurnId = "", limit = 0 } = {}) {
|
|
22
|
+
const messages = turns.flatMap((turn, index) => messageRoles.filter(role => turn[role])
|
|
23
|
+
.map(role => ({ index, role, turnId: turn.turnId })));
|
|
24
|
+
let boundary = turns.findIndex(turn => turn.turnId === beforeTurnId);
|
|
25
|
+
let beforeRole = "";
|
|
26
|
+
if (boundary < 0 && beforeTurnId.startsWith("message:")) {
|
|
27
|
+
try {
|
|
28
|
+
const [id, role] = JSON.parse(beforeTurnId.slice(8));
|
|
29
|
+
if (messageRoles.includes(role)) {
|
|
30
|
+
boundary = turns.findIndex(turn => turn.turnId === id);
|
|
31
|
+
beforeRole = role;
|
|
32
|
+
}
|
|
33
|
+
} catch { /* An unknown cursor retains the existing latest-page behavior. */ }
|
|
34
|
+
}
|
|
35
|
+
const beforeRoleIndex = beforeRole ? messageRoles.indexOf(beforeRole) : 0;
|
|
36
|
+
const foundEnd = boundary < 0 ? -1 : messages.findIndex(message =>
|
|
37
|
+
message.index > boundary ||
|
|
38
|
+
(message.index === boundary && messageRoles.indexOf(message.role) >= beforeRoleIndex));
|
|
39
|
+
const end = foundEnd < 0 ? messages.length : foundEnd;
|
|
40
|
+
const start = limit > 0 ? Math.max(0, end - limit) : 0;
|
|
41
|
+
const first = messages[start];
|
|
42
|
+
const firstRoleIndex = start > 0 ? messageRoles.indexOf(first.role) : 0;
|
|
43
|
+
const startTurn = start > 0 ? first.index : 0;
|
|
44
|
+
const endTurn = boundary < 0 ? turns.length : boundary + (beforeRole ? 1 : 0);
|
|
45
|
+
const selected = turns.slice(startTurn, endTurn).map((turn, offset) => {
|
|
46
|
+
const index = startTurn + offset;
|
|
47
|
+
const roles = messageRoles.filter(role => turn[role]);
|
|
48
|
+
const fromRole = index === startTurn ? firstRoleIndex : 0;
|
|
49
|
+
const toRole = index === boundary && beforeRole ? beforeRoleIndex : messageRoles.length;
|
|
50
|
+
const keep = roles.filter(role => {
|
|
51
|
+
const position = messageRoles.indexOf(role);
|
|
52
|
+
return position >= fromRole && position < toRole;
|
|
53
|
+
});
|
|
54
|
+
if (keep.length === roles.length) return turn;
|
|
55
|
+
// Progress belongs to the later part of a split turn, next to its answer.
|
|
56
|
+
const keepActivity = keep.includes(roles.at(-1));
|
|
57
|
+
const result = { ...turn, paginationPartial: true,
|
|
58
|
+
thinking: keepActivity ? turn.thinking : [], commentary: keepActivity ? turn.commentary : [] };
|
|
59
|
+
for (const role of messageRoles) if (!keep.includes(role)) result[role] = null;
|
|
60
|
+
result.messages = (turn.messages || [turn.system, turn.user, ...(turn.thinking || []),
|
|
61
|
+
...(turn.commentary || []), turn.assistant].filter(Boolean))
|
|
62
|
+
.filter(message => keep.includes(message.role) || (keepActivity && !messageRoles.includes(message.role)));
|
|
63
|
+
return result;
|
|
64
|
+
}).filter(turn => messageRoles.some(role => turn[role]) || turn.thinking?.length || turn.commentary?.length);
|
|
65
|
+
let nextBeforeTurnId = "";
|
|
66
|
+
if (start > 0) {
|
|
67
|
+
const split = messageRoles.slice(0, firstRoleIndex).some(role => turns[first.index][role]);
|
|
68
|
+
nextBeforeTurnId = split ? `message:${JSON.stringify([first.turnId, first.role])}` : first.turnId;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
conversationLog: selected,
|
|
72
|
+
hasMoreBefore: start > 0,
|
|
73
|
+
nextBeforeTurnId
|
|
74
|
+
};
|
|
75
|
+
}
|
|
3
76
|
|
|
4
77
|
function isRecord(value) {
|
|
5
78
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -8,6 +81,7 @@ function isRecord(value) {
|
|
|
8
81
|
function normalizeConversationLogPagination(pagination = {}) {
|
|
9
82
|
const source = isRecord(pagination) ? pagination : {};
|
|
10
83
|
return {
|
|
84
|
+
...(source.pageUnit === "messages" ? { pageUnit: "messages" } : {}),
|
|
11
85
|
beforeTurnId: String(source.beforeTurnId || "").trim(),
|
|
12
86
|
count: Number.isFinite(Number(source.count)) ? Number(source.count) : 0,
|
|
13
87
|
hasMoreBefore: source.hasMoreBefore === true,
|
|
@@ -47,7 +121,22 @@ function mergeConversationLogPages(pages = []) {
|
|
|
47
121
|
continue;
|
|
48
122
|
}
|
|
49
123
|
if (indexes.has(turnId)) {
|
|
50
|
-
|
|
124
|
+
const index = indexes.get(turnId);
|
|
125
|
+
const previous = orderedTurns[index];
|
|
126
|
+
if (!turn.paginationPartial && !previous.paginationPartial) {
|
|
127
|
+
orderedTurns[index] = turn;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const merged = { ...previous, ...turn };
|
|
131
|
+
for (const role of messageRoles) merged[role] = turn[role] || previous[role];
|
|
132
|
+
const [activity, previousActivity] = [turn, previous].map(part =>
|
|
133
|
+
(part.messages || [...(part.thinking || []), ...(part.commentary || [])])
|
|
134
|
+
.filter(message => !messageRoles.includes(message.role)));
|
|
135
|
+
const retainedActivity = activity.length ? activity : previousActivity;
|
|
136
|
+
merged.thinking = retainedActivity.filter(message => message.role === "thinking");
|
|
137
|
+
merged.commentary = retainedActivity.filter(message => message.role === "commentary");
|
|
138
|
+
merged.messages = [merged.system, merged.user, ...retainedActivity, merged.assistant].filter(Boolean);
|
|
139
|
+
orderedTurns[index] = merged;
|
|
51
140
|
continue;
|
|
52
141
|
}
|
|
53
142
|
indexes.set(turnId, orderedTurns.length);
|
|
@@ -71,6 +160,8 @@ function conversationLogReadQuery({
|
|
|
71
160
|
|
|
72
161
|
export {
|
|
73
162
|
CONVERSATION_LOG_PAGE_LIMIT,
|
|
163
|
+
conversationLogPageStart,
|
|
164
|
+
conversationLogPage,
|
|
74
165
|
normalizeConversationLogPagination,
|
|
75
166
|
normalizeConversationLogPage,
|
|
76
167
|
mergeConversationLogPages,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeThinkingMessageText } from "./thinkingText.js";
|
|
2
|
-
import { normalizeConversationLogPagination } from "./pagination.js";
|
|
2
|
+
import { conversationLogPageStart, normalizeConversationLogPagination } from "./pagination.js";
|
|
3
3
|
|
|
4
4
|
function isRecord(value) {
|
|
5
5
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -125,14 +125,17 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
|
|
|
125
125
|
numeric: true
|
|
126
126
|
}
|
|
127
127
|
));
|
|
128
|
+
const pagination = normalizeConversationLogPagination(source.pagination);
|
|
128
129
|
const limit = Number.parseInt(String(options.limit || ""), 10);
|
|
129
130
|
const limitedTurns = Number.isFinite(limit) && limit > 0
|
|
130
|
-
? nextTurns.slice(-limit)
|
|
131
|
+
? nextTurns.slice(pagination.pageUnit === "messages" ? conversationLogPageStart(nextTurns, limit) : -limit)
|
|
131
132
|
: nextTurns;
|
|
132
133
|
const wasTrimmed = limitedTurns.length < nextTurns.length;
|
|
133
|
-
const pagination = normalizeConversationLogPagination(source.pagination);
|
|
134
134
|
const hasMoreBefore = pagination.hasMoreBefore || wasTrimmed;
|
|
135
135
|
const oldestTurnId = String(limitedTurns[0]?.turnId || "").trim();
|
|
136
|
+
const beforeCursor = oldestTurnId === pagination.oldestTurnId
|
|
137
|
+
? pagination.nextBeforeTurnId || oldestTurnId
|
|
138
|
+
: oldestTurnId;
|
|
136
139
|
return {
|
|
137
140
|
...source,
|
|
138
141
|
conversationLog: limitedTurns,
|
|
@@ -141,7 +144,7 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
|
|
|
141
144
|
count: limitedTurns.length,
|
|
142
145
|
hasMoreBefore,
|
|
143
146
|
newestTurnId: String(limitedTurns.at(-1)?.turnId || "").trim(),
|
|
144
|
-
nextBeforeTurnId: hasMoreBefore ?
|
|
147
|
+
nextBeforeTurnId: hasMoreBefore ? beforeCursor : "",
|
|
145
148
|
oldestTurnId
|
|
146
149
|
}
|
|
147
150
|
};
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -746,7 +746,7 @@ test("Claude verified native cleanup retains interrupted checkpoint refusal with
|
|
|
746
746
|
|
|
747
747
|
// Retain the original supplied-store/native process fixture and drive it through
|
|
748
748
|
// the actual common runtime, including its completion-only admitted receipt.
|
|
749
|
-
async function suppliedCommonFixture(t, { tools = true } = {}) {
|
|
749
|
+
async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming } = {}) {
|
|
750
750
|
const f = await publishedClaudeFixture(t, { canonicalAdmission: true });
|
|
751
751
|
const storage = createMemoryConversationStorage();
|
|
752
752
|
const transcript = createConversationTranscript({ storage });
|
|
@@ -786,7 +786,7 @@ async function suppliedCommonFixture(t, { tools = true } = {}) {
|
|
|
786
786
|
...(tools ? { applicationTools: { storage, prepareContext(actual, admission) {
|
|
787
787
|
admissions.push(admission); return actual;
|
|
788
788
|
} } } : {}) };
|
|
789
|
-
const runtime = createConversationRuntime({ ...(tools ? { actions } : {}), authorize: () => true,
|
|
789
|
+
const runtime = createConversationRuntime({ ...(tools ? { actions } : {}), authorize: () => true, onDeliveryTiming,
|
|
790
790
|
host: { conversation: () => binding } });
|
|
791
791
|
t.after(() => runtime.close());
|
|
792
792
|
const conversation = await runtime.open({ id: "lesson", context });
|
|
@@ -822,6 +822,25 @@ async function sendCommon(f, messageId) {
|
|
|
822
822
|
return receipt;
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
+
test("Claude delivery timing separates native acknowledgement from the held canonical receipt", { timeout: 10_000 }, async t => {
|
|
826
|
+
const timings = [];
|
|
827
|
+
const f = await suppliedCommonFixture(t, { onDeliveryTiming: event => timings.push(event) });
|
|
828
|
+
const writing = Promise.withResolvers();
|
|
829
|
+
const release = Promise.withResolvers();
|
|
830
|
+
f.behavior.beforeAdmit = async () => { writing.resolve(); await release.promise; };
|
|
831
|
+
const sending = f.conversation.send({ messageId: "timed-request", text: "Private message" });
|
|
832
|
+
await writing.promise;
|
|
833
|
+
assert.deepEqual(timings.map(event => event.stage), [
|
|
834
|
+
"received", "preparation-started", "provider-started", "runtime-ready", "dispatch-started", "native-acknowledged"
|
|
835
|
+
]);
|
|
836
|
+
release.resolve();
|
|
837
|
+
assert.equal((await sending).status, "accepted");
|
|
838
|
+
assert.deepEqual(timings.slice(-2).map(event => event.stage), ["receipt-persisted", "accepted"]);
|
|
839
|
+
await f.frame({ type: "result", subtype: "success", result: "Public answer" });
|
|
840
|
+
await f.conversation.wait();
|
|
841
|
+
assert.equal(JSON.stringify(timings).includes("Private message"), false);
|
|
842
|
+
});
|
|
843
|
+
|
|
825
844
|
for (const tools of [true, false]) test(`Claude supplied common runtime checkpoints after its completion fence with tools=${tools}`, { timeout: 10_000 }, async t => {
|
|
826
845
|
const f = await suppliedCommonFixture(t, { tools });
|
|
827
846
|
const receipt = await sendCommon(f, "common-question");
|
|
@@ -1214,6 +1214,7 @@ async function fixture(t, options = {}) {
|
|
|
1214
1214
|
TEST_HISTORY: path.join(directory, "history.json"), ...options.environment };
|
|
1215
1215
|
function runtime({ nativeTools = options.nativeTools } = {}) {
|
|
1216
1216
|
const value = createConversationRuntime({ engine: "codex", storage, authorize: options.authorize || (() => true),
|
|
1217
|
+
onDeliveryTiming: options.onDeliveryTiming,
|
|
1217
1218
|
actions: options.actions, connections: options.connections, fetch: options.fetch, attachments: options.attachments,
|
|
1218
1219
|
host: { workdir: directory, runtimeDirectory: directory, env, commands: { codex: command }, execution, nativeTools, commandWrapper: options.commandWrapper },
|
|
1219
1220
|
limits: { admissionTimeoutMs: 2000, ...options.limits } });
|
|
@@ -1235,6 +1236,18 @@ async function fixture(t, options = {}) {
|
|
|
1235
1236
|
async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
|
|
1236
1237
|
}
|
|
1237
1238
|
|
|
1239
|
+
test("Codex delivery timing uses the common native receipt and first-response stages", async t => {
|
|
1240
|
+
const timings = [];
|
|
1241
|
+
const f = await fixture(t, { onDeliveryTiming: event => timings.push(event) });
|
|
1242
|
+
assert.equal((await f.conversation.send({ messageId: "timed-request", text: "Private message" })).status, "accepted");
|
|
1243
|
+
await f.conversation.wait();
|
|
1244
|
+
const stages = timings.map(event => event.stage);
|
|
1245
|
+
assert.deepEqual(stages.slice(0, 8), ["received", "preparation-started", "provider-started", "runtime-ready",
|
|
1246
|
+
"dispatch-started", "native-acknowledged", "receipt-persisted", "accepted"]);
|
|
1247
|
+
assert.equal(stages.filter(stage => stage === "first-response").length, 1);
|
|
1248
|
+
assert.equal(JSON.stringify(timings).includes("Private message"), false);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1238
1251
|
test("Codex admits, streams, persists reasoning and deduplicates using native receipts", async t => {
|
|
1239
1252
|
const f = await fixture(t);
|
|
1240
1253
|
const events = [];
|
package/test/codexTurn.test.js
CHANGED
|
@@ -1079,10 +1079,13 @@ test("original receipt owner persists authored metadata before releasing the rec
|
|
|
1079
1079
|
f.run = { ...f.run, state: "starting", providerTurnId: "", pendingUserMessageClientIds: [messageId] };
|
|
1080
1080
|
const receipt = Promise.withResolvers();
|
|
1081
1081
|
let received = false;
|
|
1082
|
+
const deliveryTimings = [];
|
|
1082
1083
|
receipt.promise.then(() => { received = true; f.operations.push("receipt"); });
|
|
1083
1084
|
f.owner.pendingUserMessages.set(`project:${f.sessionId}\0${messageId}`, {
|
|
1084
1085
|
attachments: [{ id: "attachment-1", name: "Diagram" }], actorContext,
|
|
1085
1086
|
turnMetadata: { actorId: "saved-actor", label: "authored" }, receipt,
|
|
1087
|
+
onNativeReceipt: () => deliveryTimings.push("native-acknowledged"),
|
|
1088
|
+
onReceiptPersisted: () => deliveryTimings.push("receipt-persisted"),
|
|
1086
1089
|
text: "Review the diagram."
|
|
1087
1090
|
});
|
|
1088
1091
|
f.observe();
|
|
@@ -1090,11 +1093,13 @@ test("original receipt owner persists authored metadata before releasing the rec
|
|
|
1090
1093
|
f.emit({ method: "item/completed", params: { threadId: f.threadId, turnId: f.turnId,
|
|
1091
1094
|
item: outputItem("early-commentary", "I am reviewing the diagram.", "commentary") } });
|
|
1092
1095
|
await decorating.promise;
|
|
1096
|
+
assert.deepEqual(deliveryTimings, ["native-acknowledged"], "Native acknowledgement precedes the held canonical write");
|
|
1093
1097
|
assert.equal(received, false);
|
|
1094
1098
|
assert.equal(await f.store.conversationMessageIdExists(f.sessionId, messageId), false);
|
|
1095
1099
|
assert.equal(f.publications.some(event => event.payload?.conversationLogPatch?.turn?.commentary), false);
|
|
1096
1100
|
release.resolve();
|
|
1097
1101
|
await f.drain();
|
|
1102
|
+
assert.deepEqual(deliveryTimings, ["native-acknowledged", "receipt-persisted"]);
|
|
1098
1103
|
assert.deepEqual(await receipt.promise, { id: f.turnId });
|
|
1099
1104
|
const turns = await f.store.readConversationLog(f.sessionId);
|
|
1100
1105
|
const authored = turns.find(row => row.user?.messageId === messageId);
|
|
@@ -4,6 +4,8 @@ import { createMemoryConversationStorage } from "../src/server/conversation/memo
|
|
|
4
4
|
import { createConversationStorage } from "../src/server/conversation/storage.js";
|
|
5
5
|
import { createConversationTranscript } from "../src/server/conversation/transcript.js";
|
|
6
6
|
import { conversationTurnsFromMessages } from "../src/shared/conversation/turns.js";
|
|
7
|
+
import { CONVERSATION_LOG_PAGE_LIMIT, conversationLogReadQuery, mergeConversationLogPages } from "../src/shared/conversation/pagination.js";
|
|
8
|
+
import { applyConversationLogPatch } from "../src/shared/conversation/patches.js";
|
|
7
9
|
import { verifyConversationStorageContract } from "../src/testing/conversationStorageContract.js";
|
|
8
10
|
|
|
9
11
|
test("new output identity persists on its original message while older records remain unchanged", async () => {
|
|
@@ -248,3 +250,63 @@ test("completed envelope presentation has an honest empty page when all canonica
|
|
|
248
250
|
newestTurnId: "", nextBeforeTurnId: "", oldestTurnId: "", totalTurnCount: 0 });
|
|
249
251
|
assert.equal((await transcript.readConversationLog("empty")).length, 1);
|
|
250
252
|
});
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
test("history pages load ten real messages across reasoning rows and split turns without rewriting storage", async () => {
|
|
256
|
+
const storage = createMemoryConversationStorage();
|
|
257
|
+
const transcript = createConversationTranscript({ storage });
|
|
258
|
+
await storage.write("large", async tx => {
|
|
259
|
+
let id = 0;
|
|
260
|
+
const append = async (role, turnId = String(++id).padStart(6, "0")) => {
|
|
261
|
+
await tx.appendMessage(turnId, { role, messageId: `${turnId}-${role}`, text: `${role} ${turnId}`, at: "2026-10-14" });
|
|
262
|
+
return turnId;
|
|
263
|
+
};
|
|
264
|
+
await append("thinking");
|
|
265
|
+
for (let index = 0; index < 123; index++) {
|
|
266
|
+
const turnId = await append("user");
|
|
267
|
+
await append("commentary", turnId);
|
|
268
|
+
if (index < 122) await append("assistant", turnId);
|
|
269
|
+
for (let reasoning = 0; reasoning < 9; reasoning++) await append("thinking");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
const before = await transcript.readConversationLog("large");
|
|
273
|
+
assert.equal(CONVERSATION_LOG_PAGE_LIMIT, 10);
|
|
274
|
+
assert.equal(conversationLogReadQuery().limit, "10");
|
|
275
|
+
const pages = [];
|
|
276
|
+
let cursor = "";
|
|
277
|
+
let remaining = 245;
|
|
278
|
+
do {
|
|
279
|
+
const page = await transcript.readConversationLogPage("large", { limit: 10, beforeTurnId: cursor, pageUnit: "messages" });
|
|
280
|
+
const count = page.conversationLog.reduce((sum, turn) => sum + [turn.system, turn.user, turn.assistant].filter(Boolean).length, 0);
|
|
281
|
+
assert.equal(count, Math.min(10, remaining));
|
|
282
|
+
remaining -= count;
|
|
283
|
+
assert.equal(page.pagination.hasMoreBefore, remaining > 0);
|
|
284
|
+
assert.notEqual(page.pagination.nextBeforeTurnId, cursor);
|
|
285
|
+
pages.unshift(page);
|
|
286
|
+
cursor = page.pagination.nextBeforeTurnId;
|
|
287
|
+
} while (cursor);
|
|
288
|
+
assert.equal(remaining, 0);
|
|
289
|
+
assert.match(pages.at(-1).pagination.nextBeforeTurnId, /^message:/);
|
|
290
|
+
const merged = mergeConversationLogPages(pages).conversationLog;
|
|
291
|
+
const identities = rows => rows.flatMap(turn => turn.messages.map(message => message.messageId));
|
|
292
|
+
assert.deepEqual(identities(merged), identities(before), "all messages and reasoning survive pagination exactly once and in order");
|
|
293
|
+
assert.deepEqual(await transcript.readConversationLog("large"), before, "reading pages never changes saved rows");
|
|
294
|
+
const firstAnswer = pages.at(-1).conversationLog[0];
|
|
295
|
+
assert.equal(firstAnswer.user, null);
|
|
296
|
+
assert.ok(firstAnswer.assistant);
|
|
297
|
+
const patched = applyConversationLogPatch(pages.at(-1), { type: "upsert-turn", turn: {
|
|
298
|
+
turnId: "999999", thinking: [{ role: "thinking", text: "More reasoning" }]
|
|
299
|
+
} }, { limit: 10 });
|
|
300
|
+
assert.equal(patched.conversationLog[0].turnId, firstAnswer.turnId);
|
|
301
|
+
assert.equal(patched.pagination.nextBeforeTurnId, pages.at(-1).pagination.nextBeforeTurnId);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("reasoning-only histories remain readable without creating phantom message pages", async () => {
|
|
305
|
+
const storage = createMemoryConversationStorage();
|
|
306
|
+
const transcript = createConversationTranscript({ storage });
|
|
307
|
+
for (let index = 0; index < 25; index++) await transcript.writeConversationThinkingMessage("activity", { text: `Thought ${index}` });
|
|
308
|
+
const page = await transcript.readConversationLogPage("activity", { limit: 10, pageUnit: "messages" });
|
|
309
|
+
assert.equal(page.conversationLog.length, 25);
|
|
310
|
+
assert.equal(page.pagination.hasMoreBefore, false);
|
|
311
|
+
assert.equal(page.pagination.nextBeforeTurnId, "");
|
|
312
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createConversationDeliveryTiming } from "../src/server/conversation/deliveryTiming.js";
|
|
4
|
+
|
|
5
|
+
test("native engines share bounded, content-free timing and independent message identities", () => {
|
|
6
|
+
for (const engine of ["codex", "claude", "opencode"]) {
|
|
7
|
+
let now = 0;
|
|
8
|
+
const events = [];
|
|
9
|
+
const mark = createConversationDeliveryTiming({ engine, conversationId: "chat", messageId: "one",
|
|
10
|
+
report: event => events.push(event), clock: () => now });
|
|
11
|
+
mark("received");
|
|
12
|
+
now = 3; mark("dispatch-started");
|
|
13
|
+
now = 1003; mark("native-acknowledged");
|
|
14
|
+
now = 1013; mark("receipt-persisted");
|
|
15
|
+
now = 1015; mark("accepted");
|
|
16
|
+
now = 1020; mark("first-response");
|
|
17
|
+
mark("first-response");
|
|
18
|
+
mark("arbitrary-provider-payload");
|
|
19
|
+
assert.equal(events.length, 6);
|
|
20
|
+
assert.equal(events[2].durationMs, 1000);
|
|
21
|
+
assert.equal(events[3].durationMs, 10);
|
|
22
|
+
assert.deepEqual(events[4].timings, {
|
|
23
|
+
received: 0, "dispatch-started": 3, "native-acknowledged": 1003, "receipt-persisted": 1013, accepted: 1015
|
|
24
|
+
});
|
|
25
|
+
assert.equal(events[0].timings.accepted, undefined, "Earlier reports are snapshots");
|
|
26
|
+
for (const event of events) assert.deepEqual(Object.keys(event).sort(),
|
|
27
|
+
["engine", "conversationId", "messageId", "stage", "previousStage", "durationMs", "elapsedMs", "timings"].sort());
|
|
28
|
+
const next = createConversationDeliveryTiming({ engine, conversationId: "chat", messageId: "two",
|
|
29
|
+
report: event => events.push(event), clock: () => now });
|
|
30
|
+
next("received");
|
|
31
|
+
assert.equal(events.at(-1).messageId, "two");
|
|
32
|
+
assert.equal(events.at(-1).elapsedMs, 0);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("diagnostic failures and unfinished asynchronous observers never block delivery", async () => {
|
|
37
|
+
const pending = Promise.withResolvers();
|
|
38
|
+
let calls = 0;
|
|
39
|
+
const mark = createConversationDeliveryTiming({ engine: "claude", conversationId: "chat", messageId: "one",
|
|
40
|
+
report() {
|
|
41
|
+
calls++;
|
|
42
|
+
if (calls === 1) throw new Error("Observer unavailable");
|
|
43
|
+
if (calls === 2) return Promise.reject(new Error("Observer rejected"));
|
|
44
|
+
return pending.promise;
|
|
45
|
+
} });
|
|
46
|
+
mark("received"); mark("dispatch-started"); mark("native-acknowledged"); mark("receipt-persisted");
|
|
47
|
+
assert.equal(calls, 4);
|
|
48
|
+
await Promise.resolve();
|
|
49
|
+
pending.resolve();
|
|
50
|
+
});
|
|
@@ -227,6 +227,7 @@ async function fixture(t, options = {}) {
|
|
|
227
227
|
});
|
|
228
228
|
const runtime = () => {
|
|
229
229
|
const value = createConversationRuntime({ engine: "opencode", storage, authorize: options.authorize || (() => true), actions: options.actions, attachments: options.attachments,
|
|
230
|
+
onDeliveryTiming: options.onDeliveryTiming,
|
|
230
231
|
connections: { resolve: options.resolve || (async () => ({ providerId: "test", model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
|
|
231
232
|
modelLimits: { context: 32000, output: 4000 }, apiKey: key })) },
|
|
232
233
|
host: { workdir: directory, env: { ...process.env, UNRELATED_SECRET: "must-not-inherit" },
|
|
@@ -249,6 +250,19 @@ async function fixture(t, options = {}) {
|
|
|
249
250
|
async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
test("OpenCode delivery timing uses the common receipt stages without recording authored content", async t => {
|
|
254
|
+
const timings = [];
|
|
255
|
+
const f = await fixture(t, { onDeliveryTiming: event => timings.push(event) });
|
|
256
|
+
const receipt = await f.conversation.send({ messageId: "timed-request", text: "Private message" });
|
|
257
|
+
assert.equal(receipt.status, "accepted");
|
|
258
|
+
await f.conversation.wait();
|
|
259
|
+
const stages = timings.map(event => event.stage);
|
|
260
|
+
assert.deepEqual(stages.slice(0, 8), ["received", "preparation-started", "provider-started", "runtime-ready", "dispatch-started",
|
|
261
|
+
"native-acknowledged", "receipt-persisted", "accepted"]);
|
|
262
|
+
assert.equal(stages.filter(stage => stage === "first-response").length, 1);
|
|
263
|
+
assert.equal(JSON.stringify(timings).includes("Private message"), false);
|
|
264
|
+
});
|
|
265
|
+
|
|
252
266
|
test("OpenCode structured output shares the original prompt and completed-result normalization across reopen", async t => {
|
|
253
267
|
const outputSchema = { type: "object", additionalProperties: false,
|
|
254
268
|
properties: { answer: { type: "string", maxLength: 32 } }, required: ["answer"] };
|