@jskit-ai/assistant-core 0.1.222 → 0.1.224
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 +1 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +8 -8
- package/src/server/conversation/claudeHistory.js +9 -3
- package/src/server/conversation/claudeProcess.js +60 -20
- package/src/server/conversation/claudeTurn.js +190 -41
- package/src/server/conversation/codexProvider.js +67 -3
- package/src/server/conversation/providers/claude.js +5 -1
- package/src/server/conversation/providers/claudeDriver.js +5 -1
- package/src/server/conversation/providers/codex.js +325 -216
- package/src/server/conversation/providers/codexDriver.js +14 -1
- package/src/server/conversation/providers/scoped.js +39 -23
- package/src/server/conversation/runtime.js +20 -1
- 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/claudeConversationSession.test.js +3 -2
- package/test/claudeProcess.test.js +139 -1
- package/test/claudeTurn.test.js +253 -3
- package/test/codexConversationSession.test.js +27 -4
- package/test/codexGoal.test.js +152 -0
- package/test/conversation.test.js +62 -0
- package/test/conversationRuntime.test.js +168 -4
- package/test/conversationSession.test.js +48 -0
|
@@ -26,7 +26,7 @@ const goalCapabilities = Object.freeze({ goals: true, goalBudgets: true,
|
|
|
26
26
|
goalCommands: Object.freeze({
|
|
27
27
|
set: Object.freeze({ delivery: "control", interruptsTurn: false }),
|
|
28
28
|
resume: Object.freeze({ delivery: "control", interruptsTurn: false }),
|
|
29
|
-
pause: Object.freeze({ delivery: "control", interruptsTurn:
|
|
29
|
+
pause: Object.freeze({ delivery: "control", interruptsTurn: true }),
|
|
30
30
|
cancel: Object.freeze({ delivery: "control", interruptsTurn: false })
|
|
31
31
|
}) });
|
|
32
32
|
const nativeInput = input => [
|
|
@@ -216,6 +216,12 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
216
216
|
async ensureConversation({ sessionId, native }) {
|
|
217
217
|
return native.runOwner.ensureSessionReadiness(sessionId, await native.preparation.readiness());
|
|
218
218
|
},
|
|
219
|
+
async updateConversationSettings({ sessionId, native, context, input }) {
|
|
220
|
+
const acquired = await native.runOwner.conversationContext(sessionId, { conversationId: input.threadId }, context);
|
|
221
|
+
if (acquired.ok === false) return acquired;
|
|
222
|
+
await acquired.provider.updateThreadSettings(input.threadId, { model: input.model, effort: input.effort });
|
|
223
|
+
return { ok: true };
|
|
224
|
+
},
|
|
219
225
|
async createConversation({ sessionId, native, input, context }) {
|
|
220
226
|
return native.runOwner.createConversation(sessionId, input, context);
|
|
221
227
|
},
|
|
@@ -931,6 +937,12 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
931
937
|
});
|
|
932
938
|
if (result.ok !== false) binding.goal = result.goal || null;
|
|
933
939
|
} finally { admission.release(); }
|
|
940
|
+
if (result.ok !== false && input.action === "pause") {
|
|
941
|
+
// Pause keeps the goal and uses the original verified Stop owner
|
|
942
|
+
// to stop its current conversation turn as well as scheduling.
|
|
943
|
+
const stopped = await interrupt({ configuration, context });
|
|
944
|
+
if (stopped?.ok === false) throw Object.assign(new Error(stopped.error), stopped, { cleanupFailed: true });
|
|
945
|
+
}
|
|
934
946
|
if (nativeContext.completeResult) await nativeContext.completeResult(result);
|
|
935
947
|
return { goal: goalValue(result.goal), nativeResult: result };
|
|
936
948
|
}
|
|
@@ -949,6 +961,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
949
961
|
prepareConversation: () => goalContext(configuration, context, signal, { prepareThread: true, tools })
|
|
950
962
|
});
|
|
951
963
|
if (result.ok === false) throw new Error(result.error);
|
|
964
|
+
if (input.action === "pause") await interrupt({ configuration, context });
|
|
952
965
|
return goalValue(result.goal);
|
|
953
966
|
},
|
|
954
967
|
async inspectAdmission({ messageId, threadId, nativeTurnId, configuration, context, goal: command, representation, verifyAccount = false, receiptBinding }) {
|
|
@@ -107,12 +107,48 @@ function streamMessage({ sessionId, store, projection, publish }, context, recor
|
|
|
107
107
|
}).catch(() => {});
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Saved transcript projection is also used while a host's source lease is busy.
|
|
111
|
+
// Keep provider observation and transcript writes in readPersistentConversation.
|
|
112
|
+
async function projectPersistentConversation(options, context, record, response, turns) {
|
|
113
|
+
const { sessionId, store, projection, receipts, includeAccess = false, transcriptQuery } = options;
|
|
114
|
+
const scope = { sessionId, conversationId: record.conversationId };
|
|
115
|
+
const messages = turns.flatMap((turn) => turn.messages.map((message) => ({
|
|
116
|
+
...message,
|
|
117
|
+
id: message.messageId || `${turn.turnId}:${message.role}:${message.at}`,
|
|
118
|
+
...(message.role === "user" ? { attachments: turn.user?.attachments || [] } : {}),
|
|
119
|
+
...(message.role === "user" && !receipts.persisted(turn) &&
|
|
120
|
+
!(response.admitted === true && message.messageId === record.messageId) ? { receipt: false } : {}),
|
|
121
|
+
status: "completed",
|
|
122
|
+
...projection.messageMetadata(turn)
|
|
123
|
+
})));
|
|
124
|
+
for (const message of response.messages || []) {
|
|
125
|
+
if (message.complete === false && !messages.some((saved) => saved.id === message.id)) {
|
|
126
|
+
const projected = projection.message(message, record, "incomplete");
|
|
127
|
+
if (projected) messages.push({ ...projected, status: response.status });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return projection.snapshot({ context, record, response, messages, includeAccess,
|
|
131
|
+
readPage: transcriptQuery ? async () => {
|
|
132
|
+
const page = await store.readConversationLogPage(scope, transcriptQuery);
|
|
133
|
+
return { ...page, conversationLog: qualifyTranscript(page.conversationLog, record, response, receipts) };
|
|
134
|
+
} : null
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function projectStoredPersistentConversation(options) {
|
|
139
|
+
const { sessionId, store, record, projection } = options;
|
|
140
|
+
const context = projection.context(options.context, record);
|
|
141
|
+
const turns = await store.readConversationLog({ sessionId, conversationId: record.conversationId });
|
|
142
|
+
return projectPersistentConversation(options, context, record, {
|
|
143
|
+
status: record.status, runId: record.runId, error: record.error
|
|
144
|
+
}, turns);
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
// These original persistent-conversation operations run inside the host's
|
|
111
148
|
// existing write lease. Their record, transcript and native operations are the
|
|
112
149
|
// same facilities used by its retained conversation; no state is copied here.
|
|
113
150
|
export async function readPersistentConversation(options) {
|
|
114
|
-
const { sessionId, operations, store, records, projection,
|
|
115
|
-
includeAccess = false, transcriptQuery } = options;
|
|
151
|
+
const { sessionId, operations, store, records, projection, publish } = options;
|
|
116
152
|
let { record } = options;
|
|
117
153
|
const context = projection.context(options.context, record);
|
|
118
154
|
const scope = { sessionId, conversationId: record.conversationId };
|
|
@@ -154,27 +190,7 @@ export async function readPersistentConversation(options) {
|
|
|
154
190
|
store.readConversationStream(scope).messages.length) {
|
|
155
191
|
await publish(context, record.conversationId, { payload: { conversationStream: store.clearConversationStream(scope) } });
|
|
156
192
|
}
|
|
157
|
-
|
|
158
|
-
...message,
|
|
159
|
-
id: message.messageId || `${turn.turnId}:${message.role}:${message.at}`,
|
|
160
|
-
...(message.role === "user" ? { attachments: turn.user?.attachments || [] } : {}),
|
|
161
|
-
...(message.role === "user" && !receipts.persisted(turn) &&
|
|
162
|
-
!(response.admitted === true && message.messageId === record.messageId) ? { receipt: false } : {}),
|
|
163
|
-
status: "completed",
|
|
164
|
-
...projection.messageMetadata(turn)
|
|
165
|
-
})));
|
|
166
|
-
for (const message of response.messages || []) {
|
|
167
|
-
if (message.complete === false && !messages.some((saved) => saved.id === message.id)) {
|
|
168
|
-
const projected = projection.message(message, record, "incomplete");
|
|
169
|
-
if (projected) messages.push({ ...projected, status: response.status });
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return projection.snapshot({ context, record, response, messages, includeAccess,
|
|
173
|
-
readPage: transcriptQuery ? async () => {
|
|
174
|
-
const page = await store.readConversationLogPage(scope, transcriptQuery);
|
|
175
|
-
return { ...page, conversationLog: qualifyTranscript(page.conversationLog, record, response, receipts) };
|
|
176
|
-
} : null
|
|
177
|
-
});
|
|
193
|
+
return projectPersistentConversation(options, context, record, response, turns);
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
export async function startPersistentConversationTurn(options) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createConversationProviderFactory } from "./provider.js";
|
|
2
2
|
import { createConversationDeliveryTiming } from "./deliveryTiming.js";
|
|
3
3
|
import { normalizeConversationConfiguration } from "./configuration.js";
|
|
4
|
-
import { runScopedConversationTurn, readPersistentConversation, startPersistentConversationTurn,
|
|
4
|
+
import { runScopedConversationTurn, readPersistentConversation, projectStoredPersistentConversation, startPersistentConversationTurn,
|
|
5
5
|
inspectPersistentConversationAdmission, preparePersistentConversationChangeover,
|
|
6
6
|
inspectPersistentConversationDelivery, stopPersistentConversation, deletePersistentConversation } from "./providers/scoped.js";
|
|
7
7
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -2186,6 +2186,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2186
2186
|
return Object.freeze({
|
|
2187
2187
|
// Internal host composition uses its existing admission/write lease. These
|
|
2188
2188
|
// original coordinators share the scoped owner and introduce no entry or queue.
|
|
2189
|
+
async projectStoredPersistentConversation(options) {
|
|
2190
|
+
if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
|
|
2191
|
+
return projectStoredPersistentConversation(options);
|
|
2192
|
+
},
|
|
2189
2193
|
async readPersistentConversation(options) {
|
|
2190
2194
|
if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
|
|
2191
2195
|
return readPersistentConversation(options);
|
|
@@ -2300,6 +2304,21 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2300
2304
|
}
|
|
2301
2305
|
return createDriver(prepared.engine, defaultHost).seedRenewalHandover(prepared);
|
|
2302
2306
|
},
|
|
2307
|
+
async updateNativeConversationSettings({ id, context, input = {} } = {}) {
|
|
2308
|
+
if (typeof id !== "string" || !id.trim()) throw new TypeError("A conversation id is required.");
|
|
2309
|
+
await access(context, id, "configure");
|
|
2310
|
+
if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
|
|
2311
|
+
const prepared = await defaultHost?.conversation?.({ id, context, input, operation: "updateSettings" });
|
|
2312
|
+
if (prepared?.sessionId !== id || !prepared.native || !Object.hasOwn(prepared, "context")) {
|
|
2313
|
+
throw new TypeError("Native settings require their authorized host preparation.");
|
|
2314
|
+
}
|
|
2315
|
+
const driver = createDriver(prepared.engine, defaultHost);
|
|
2316
|
+
if (typeof driver.updateConversationSettings !== "function") {
|
|
2317
|
+
throw failure("This provider does not support live native settings.", "conversation_unsupported");
|
|
2318
|
+
}
|
|
2319
|
+
if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
|
|
2320
|
+
return driver.updateConversationSettings({ ...prepared, input });
|
|
2321
|
+
},
|
|
2303
2322
|
// Readiness uses the same authorized host and original native owner without
|
|
2304
2323
|
// opening a retained handle or starting a conversational turn.
|
|
2305
2324
|
async ensureNativeConversation({ id, context } = {}) {
|
|
@@ -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
|
};
|
|
@@ -298,6 +298,7 @@ async function fixture(t, options = {}) {
|
|
|
298
298
|
if (text === "steering") {
|
|
299
299
|
answer.message.content[0].text = "Initial progress";
|
|
300
300
|
answer.message.id = frame.uuid + "-before";
|
|
301
|
+
answer.message.stop_reason = "end_turn";
|
|
301
302
|
record(answer); emit(answer); return;
|
|
302
303
|
}
|
|
303
304
|
if (text === "phases") {
|
|
@@ -434,7 +435,7 @@ test("Claude reports common work and compaction phases and clears phase after se
|
|
|
434
435
|
assert.deepEqual(phases, ["preparing", "working", "compacting", "working", ""]);
|
|
435
436
|
});
|
|
436
437
|
|
|
437
|
-
test("Claude steering interrupts generation and admits a distinct message without replacing its process", async t => {
|
|
438
|
+
test("Claude steering interrupts generation and admits a distinct message without replacing its process", { timeout: 30_000 }, async t => {
|
|
438
439
|
const f = await fixture(t);
|
|
439
440
|
const started = Promise.withResolvers();
|
|
440
441
|
const events = [];
|
|
@@ -2097,7 +2098,7 @@ test("completed Claude native-tool tracking refuses steering a retained marked u
|
|
|
2097
2098
|
} finally { await f.close(); }
|
|
2098
2099
|
});
|
|
2099
2100
|
|
|
2100
|
-
test("completed Claude native-tool tracking preserves ordinary native steering in the opted runtime", async t => {
|
|
2101
|
+
test("completed Claude native-tool tracking preserves ordinary native steering in the opted runtime", { timeout: 30_000 }, async t => {
|
|
2101
2102
|
const f = await completedEnvelopeFixture(t);
|
|
2102
2103
|
const progress = Promise.withResolvers();
|
|
2103
2104
|
await f.conversation.subscribe(event => {
|
|
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import os, { tmpdir } from "node:os";
|
|
4
4
|
import path, { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
-
import { claudePlanUsage, claudeCodeArguments, claudeModelConfiguration, createClaudeCodeProcess, readClaudeCodeAuthStatus, stopClaudeCodeProcess } from "../src/server/conversation/claudeProcess.js";
|
|
6
|
+
import { createClaudeAccountQueries, claudePlanUsage, claudeCodeArguments, claudeModelConfiguration, createClaudeCodeProcess, readClaudeCodeAuthStatus, stopClaudeCodeProcess } from "../src/server/conversation/claudeProcess.js";
|
|
7
7
|
import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
|
|
8
8
|
|
|
9
9
|
test("native command construction preserves tool-free constraints without granting permissions by default", () => {
|
|
@@ -278,6 +278,31 @@ test("Claude handshake failure retains acquired execution custody when cleanup i
|
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
test("Claude observes parent exit after initialization even while a child retains stdout", { timeout: 3_000 }, async t => {
|
|
282
|
+
const { PassThrough, Writable } = await import("node:stream");
|
|
283
|
+
const exited = Promise.withResolvers();
|
|
284
|
+
const failed = Promise.withResolvers();
|
|
285
|
+
const stdout = new PassThrough();
|
|
286
|
+
const stdin = new Writable({ write(chunk, _encoding, done) {
|
|
287
|
+
const frame = JSON.parse(String(chunk));
|
|
288
|
+
stdout.write(JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: frame.request_id, response: {} } }) + "\n");
|
|
289
|
+
done();
|
|
290
|
+
} });
|
|
291
|
+
let failures = 0;
|
|
292
|
+
const native = await createClaudeCodeProcess({ execution: {
|
|
293
|
+
async start() { return { id: "parent-exit", stdin, stdout, exited: exited.promise }; },
|
|
294
|
+
async stop() { return { scopeEmpty: true }; }
|
|
295
|
+
}, onFailure(error) { failures++; failed.resolve(error); } });
|
|
296
|
+
t.after(() => native.stop());
|
|
297
|
+
assert.equal(stdout.readableEnded, false);
|
|
298
|
+
exited.resolve({ code: 1, signal: null });
|
|
299
|
+
const error = await failed.promise;
|
|
300
|
+
assert.equal(error.code, "assistant_claude_observation_lost");
|
|
301
|
+
assert.deepEqual(error.processExit, { code: 1, signal: null });
|
|
302
|
+
await native.client.completion;
|
|
303
|
+
assert.equal(failures, 1);
|
|
304
|
+
});
|
|
305
|
+
|
|
281
306
|
test("Claude initialization rejects confirmed execution exit before half-open protocol EOF", async () => {
|
|
282
307
|
const { PassThrough, Writable } = await import("node:stream");
|
|
283
308
|
for (const cleanup of ["verified", "unconfirmed", "rejected"]) {
|
|
@@ -377,3 +402,116 @@ test("Claude supplied execution hosts retain initialization without an optional
|
|
|
377
402
|
assert.equal(native.initialization.originalHost, true);
|
|
378
403
|
assert.deepEqual(await native.stop(), { scopeEmpty: true, exited: true });
|
|
379
404
|
});
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
test("Claude account queries isolate concurrent identities and deduplicate each account", async () => {
|
|
408
|
+
for (const method of ["readCatalogue", "readPlanUsage"]) {
|
|
409
|
+
const pending = new Map(["first", "second"].map(id => [id, Promise.withResolvers()]));
|
|
410
|
+
const calls = [];
|
|
411
|
+
const queries = createClaudeAccountQueries({
|
|
412
|
+
accountIdentity: async context => context.id,
|
|
413
|
+
processes: () => [...pending].map(([id, held]) => ({ accountIdentity: id, process: {
|
|
414
|
+
get initialization() { calls.push(id); return held.promise; },
|
|
415
|
+
client: { request() { calls.push(id); return held.promise; } }
|
|
416
|
+
} })),
|
|
417
|
+
createProcess() { throw new Error("An owned account process should be reused."); }
|
|
418
|
+
});
|
|
419
|
+
const first = queries[method]({ id: "first" });
|
|
420
|
+
const duplicate = queries[method]({ id: "first" });
|
|
421
|
+
const second = queries[method]({ id: "second" });
|
|
422
|
+
// Finish in reverse order to expose cross-account sharing and cache races.
|
|
423
|
+
pending.get("second").resolve({ models: [{ value: "second" }], rate_limits_available: true,
|
|
424
|
+
rate_limits: { five_hour: { utilization: 20 } } });
|
|
425
|
+
pending.get("first").resolve({ models: [{ value: "first" }], rate_limits_available: true,
|
|
426
|
+
rate_limits: { five_hour: { utilization: 80 } } });
|
|
427
|
+
const [a, same, b] = await Promise.all([first, duplicate, second]);
|
|
428
|
+
assert.deepEqual(calls, ["first", "second"], method);
|
|
429
|
+
assert.deepEqual(same, a);
|
|
430
|
+
if (method === "readCatalogue") {
|
|
431
|
+
assert.equal(a.models[0].value, "first");
|
|
432
|
+
assert.equal(b.models[0].value, "second");
|
|
433
|
+
} else {
|
|
434
|
+
assert.equal(a.identity, "first");
|
|
435
|
+
assert.equal(b.identity, "second");
|
|
436
|
+
assert.equal(a.windows[0].remainingPercent, 20);
|
|
437
|
+
assert.equal(b.windows[0].remainingPercent, 80);
|
|
438
|
+
}
|
|
439
|
+
await queries.invalidate();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("account invalidation cancels held startup and usage without stopping a borrowed conversation", async () => {
|
|
444
|
+
for (const phase of ["startup", "owned usage", "borrowed usage"]) {
|
|
445
|
+
const started = Promise.withResolvers();
|
|
446
|
+
let stops = 0;
|
|
447
|
+
const native = { client: { request() { started.resolve(); return new Promise(() => {}); } },
|
|
448
|
+
async stop() { stops++; return { scopeEmpty: true }; } };
|
|
449
|
+
const queries = createClaudeAccountQueries({ accountIdentity: async () => "account",
|
|
450
|
+
processes: () => phase === "borrowed usage" ? [{ accountIdentity: "account", process: native }] : [],
|
|
451
|
+
createProcess({ signal }) {
|
|
452
|
+
if (phase !== "startup") return native;
|
|
453
|
+
started.resolve();
|
|
454
|
+
return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
const pending = queries.readPlanUsage({});
|
|
458
|
+
const rejected = assert.rejects(pending, /cancelled for reconnection/);
|
|
459
|
+
await started.promise;
|
|
460
|
+
await queries.invalidate();
|
|
461
|
+
await rejected;
|
|
462
|
+
assert.equal(stops, phase === "owned usage" ? 1 : 0, phase);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
for (const method of ["readCatalogue", "readPlanUsage"]) {
|
|
467
|
+
for (const phase of ["initial identity", "final identity", "retained cleanup"]) {
|
|
468
|
+
test(`${method} invalidation fences ${phase} before launching or caching a stale query`, { timeout: 2_000 }, async () => {
|
|
469
|
+
const entered = Promise.withResolvers();
|
|
470
|
+
const release = Promise.withResolvers();
|
|
471
|
+
let identities = 0;
|
|
472
|
+
let starts = 0;
|
|
473
|
+
let stops = 0;
|
|
474
|
+
const queries = createClaudeAccountQueries({
|
|
475
|
+
async accountIdentity() {
|
|
476
|
+
identities++;
|
|
477
|
+
if (identities === (phase === "initial identity" ? 1 : phase === "final identity" ? 2 : -1)) {
|
|
478
|
+
entered.resolve();
|
|
479
|
+
await release.promise;
|
|
480
|
+
}
|
|
481
|
+
return "account";
|
|
482
|
+
},
|
|
483
|
+
createProcess() {
|
|
484
|
+
starts++;
|
|
485
|
+
return {
|
|
486
|
+
initialization: { models: [{ value: `model-${starts}` }] },
|
|
487
|
+
client: { request: async () => ({ rate_limits_available: true, rate_limits: {} }) },
|
|
488
|
+
async stop() {
|
|
489
|
+
stops++;
|
|
490
|
+
if (phase === "retained cleanup" && stops === 1) return { scopeEmpty: false };
|
|
491
|
+
if (phase === "retained cleanup" && stops === 2) {
|
|
492
|
+
entered.resolve();
|
|
493
|
+
await release.promise;
|
|
494
|
+
}
|
|
495
|
+
return { scopeEmpty: true };
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
if (phase === "retained cleanup") await assert.rejects(queries[method]({}), /cleanup could not be confirmed/);
|
|
501
|
+
const pending = queries[method]({}).then(value => ({ value }), failure => ({ failure }));
|
|
502
|
+
await entered.promise;
|
|
503
|
+
const invalidating = queries.invalidate();
|
|
504
|
+
// Initial account lookup has not registered a process or pending query.
|
|
505
|
+
// Let invalidation finish to exercise an old read resuming after reconnect.
|
|
506
|
+
if (phase === "initial identity") await invalidating;
|
|
507
|
+
release.resolve();
|
|
508
|
+
const result = await pending;
|
|
509
|
+
await invalidating;
|
|
510
|
+
assert.match(result.failure?.message || "query unexpectedly succeeded", /cancelled for reconnection/);
|
|
511
|
+
assert.equal(starts, phase === "initial identity" ? 0 : 1, "invalidation must not launch a successor");
|
|
512
|
+
await queries[method]({});
|
|
513
|
+
assert.equal(starts, phase === "initial identity" ? 1 : 2, "a fresh request repopulates the invalidated cache");
|
|
514
|
+
await queries.invalidate();
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|