@jskit-ai/assistant-core 0.2.13 → 0.2.15
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/CHANGELOG.md +346 -0
- package/examples/conversation/package.json +5 -5
- package/locales/en.json +171 -6
- package/package.json +5 -5
- package/src/server/conversation/boundedToolLoop.js +4 -2
- package/src/server/conversation/claudeClient.js +15 -10
- package/src/server/conversation/claudeCommandRun.js +16 -6
- package/src/server/conversation/claudeNativeTurn.js +18 -6
- package/src/server/conversation/claudeProcess.js +20 -8
- package/src/server/conversation/claudeProcessStart.js +1 -1
- package/src/server/conversation/claudeSessionCleanup.js +6 -10
- package/src/server/conversation/claudeTurn.js +3 -3
- package/src/server/conversation/codexAccountReader.js +15 -6
- package/src/server/conversation/codexConversationStops.js +3 -3
- package/src/server/conversation/codexConversations.js +1 -1
- package/src/server/conversation/codexDetachedTurn.js +4 -2
- package/src/server/conversation/codexGoalCommands.js +3 -1
- package/src/server/conversation/codexHelperExecution.js +4 -4
- package/src/server/conversation/codexHelperThreadRecovery.js +2 -2
- package/src/server/conversation/codexHistoryAdapter.js +1 -1
- package/src/server/conversation/codexMessageCommands.js +4 -2
- package/src/server/conversation/codexModelCatalog.js +1 -1
- package/src/server/conversation/codexObservationControl.js +1 -1
- package/src/server/conversation/codexProcess.js +3 -1
- package/src/server/conversation/codexProvider.js +4 -2
- package/src/server/conversation/codexProviderAuth.js +17 -9
- package/src/server/conversation/codexProviderInvalidation.js +6 -2
- package/src/server/conversation/codexProviderThreads.js +4 -2
- package/src/server/conversation/codexProviderTurns.js +4 -2
- package/src/server/conversation/codexRenewalThreads.js +2 -2
- package/src/server/conversation/conversationTurnDelivery.js +3 -1
- package/src/server/conversation/conversationTurns.js +3 -1
- package/src/server/conversation/openCodeProcess.js +5 -9
- package/src/server/conversation/openCodeRuntime.js +3 -1
- package/src/server/conversation/openCodeTurn.js +3 -2
- package/src/server/conversation/providers/claudeDriver.js +12 -4
- package/src/server/conversation/providers/codex.js +8 -4
- package/src/server/conversation/providers/codexDriver.js +23 -10
- package/src/server/conversation/providers/codexDriverConnection.js +18 -6
- package/src/server/conversation/providers/codexDriverTurns.js +3 -1
- package/src/server/conversation/providers/opencode.js +3 -1
- package/src/server/conversation/providers/opencodeDriver.js +3 -1
- package/src/server/conversation/providers/opencodeStandalone.js +37 -13
- package/src/server/conversation/providers/scoped.js +3 -1
- package/src/server/conversation/systemPrompt.js +3 -1
- package/src/server/conversation/tools.js +6 -2
- package/src/shared/assistantSettingsResource.js +14 -2
- package/test/claudeLifecycle.test.js +60 -5
|
@@ -99,7 +99,9 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
99
99
|
|
|
100
100
|
if (!binding || typeof binding.sessionId !== "string" || binding.workdir !== await realpath(workdir) ||
|
|
101
101
|
path.dirname(binding.directory || "") !== stateDirectory) {
|
|
102
|
-
throw new Error("This OpenCode conversation belongs to another working directory or native state directory. Restore its original host configuration.")
|
|
102
|
+
throw Object.assign(new Error("This OpenCode conversation belongs to another working directory or native state directory. Restore its original host configuration."), {
|
|
103
|
+
messageCode: "assistant.opencode.otherWorkingDirectory", messageParams: {}
|
|
104
|
+
});
|
|
103
105
|
}
|
|
104
106
|
let native;
|
|
105
107
|
let toolBridge;
|
|
@@ -151,7 +153,8 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
151
153
|
})().catch(error => {
|
|
152
154
|
error.cleanupFailed = true;
|
|
153
155
|
if (error.code === "assistant_opencode_stop_unverified") {
|
|
154
|
-
error
|
|
156
|
+
Object.assign(error, { message: "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.",
|
|
157
|
+
messageCode: "assistant.opencode.cleanupUnconfirmed", messageParams: {} });
|
|
155
158
|
}
|
|
156
159
|
throw error;
|
|
157
160
|
}).finally(() => { stopping = null; });
|
|
@@ -175,11 +178,15 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
175
178
|
binding.runtimeDirectory && binding.runtimeDirectory !== preparedFacilities.runtimeDirectory ||
|
|
176
179
|
facilities && facilities.runtime !== preparedFacilities.runtime
|
|
177
180
|
)) {
|
|
178
|
-
throw new Error("This OpenCode conversation belongs to another native history store or runtime. Restore its original host configuration.")
|
|
181
|
+
throw Object.assign(new Error("This OpenCode conversation belongs to another native history store or runtime. Restore its original host configuration."), {
|
|
182
|
+
messageCode: "assistant.opencode.otherHistoryStoreOrRuntime", messageParams: {}
|
|
183
|
+
});
|
|
179
184
|
}
|
|
180
185
|
const allowCredentialRefresh = authoredPreparation && host.opencode && preparedFacilities.allowCredentialRefresh === true;
|
|
181
186
|
if (binding.accountIdentity && identity !== binding.accountIdentity && !allowCredentialRefresh) {
|
|
182
|
-
throw new Error("This conversation belongs to another OpenCode account. Restore that connection or start a new conversation.")
|
|
187
|
+
throw Object.assign(new Error("This conversation belongs to another OpenCode account. Restore that connection or start a new conversation."), {
|
|
188
|
+
messageCode: "assistant.opencode.otherAccount", messageParams: {}
|
|
189
|
+
});
|
|
183
190
|
}
|
|
184
191
|
if (binding.accountIdentity !== identity) await updateBinding({ accountIdentity: identity });
|
|
185
192
|
const model = { providerID: connection.providerId, id: connection.model };
|
|
@@ -223,7 +230,9 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
223
230
|
};
|
|
224
231
|
}
|
|
225
232
|
if (binding.databasePath && binding.databasePath !== facilities.databasePath) {
|
|
226
|
-
throw new Error("This OpenCode conversation belongs to another native history store. Restore its original host configuration.")
|
|
233
|
+
throw Object.assign(new Error("This OpenCode conversation belongs to another native history store. Restore its original host configuration."), {
|
|
234
|
+
messageCode: "assistant.opencode.otherHistoryStore", messageParams: {}
|
|
235
|
+
});
|
|
227
236
|
}
|
|
228
237
|
await updateBinding({ databasePath: facilities.databasePath, runtimeDirectory: facilities.runtimeDirectory });
|
|
229
238
|
}
|
|
@@ -257,19 +266,25 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
257
266
|
await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
|
|
258
267
|
const catalogue = await native.server.client.providers({ signal });
|
|
259
268
|
const selected = catalogue.all.find(provider => provider.id === connection.providerId)?.models?.[connection.model];
|
|
260
|
-
if (selected?.id !== connection.model) throw new Error("OpenCode could not load the configured model definition. Check the selected integration before sending.")
|
|
269
|
+
if (selected?.id !== connection.model) throw Object.assign(new Error("OpenCode could not load the configured model definition. Check the selected integration before sending."), {
|
|
270
|
+
messageCode: "assistant.opencode.modelDefinitionUnavailable", messageParams: {}
|
|
271
|
+
});
|
|
261
272
|
processVariants = selected.variants || {};
|
|
262
273
|
} catch (error) {
|
|
263
274
|
if (error.stopProof?.scopeEmpty) await updateBinding({ executionId: "", processDirectory: "" });
|
|
264
275
|
throw error;
|
|
265
276
|
}
|
|
266
277
|
if (configuration.effort) {
|
|
267
|
-
if (!Object.hasOwn(processVariants, configuration.effort)) throw new Error("The selected OpenCode model does not support this reasoning effort.")
|
|
278
|
+
if (!Object.hasOwn(processVariants, configuration.effort)) throw Object.assign(new Error("The selected OpenCode model does not support this reasoning effort."), {
|
|
279
|
+
messageCode: "assistant.opencode.reasoningEffortUnsupported", messageParams: {}
|
|
280
|
+
});
|
|
268
281
|
model.variant = configuration.effort;
|
|
269
282
|
}
|
|
270
283
|
const retainedId = binding.sessionId;
|
|
271
284
|
if (retainedId && (await native.server.client.sessionStatus(retainedId, { signal })).type !== "idle") {
|
|
272
|
-
throw new Error("The native OpenCode conversation is still working. Stop or inspect it before sending another message.")
|
|
285
|
+
throw Object.assign(new Error("The native OpenCode conversation is still working. Stop or inspect it before sending another message."), {
|
|
286
|
+
messageCode: "assistant.opencode.nativeStillWorking", messageParams: {}
|
|
287
|
+
});
|
|
273
288
|
}
|
|
274
289
|
const session = {
|
|
275
290
|
selection: { agentId: agent, modelId: model.id, modelProviderId: model.providerID, variantId: model.variant || "" },
|
|
@@ -277,7 +292,9 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
277
292
|
invalidIdentity: () => new Error("OpenCode did not acknowledge a native conversation."),
|
|
278
293
|
identity: {
|
|
279
294
|
async write(nativeId) {
|
|
280
|
-
if (retainedId && nativeId !== retainedId) throw new Error("The saved OpenCode conversation is unavailable. Restore its native history before continuing.")
|
|
295
|
+
if (retainedId && nativeId !== retainedId) throw Object.assign(new Error("The saved OpenCode conversation is unavailable. Restore its native history before continuing."), {
|
|
296
|
+
messageCode: "assistant.opencode.savedConversationUnavailable", messageParams: {}
|
|
297
|
+
});
|
|
281
298
|
if (binding.sessionId !== nativeId) await updateBinding({ sessionId: nativeId });
|
|
282
299
|
},
|
|
283
300
|
publish(nativeId) {
|
|
@@ -390,7 +407,9 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
390
407
|
monitor: messageMonitor(active, command)
|
|
391
408
|
});
|
|
392
409
|
if (value.failure) throw value.failure.error;
|
|
393
|
-
if (!value.delivered) throw new Error("OpenCode did not acknowledge the message.")
|
|
410
|
+
if (!value.delivered) throw Object.assign(new Error("OpenCode did not acknowledge the message."), {
|
|
411
|
+
messageCode: "assistant.opencode.messageNotAcknowledged", messageParams: {}
|
|
412
|
+
});
|
|
394
413
|
} catch (error) {
|
|
395
414
|
const turn = facilities.runtime.turns.get(binding.directory);
|
|
396
415
|
const attempted = value?.failure ? value.failure.attempted : turn?.promptAttempted === true;
|
|
@@ -427,11 +446,15 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
427
446
|
text: configuration.outputSchema ? openCodeStructuredOutput(openCodeAssistantMessageText(message)) : openCodeAssistantMessageText(message) })) };
|
|
428
447
|
},
|
|
429
448
|
steer(command) {
|
|
430
|
-
if (!current || current.finished) throw new Error("OpenCode has no active turn to steer.")
|
|
449
|
+
if (!current || current.finished) throw Object.assign(new Error("OpenCode has no active turn to steer."), {
|
|
450
|
+
messageCode: "assistant.opencode.noTurnToSteer", messageParams: {}
|
|
451
|
+
});
|
|
431
452
|
return send(command);
|
|
432
453
|
},
|
|
433
454
|
async run(command) {
|
|
434
|
-
if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.")
|
|
455
|
+
if (disposed || current) throw Object.assign(new Error("This OpenCode conversation is closed or already working."), {
|
|
456
|
+
messageCode: "assistant.opencode.closedOrWorking", messageParams: {}
|
|
457
|
+
});
|
|
435
458
|
const active = current = { tools: command.tools, configuration: command.configuration,
|
|
436
459
|
onMessage: command.onMessage, onEvent: command.onEvent, messageIds: new Map(), previous: new Map() };
|
|
437
460
|
let problem;
|
|
@@ -461,7 +484,8 @@ export async function openStandaloneOpenCodeConversation({
|
|
|
461
484
|
}
|
|
462
485
|
} catch (error) {
|
|
463
486
|
problem = error.cleanupFailed ? error : command.signal.aborted ? command.signal.reason : error;
|
|
464
|
-
if (problem.cleanupFailed) problem
|
|
487
|
+
if (problem.cleanupFailed) Object.assign(problem, { message: "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.",
|
|
488
|
+
messageCode: "assistant.opencode.cleanupUnconfirmed", messageParams: {} });
|
|
465
489
|
}
|
|
466
490
|
active.finished = true;
|
|
467
491
|
try { if (!problem?.cleanupFailed && (problem || command.signal.aborted)) await stop(); }
|
|
@@ -376,7 +376,9 @@ export async function preparePersistentConversationChangeover(options) {
|
|
|
376
376
|
const current = await records.read(context, record.conversationId);
|
|
377
377
|
const native = current.providerConversationId ? requireSuccess(await operations.readConversation(sessionId,
|
|
378
378
|
records.input(current), context)) : { status: "ready" };
|
|
379
|
-
if (["starting", "inProgress"].includes(native.status)) throw new Error("Stop this temporary conversation before changing its AI.")
|
|
379
|
+
if (["starting", "inProgress"].includes(native.status)) throw Object.assign(new Error("Stop this temporary conversation before changing its AI."), {
|
|
380
|
+
messageCode: "assistant.conversation.stopTemporaryBeforeChange", messageParams: {}
|
|
381
|
+
});
|
|
380
382
|
record = await records.read(context, record.conversationId);
|
|
381
383
|
if (record.providerConversationId) {
|
|
382
384
|
requireSuccess(await operations.stopConversation(sessionId, records.input(record, { runId: record.runId }), context));
|
|
@@ -41,7 +41,9 @@ export function createConversationSystemPrompt() {
|
|
|
41
41
|
}
|
|
42
42
|
// Do not let a caller start inference against an obsolete installation.
|
|
43
43
|
if (generation !== startedGeneration) {
|
|
44
|
-
throw new Error("Conversation context changed while installing its system prompt. Retry before sending.")
|
|
44
|
+
throw Object.assign(new Error("Conversation context changed while installing its system prompt. Retry before sending."), {
|
|
45
|
+
messageCode: "assistant.conversation.contextChangedInstallingPrompt", messageParams: {}
|
|
46
|
+
});
|
|
45
47
|
}
|
|
46
48
|
installed = { systemPrompt, contextIdentity, value };
|
|
47
49
|
return value;
|
|
@@ -61,9 +61,13 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
|
|
|
61
61
|
signal.throwIfAborted();
|
|
62
62
|
if (typeof id !== "string" || !id || id.length > 256 || typeof name !== "string" || !name || name.length > 256 ||
|
|
63
63
|
typeof argumentsText !== "string") throw new TypeError("An application tool requires its call id, name and JSON arguments.");
|
|
64
|
-
if (Buffer.byteLength(argumentsText) > catalog.limits.maxToolArgumentBytes) throw new Error("The application tool arguments exceed their size limit.")
|
|
64
|
+
if (Buffer.byteLength(argumentsText) > catalog.limits.maxToolArgumentBytes) throw Object.assign(new Error("The application tool arguments exceed their size limit."), {
|
|
65
|
+
messageCode: "assistant.conversation.toolArgumentsTooLarge", messageParams: {}
|
|
66
|
+
});
|
|
65
67
|
const input = { id, name, arguments: argumentsText };
|
|
66
|
-
if (++requested > maximumCalls) throw new Error("The assistant reached the application tool-call limit for this turn.")
|
|
68
|
+
if (++requested > maximumCalls) throw Object.assign(new Error("The assistant reached the application tool-call limit for this turn."), {
|
|
69
|
+
messageCode: "assistant.conversation.toolCallLimit", messageParams: {}
|
|
70
|
+
});
|
|
67
71
|
const recorded = calls.get(id);
|
|
68
72
|
if (recorded) {
|
|
69
73
|
if (!isDeepStrictEqual(input, { id: recorded.id, name: recorded.name, arguments: recorded.arguments })) {
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
2
|
import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
|
|
3
|
+
import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
|
|
3
4
|
import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
|
|
4
5
|
|
|
5
6
|
const MAX_SYSTEM_PROMPT_CHARS = 12_000;
|
|
6
7
|
|
|
8
|
+
// Each field message: its English and the code a client renders it from in the
|
|
9
|
+
// user's language, the { code, message, params } form of resource field messages.
|
|
10
|
+
const ASSISTANT_SETTINGS_FIELD_MESSAGES = deepFreeze({
|
|
11
|
+
systemPromptTooLong: {
|
|
12
|
+
code: "assistant.settings.systemPromptTooLong",
|
|
13
|
+
message: `Assistant system prompt must be at most ${MAX_SYSTEM_PROMPT_CHARS} characters.`,
|
|
14
|
+
params: { max: MAX_SYSTEM_PROMPT_CHARS }
|
|
15
|
+
},
|
|
16
|
+
systemPromptInvalid: { code: "assistant.settings.systemPromptInvalid", message: "Assistant system prompt must be valid text." }
|
|
17
|
+
});
|
|
18
|
+
|
|
7
19
|
const assistantConfigRecordSchema = createSchema({
|
|
8
20
|
targetSurfaceId: {
|
|
9
21
|
type: "string",
|
|
@@ -43,8 +55,8 @@ const assistantConfigPatchSchema = createSchema({
|
|
|
43
55
|
required: false,
|
|
44
56
|
maxLength: MAX_SYSTEM_PROMPT_CHARS,
|
|
45
57
|
messages: {
|
|
46
|
-
maxLength:
|
|
47
|
-
default:
|
|
58
|
+
maxLength: ASSISTANT_SETTINGS_FIELD_MESSAGES.systemPromptTooLong,
|
|
59
|
+
default: ASSISTANT_SETTINGS_FIELD_MESSAGES.systemPromptInvalid
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
});
|
|
@@ -18,10 +18,11 @@ const IDLE_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
|
|
18
18
|
// A host with Vibe64's storage rules: every save writes the session record, an
|
|
19
19
|
// archived session refuses writes, saved conversations are re-read from storage
|
|
20
20
|
// and a session whose workspace is gone cannot prepare a conversation context.
|
|
21
|
-
|
|
21
|
+
// A running turn's end first runs the host's checkpoint, a Git snapshot there.
|
|
22
|
+
async function lifecycleFixture(t, { turnEndCheckpoint = async () => {} } = {}) {
|
|
22
23
|
const root = await mkdtemp(path.join(os.tmpdir(), "claude-lifecycle-"));
|
|
23
24
|
const sessions = new Map();
|
|
24
|
-
const calls = { saves: [], reads: 0, stops: [] };
|
|
25
|
+
const calls = { saves: [], reads: 0, stops: [], checkpoints: [] };
|
|
25
26
|
const processes = [];
|
|
26
27
|
const session = sessionId => {
|
|
27
28
|
if (!sessions.has(sessionId)) {
|
|
@@ -83,7 +84,13 @@ async function lifecycleFixture(t) {
|
|
|
83
84
|
}
|
|
84
85
|
},
|
|
85
86
|
// As in Vibe64, the native turn's own checkpoints write the session record.
|
|
86
|
-
async onEvent(entry, event) {
|
|
87
|
+
async onEvent(entry, event) {
|
|
88
|
+
if (event.type === "before-state" && !event.active && entry.turn?.active) {
|
|
89
|
+
calls.checkpoints.push(`${entry.context.sessionId}:${event.state}`);
|
|
90
|
+
await turnEndCheckpoint();
|
|
91
|
+
}
|
|
92
|
+
if (event.type === "save") await store.save(entry);
|
|
93
|
+
}
|
|
87
94
|
});
|
|
88
95
|
owner = create();
|
|
89
96
|
const terminals = [];
|
|
@@ -199,9 +206,40 @@ test("Claude shutdown stops sessions together and never rewrites an idle chat",
|
|
|
199
206
|
assert.equal(result.ok, true);
|
|
200
207
|
assert.deepEqual(running.map(entry => entry.process), [null, null, null]);
|
|
201
208
|
const before = f.calls.saves.length;
|
|
202
|
-
// Shutdown invalidates more than once;
|
|
209
|
+
// Shutdown invalidates more than once; nothing is left to stop or write.
|
|
203
210
|
assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
|
|
204
|
-
assert.equal(f.calls.saves.length, before, "
|
|
211
|
+
assert.equal(f.calls.saves.length, before, "the chats were not written again");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("Claude shutdown leaves a running turn's end and its checkpoint to the next server", { timeout: 5_000 }, async t => {
|
|
215
|
+
// The host's checkpoint can take longer than the server has to stop.
|
|
216
|
+
const checkpoint = Promise.withResolvers();
|
|
217
|
+
let checkpointWaits = true;
|
|
218
|
+
const f = await lifecycleFixture(t, { turnEndCheckpoint: () => checkpointWaits ? checkpoint.promise : undefined });
|
|
219
|
+
const a = await f.running("a");
|
|
220
|
+
const saved = f.session("a").records.get(MAIN_IDS.a);
|
|
221
|
+
let timer;
|
|
222
|
+
const result = await Promise.race([
|
|
223
|
+
f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account),
|
|
224
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Shutdown waited for the turn's checkpoint")), 1_000); })
|
|
225
|
+
]).finally(() => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
checkpoint.resolve();
|
|
228
|
+
});
|
|
229
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
230
|
+
assert.equal(a.process, null, "the process stopped");
|
|
231
|
+
assert.deepEqual(f.calls.checkpoints, [], "shutdown ran no checkpoint");
|
|
232
|
+
assert.equal(f.session("a").records.get(MAIN_IDS.a), saved, "the saved record was not rewritten");
|
|
233
|
+
const custody = ({ executionId, state }) => ({ executionId, state });
|
|
234
|
+
assert.deepEqual(custody(JSON.parse(saved)), { executionId: "execution-0", state: "active" });
|
|
235
|
+
// The next server's close proves that exit, then interrupts the turn through its checkpoint.
|
|
236
|
+
checkpointWaits = false;
|
|
237
|
+
f.restart();
|
|
238
|
+
const stops = f.calls.stops.length;
|
|
239
|
+
assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
|
|
240
|
+
assert.deepEqual(f.calls.stops.slice(stops), ["execution-0"]);
|
|
241
|
+
assert.deepEqual(f.calls.checkpoints, ["a:interrupted"]);
|
|
242
|
+
assert.deepEqual(custody(JSON.parse(f.session("a").records.get(MAIN_IDS.a))), { executionId: "", state: "interrupted" });
|
|
205
243
|
});
|
|
206
244
|
|
|
207
245
|
test("Claude project close releases its sessions from memory without reopening saved conversations", async t => {
|
|
@@ -280,6 +318,23 @@ test("closing a Claude session reopens only the saved custody an earlier server
|
|
|
280
318
|
new RegExp(`saved Claude conversation ${IDLE_ID} is unreadable`));
|
|
281
319
|
});
|
|
282
320
|
|
|
321
|
+
test("reconciling a Claude session reopens only the saved custody an earlier server left", async t => {
|
|
322
|
+
const f = await lifecycleFixture(t);
|
|
323
|
+
const records = f.session("a").records;
|
|
324
|
+
const idle = JSON.stringify({ state: "completed", main: false });
|
|
325
|
+
records.set(MAIN_IDS.a, JSON.stringify({ state: "completed", turnId: "done", main: true }));
|
|
326
|
+
records.set(IDLE_ID, idle);
|
|
327
|
+
records.set(TEMPORARY_ID, JSON.stringify({ executionId: "execution-before-restart", state: "active", turnId: "turn", main: false }));
|
|
328
|
+
const result = await f.owner.reconcileSessions([{ sessionId: "a" }], {}, f.application);
|
|
329
|
+
assert.equal(result.ok, true);
|
|
330
|
+
assert.deepEqual(result.results.map(({ ok, sessionId }) => [sessionId, ok]), [["a", true]]);
|
|
331
|
+
assert.deepEqual(f.calls.stops, ["execution-before-restart"], "the saved execution's exit was proven");
|
|
332
|
+
assert.equal(JSON.parse(records.get(TEMPORARY_ID)).executionId, "");
|
|
333
|
+
assert.equal(JSON.parse(records.get(TEMPORARY_ID)).state, "interrupted");
|
|
334
|
+
assert.deepEqual([...f.owner.entries.values()].map(entry => entry.id), [TEMPORARY_ID], "idle saved chats stayed closed");
|
|
335
|
+
assert.equal(records.get(IDLE_ID), idle, "an idle saved chat was not rewritten");
|
|
336
|
+
});
|
|
337
|
+
|
|
283
338
|
test("after shutdown invalidation a Claude session close reopens nothing", async t => {
|
|
284
339
|
const f = await lifecycleFixture(t);
|
|
285
340
|
await f.idle("a");
|