@jskit-ai/assistant-core 0.2.1 → 0.2.3
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/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +171 -53
- package/src/server/conversation/claudeTurn.js +199 -63
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +272 -10
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +34 -14
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +257 -69
- package/src/server/conversation/providers/claudeDriver.js +7 -5
- package/src/server/conversation/providers/codexDriver.js +23 -16
- package/src/server/conversation/providers/opencodeDriver.js +17 -14
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +93 -6
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +134 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +364 -33
- package/test/claudeTurn.test.js +192 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +52 -1
- package/test/conversationNativeStorage.test.js +284 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- package/test/openCodeRuntime.test.js +25 -78
|
@@ -76,10 +76,10 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
76
76
|
return { text: prepared, content: input.content };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
function withNativeStorageTarget({ native, context }, operation) {
|
|
80
80
|
const prepared = native.preparation.storage(context);
|
|
81
|
-
return native.owner.
|
|
82
|
-
native.owner.startPreparedProcess(await prepared.process())
|
|
81
|
+
return native.owner.withStorageProcess(async () =>
|
|
82
|
+
native.owner.startPreparedProcess(await prepared.process()), operation, prepared.failure);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
async function disposeNative({ native, options }) {
|
|
@@ -127,17 +127,18 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
127
127
|
const prepared = await native.preparation.existing(input, context, { operation: "delete" });
|
|
128
128
|
return native.owner.deletePreparedConversation(prepared, input);
|
|
129
129
|
},
|
|
130
|
-
|
|
131
|
-
const target = await nativeStorageTarget(prepared);
|
|
130
|
+
listConversationStorage(prepared) {
|
|
132
131
|
const { binding, context } = prepared;
|
|
133
|
-
return (
|
|
134
|
-
|
|
132
|
+
return withNativeStorageTarget(prepared, async target =>
|
|
133
|
+
(await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
|
|
134
|
+
.map((row) => ({ ...binding, conversationId: row.id })));
|
|
135
135
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
retireConversationHistory(prepared) {
|
|
137
|
+
return withNativeStorageTarget(prepared, target => {
|
|
138
|
+
// Native status is scoped to the saved project instance, not the control server.
|
|
139
|
+
const client = openCodeServerForDirectory(target.server, prepared.binding.workdir).client;
|
|
140
|
+
return retireOpenCodeConversationHistory(target.server.client, client, prepared.binding, prepared.context);
|
|
141
|
+
});
|
|
141
142
|
},
|
|
142
143
|
closeProject({ native, input }) {
|
|
143
144
|
return native.owner.closeProject(input, native.preparation.projectCleanup());
|
|
@@ -489,13 +490,15 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
489
490
|
const start = host.opencode
|
|
490
491
|
? async () => facilities.runtime.startPreparedProcess(await facilities.prepareServer())
|
|
491
492
|
: () => facilities.start({ signal, connection });
|
|
493
|
+
// A managed host's saved connections reach its running server in place.
|
|
494
|
+
const savedConnections = host.opencode ? async () => (await facilities.prepareServer()).connections : null;
|
|
492
495
|
if (!native) {
|
|
493
496
|
native = await facilities.runtime.acquire(binding.directory, async () => {
|
|
494
|
-
const shared = await facilities.runtime.ensure(facilities.selected, start);
|
|
497
|
+
const shared = await facilities.runtime.ensure(facilities.selected, start, savedConnections);
|
|
495
498
|
return { key: binding.directory, workdir: binding.workdir, upstreamSessionId: binding.sessionId,
|
|
496
499
|
abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, binding.workdir) };
|
|
497
500
|
});
|
|
498
|
-
} else await facilities.runtime.ensure(facilities.selected, start);
|
|
501
|
+
} else await facilities.runtime.ensure(facilities.selected, start, savedConnections);
|
|
499
502
|
signal.throwIfAborted();
|
|
500
503
|
await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
|
|
501
504
|
const catalogue = await native.server.client.providers({ signal });
|
|
@@ -320,7 +320,7 @@ export async function inspectPersistentConversationAdmission(options) {
|
|
|
320
320
|
if (!record.providerConversationId) return { admission: "unknown", turnId: "" };
|
|
321
321
|
const state = requireSuccess(await operations.readConversation(sessionId,
|
|
322
322
|
inputFor(record, { messageId: input.messageId }), options.context));
|
|
323
|
-
return { admission: state.admitted ? "accepted" : "unknown", turnId: state.runId };
|
|
323
|
+
return { admission: state.admitted ? "accepted" : state.notAdmitted ? "not-admitted" : "unknown", turnId: state.runId };
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
export async function preparePersistentConversationChangeover(options) {
|
|
@@ -353,7 +353,10 @@ export async function inspectPersistentConversationDelivery(options) {
|
|
|
353
353
|
const result = await createConversationChangeover({ ...continuity, agent: {
|
|
354
354
|
inspectMessageAdmission: request => inspectMessageAdmission(sessionId, request, context),
|
|
355
355
|
sendMessage() { throw new Error("Receipt inspection cannot submit another native prompt."); }
|
|
356
|
-
} }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata });
|
|
356
|
+
} }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata, inspectOnly: true });
|
|
357
|
+
if (result?.notSent) return { status: "not-sent", messageId: input.messageId,
|
|
358
|
+
error: "This message was not sent. Review it before sending a new message.",
|
|
359
|
+
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
357
360
|
accepted = result?.delivered === true;
|
|
358
361
|
} else if (!accepted) {
|
|
359
362
|
if (row && row.user?.receipt !== false) return { status: "accepted", messageId: input.messageId, turnId: row.turnId, duplicate: true };
|
|
@@ -28,6 +28,9 @@ function failure(message, code, statusCode = 409, { messageCode, messageParams }
|
|
|
28
28
|
return Object.assign(new Error(message), { code, statusCode }, messageCode ? { messageCode, messageParams: messageParams || {} } : {});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The newest messages cancelled in one open conversation, refused if sent again.
|
|
32
|
+
const MAX_CANCELLED_MESSAGES = 100;
|
|
33
|
+
|
|
31
34
|
// A native delivery result keeps its own sentence and messageCode when it has one.
|
|
32
35
|
function deliveryFailure(delivered) {
|
|
33
36
|
const code = delivered?.code || "conversation_delivery_uncertain";
|
|
@@ -1558,6 +1561,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1558
1561
|
deliveryTiming?.("preparation-started");
|
|
1559
1562
|
const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
|
|
1560
1563
|
await access(context, entry.id, operation, entry, grant);
|
|
1564
|
+
if (entry.cancelledMessages.has(input?.messageId)) {
|
|
1565
|
+
throw Object.assign(failure("This message was cancelled. Send it again as a new message.", "conversation_message_cancelled", 409,
|
|
1566
|
+
{ messageCode: "assistant.conversation.messageCancelled" }), { delivery: "not-sent" });
|
|
1567
|
+
}
|
|
1561
1568
|
await entry.conversation?.admission(context, operation);
|
|
1562
1569
|
deliveryTiming?.("authorized");
|
|
1563
1570
|
if (goal) context?.signal?.throwIfAborted();
|
|
@@ -1738,6 +1745,34 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1738
1745
|
return { stopped: true };
|
|
1739
1746
|
}
|
|
1740
1747
|
|
|
1748
|
+
// Cancel of one message in a runtime-owned conversation never waits for the
|
|
1749
|
+
// conversation queue. The message is recorded as cancelled at once, so a send
|
|
1750
|
+
// of it still queued, or arriving later, is refused as not sent; its own
|
|
1751
|
+
// submission stops, ending any turn it started. Its unconfirmed request or
|
|
1752
|
+
// pending native attempt is discarded afterwards, in queue order. A message
|
|
1753
|
+
// already accepted stays history.
|
|
1754
|
+
async function cancelDelivery(entry, context, messageId, representation) {
|
|
1755
|
+
entry.cancelledMessages.delete(messageId);
|
|
1756
|
+
entry.cancelledMessages.add(messageId);
|
|
1757
|
+
if (entry.cancelledMessages.size > MAX_CANCELLED_MESSAGES) entry.cancelledMessages.delete(entry.cancelledMessages.values().next().value);
|
|
1758
|
+
const interrupted = Boolean(entry.active?.requests.has(messageId));
|
|
1759
|
+
if (interrupted) await stop(entry, context, undefined, representation);
|
|
1760
|
+
void serial(entry, async () => {
|
|
1761
|
+
await access(context, entry.id, "cancel", entry);
|
|
1762
|
+
if (entry.conversation) {
|
|
1763
|
+
await nativeChangeover(entry, context, null, () => { throw new Error("Cancel never sends a message."); }).discard({ messageId });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
await storage.write(entry.id, async transaction => {
|
|
1767
|
+
const metadata = await transaction.readMetadata();
|
|
1768
|
+
if (metadata.runtime.request?.messageId !== messageId) return;
|
|
1769
|
+
delete metadata.runtime.request;
|
|
1770
|
+
await transaction.writeMetadata(metadata);
|
|
1771
|
+
});
|
|
1772
|
+
}).catch(() => {});
|
|
1773
|
+
return { ok: true, cancelled: true, messageId, interrupted };
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1741
1776
|
async function readGoal(entry, context, representation) {
|
|
1742
1777
|
await access(context, entry.id, "readGoal", entry);
|
|
1743
1778
|
if (!capabilities(entry).goals) return null;
|
|
@@ -1892,6 +1927,11 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1892
1927
|
return { saved: true };
|
|
1893
1928
|
}
|
|
1894
1929
|
|
|
1930
|
+
function notSentReceipt(messageId) {
|
|
1931
|
+
return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
|
|
1932
|
+
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1895
1935
|
async function recoverDelivery(entry, context, messageId, { observation: suppliedObservation, allowActive = false } = {}) {
|
|
1896
1936
|
await access(context, entry.id, "inspectDelivery", entry);
|
|
1897
1937
|
if (entry.active && !allowActive) throw failure("Wait for the current submission before checking delivery.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForSubmission" });
|
|
@@ -1920,8 +1960,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1920
1960
|
["codex", "claude", "opencode"].includes(segment.engine) && segment.request.attempted === false && !segment.request.inspectionOnly &&
|
|
1921
1961
|
isDeepStrictEqual(segment.request, savedRequests[0].request))) {
|
|
1922
1962
|
await access(context, entry.id, "inspectDelivery", entry);
|
|
1923
|
-
return
|
|
1924
|
-
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
1963
|
+
return notSentReceipt(messageId);
|
|
1925
1964
|
}
|
|
1926
1965
|
const request = existing ? submittedMessage(existing) : metadata.runtime.request;
|
|
1927
1966
|
if (!request || request.messageId !== messageId || !entry.provider.inspectAdmission) return receipt || { status: "unknown", messageId };
|
|
@@ -1930,6 +1969,17 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1930
1969
|
const observation = suppliedObservation || await entry.provider.inspectAdmission({ ...request, nativeTurnId: existing?.metadata?.runtime?.nativeTurnId,
|
|
1931
1970
|
context, configuration: metadata.runtime.configuration });
|
|
1932
1971
|
await access(context, entry.id, "inspectDelivery", entry);
|
|
1972
|
+
// The engine proved it never admitted this saved request. Record that it
|
|
1973
|
+
// was not sent, as a definite rejection would, so Retry may send it.
|
|
1974
|
+
if (!existing && observation.notAdmitted && pending && !pending.inspectionOnly && !entry.active) {
|
|
1975
|
+
await storage.write(entry.id, async transaction => {
|
|
1976
|
+
const current = await transaction.readMetadata();
|
|
1977
|
+
if (current.runtime.request?.messageId !== messageId) return;
|
|
1978
|
+
current.runtime.request.attempted = false;
|
|
1979
|
+
await transaction.writeMetadata(current);
|
|
1980
|
+
});
|
|
1981
|
+
return notSentReceipt(messageId);
|
|
1982
|
+
}
|
|
1933
1983
|
if (!observation.accepted) return { ...(receipt || { status: "unknown", messageId }),
|
|
1934
1984
|
...(observation.recoveryLimitation ? { recoveryLimitation: observation.recoveryLimitation } : {}) };
|
|
1935
1985
|
if (entry.driver.canonicalTranscript) {
|
|
@@ -2046,7 +2096,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2046
2096
|
throw new Error("Receipt inspection cannot submit another native prompt.");
|
|
2047
2097
|
});
|
|
2048
2098
|
const delivered = await sender.send({ engineId: entry.engine,
|
|
2049
|
-
messages: await entry.conversation.transcript.history(), input: { messageId } });
|
|
2099
|
+
messages: await entry.conversation.transcript.history(), input: { messageId }, inspectOnly: true });
|
|
2100
|
+
if (delivered?.notSent) return notSentReceipt(messageId);
|
|
2050
2101
|
if (!delivered?.delivered) throw deliveryFailure(delivered);
|
|
2051
2102
|
}
|
|
2052
2103
|
const row = (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId);
|
|
@@ -2057,7 +2108,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2057
2108
|
await access(context, entry.id, "inspectDelivery", entry);
|
|
2058
2109
|
// Native-only acceptance is evidence of delivery, not permission to
|
|
2059
2110
|
// manufacture an authored row or use a native turn ID as its receipt.
|
|
2060
|
-
|
|
2111
|
+
if (observation.accepted) return { status: "accepted", messageId, duplicate: true };
|
|
2112
|
+
return observation.notAdmitted && !entry.active ? notSentReceipt(messageId) : { status: "unknown", messageId };
|
|
2061
2113
|
}
|
|
2062
2114
|
const result = await recoverDelivery(entry, context, messageId);
|
|
2063
2115
|
if (result.status !== "accepted" || entry.engine === "api") return result;
|
|
@@ -2299,7 +2351,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2299
2351
|
function retireEntry(entry) {
|
|
2300
2352
|
entry.disposed = true;
|
|
2301
2353
|
entry.listeners.clear();
|
|
2302
|
-
streams.
|
|
2354
|
+
// Live streams are kept per conversation id. A logical conversation streams
|
|
2355
|
+
// through its own owner, so the stream under its id belongs to another entry
|
|
2356
|
+
// with the same id, such as its session's Main conversation.
|
|
2357
|
+
if (!entry.conversation?.commands) streams.clear(entry.id);
|
|
2303
2358
|
if (entries.get(entry.key) === entry) entries.delete(entry.key);
|
|
2304
2359
|
}
|
|
2305
2360
|
|
|
@@ -2553,7 +2608,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2553
2608
|
const key = conversation?.namespace || id;
|
|
2554
2609
|
let entry = entries.get(key);
|
|
2555
2610
|
if (!entry) {
|
|
2556
|
-
entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: ""
|
|
2611
|
+
entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: "",
|
|
2612
|
+
cancelledMessages: new Set() };
|
|
2557
2613
|
entries.set(key, entry);
|
|
2558
2614
|
entry.initializing = initialize(entry, configuration, engine).catch(error => {
|
|
2559
2615
|
if (entries.get(key) === entry) entries.delete(key);
|
|
@@ -2606,6 +2662,20 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2606
2662
|
await access(context, id, "read", entry);
|
|
2607
2663
|
return read(entry);
|
|
2608
2664
|
},
|
|
2665
|
+
// Cancel of one unsent or unconfirmed message always records it; an
|
|
2666
|
+
// application conversation supplies its own operation for that.
|
|
2667
|
+
async cancelDelivery({ messageId } = {}) {
|
|
2668
|
+
if (typeof messageId !== "string" || !messageId) throw new TypeError("A message id is required.");
|
|
2669
|
+
await access(context, id, "cancel", entry);
|
|
2670
|
+
if (entry.conversation?.commands) {
|
|
2671
|
+
if (typeof entry.conversation.commands.cancelDelivery !== "function") {
|
|
2672
|
+
throw failure("This conversation cannot cancel one message.", "conversation_unsupported", 400,
|
|
2673
|
+
{ messageCode: "assistant.conversation.messageCancelUnsupported" });
|
|
2674
|
+
}
|
|
2675
|
+
return entry.conversation.commands.cancelDelivery({ messageId }, context);
|
|
2676
|
+
}
|
|
2677
|
+
return cancelDelivery(entry, context, messageId, representation);
|
|
2678
|
+
},
|
|
2609
2679
|
async cancel(input) {
|
|
2610
2680
|
await access(context, id, "cancel", entry);
|
|
2611
2681
|
if (entry.conversation?.commands) return entry.conversation.commands.cancel(input, context);
|
|
@@ -2830,6 +2900,23 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2830
2900
|
});
|
|
2831
2901
|
return retained || cleanupUnopened();
|
|
2832
2902
|
},
|
|
2903
|
+
// Trusted server lifecycle: the application deleted or archived a logical
|
|
2904
|
+
// conversation that it binds through its own commands. Its open entry, if
|
|
2905
|
+
// any, leaves memory and its handles close. Nothing is opened; native and
|
|
2906
|
+
// storage-owned conversations keep their own disposal.
|
|
2907
|
+
async retireOpenConversation({ namespace, sessionId }) {
|
|
2908
|
+
const entry = entries.get(namespace);
|
|
2909
|
+
if (!entry || entry.id !== sessionId) return { retired: false };
|
|
2910
|
+
if (!entry.conversation?.commands) {
|
|
2911
|
+
throw new TypeError("Only a logical conversation bound through its commands retires without disposal.");
|
|
2912
|
+
}
|
|
2913
|
+
await entry.initializing.catch(() => {});
|
|
2914
|
+
return serial(entry, async () => {
|
|
2915
|
+
if (entry.disposed || entries.get(namespace) !== entry) return { retired: false };
|
|
2916
|
+
retireEntry(entry);
|
|
2917
|
+
return { retired: true };
|
|
2918
|
+
});
|
|
2919
|
+
},
|
|
2833
2920
|
// Host publication from the same native owner, through the existing entry.
|
|
2834
2921
|
// This does not subscribe to a provider or retain another observer registry.
|
|
2835
2922
|
async publishNative({ namespace, sessionId, event }) {
|
|
@@ -26,6 +26,31 @@ async function settleReaderAt(body, scrollTop) {
|
|
|
26
26
|
}, scrollTop)).toBe(true);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Prepended history starts at those estimates and materializes on a later frame,
|
|
30
|
+
// when scroll anchoring moves scrollTop. Read positions only once that settles.
|
|
31
|
+
async function settleLayout(body) {
|
|
32
|
+
await expect.poll(() => body.evaluate(async element => {
|
|
33
|
+
const [top, height] = [element.scrollTop, element.scrollHeight];
|
|
34
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
35
|
+
return element.scrollTop === top && element.scrollHeight === height;
|
|
36
|
+
})).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A gesture's scroll ends after its last input event; a touch drag ends with
|
|
40
|
+
// its fling. A scrollend still queued from earlier scrolling must not count, or
|
|
41
|
+
// the reader is measured while the fling keeps moving it.
|
|
42
|
+
async function scrollGesture(body, lastEventType, perform) {
|
|
43
|
+
await body.evaluate((element, type) => {
|
|
44
|
+
element.gestureScrollEnded = new Promise(resolve => {
|
|
45
|
+
element.addEventListener(type, () => {
|
|
46
|
+
element.addEventListener("scrollend", () => resolve(), { once: true });
|
|
47
|
+
}, { once: true, passive: true });
|
|
48
|
+
});
|
|
49
|
+
}, lastEventType);
|
|
50
|
+
await perform();
|
|
51
|
+
await body.evaluate(element => element.gestureScrollEnded);
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
const VIEWPORTS = Object.freeze([
|
|
30
55
|
Object.freeze({ name: "phone", width: 390, height: 844 }),
|
|
31
56
|
Object.freeze({ name: "compact", width: 800, height: 900 }),
|
|
@@ -266,10 +291,12 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
266
291
|
await page.getByRole("button", { name: "Read more", exact: true }).click();
|
|
267
292
|
await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
|
|
268
293
|
const original = page.getByText("Conversation line 1:", { exact: false }).first();
|
|
294
|
+
await settleLayout(body);
|
|
269
295
|
const originalTop = (await original.boundingBox()).y;
|
|
270
296
|
// Measure history anchoring without Playwright first scrolling to the button.
|
|
271
297
|
await page.getByRole("button", { name: "Load older messages", exact: true }).evaluate(element => element.click());
|
|
272
|
-
await
|
|
298
|
+
await settleLayout(body);
|
|
299
|
+
expect(Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
|
|
273
300
|
const before = await body.evaluate((element) => element.scrollTop);
|
|
274
301
|
await page.getByRole("button", { name: "Append reply", exact: true }).click();
|
|
275
302
|
await expect(page.getByText("New reply.", { exact: false })).toBeAttached();
|
|
@@ -370,20 +397,18 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
370
397
|
const cdp = await page.context().newCDPSession(page);
|
|
371
398
|
const x = bounds.x + bounds.width / 2;
|
|
372
399
|
const y = bounds.y + 40;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
await
|
|
380
|
-
}
|
|
381
|
-
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
382
|
-
await stopped;
|
|
400
|
+
await scrollGesture(body, "touchend", async () => {
|
|
401
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
402
|
+
for (let offset = 20; offset <= 380; offset += 20) {
|
|
403
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
|
|
404
|
+
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
405
|
+
}
|
|
406
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
407
|
+
});
|
|
383
408
|
await cdp.detach();
|
|
384
409
|
} else {
|
|
385
410
|
await body.hover();
|
|
386
|
-
await page.mouse.wheel(0, -380);
|
|
411
|
+
await scrollGesture(body, "wheel", () => page.mouse.wheel(0, -380));
|
|
387
412
|
}
|
|
388
413
|
const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
|
|
389
414
|
await expect(pending).toBeDisabled();
|
|
@@ -394,10 +419,12 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
394
419
|
element.dispatchEvent(new Event("scroll"));
|
|
395
420
|
});
|
|
396
421
|
const original = page.getByText("Conversation line 1:", { exact: false }).first();
|
|
422
|
+
await settleLayout(body);
|
|
397
423
|
const originalTop = (await original.boundingBox()).y;
|
|
398
424
|
await external("Complete history load");
|
|
399
425
|
await expect(pending).toHaveCount(0);
|
|
400
|
-
await
|
|
426
|
+
await settleLayout(body);
|
|
427
|
+
expect(Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
|
|
401
428
|
await expect(requests).toHaveText("History requests: 1");
|
|
402
429
|
|
|
403
430
|
// Keyboard navigation also requests older history.
|
|
@@ -421,11 +448,7 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
421
448
|
// Removing the history control re-anchors the scroller, which would
|
|
422
449
|
// cancel a keyboard scroll already in flight; press Home once it settles.
|
|
423
450
|
await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
|
|
424
|
-
await
|
|
425
|
-
const height = element.scrollHeight;
|
|
426
|
-
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
427
|
-
return element.scrollHeight === height;
|
|
428
|
-
})).toBe(true);
|
|
451
|
+
await settleLayout(body);
|
|
429
452
|
await expect.poll(async () => {
|
|
430
453
|
await body.focus();
|
|
431
454
|
return body.evaluate(element => document.activeElement === element);
|
|
@@ -443,6 +466,99 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
443
466
|
}
|
|
444
467
|
});
|
|
445
468
|
|
|
469
|
+
test("a reader's own upward scrolling leaves the latest message from its first frame", {
|
|
470
|
+
skip: !RUN_BROWSER_TEST,
|
|
471
|
+
timeout: 60_000
|
|
472
|
+
}, async () => {
|
|
473
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
474
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
475
|
+
try {
|
|
476
|
+
for (const width of [390, 1365]) {
|
|
477
|
+
const touch = width < 1000;
|
|
478
|
+
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
|
|
479
|
+
const errors = [];
|
|
480
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
481
|
+
await page.goto(`${vite.baseURL}/?controls=1&history=1`);
|
|
482
|
+
const body = page.locator(".assistant-transcript__body");
|
|
483
|
+
const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
484
|
+
const appendReply = async () => {
|
|
485
|
+
const replies = page.getByText("New reply.", { exact: false });
|
|
486
|
+
const count = await replies.count();
|
|
487
|
+
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
488
|
+
await expect(replies).toHaveCount(count + 1);
|
|
489
|
+
await settleLayout(body);
|
|
490
|
+
};
|
|
491
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
492
|
+
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
493
|
+
await settleLayout(body);
|
|
494
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
495
|
+
await expect.poll(async () => {
|
|
496
|
+
await body.focus();
|
|
497
|
+
return body.evaluate(element => document.activeElement === element);
|
|
498
|
+
}).toBe(true);
|
|
499
|
+
|
|
500
|
+
// A short upward wheel or touch scroll stays within the 48px follow
|
|
501
|
+
// threshold, and a touch fling ends without further input events.
|
|
502
|
+
if (touch) {
|
|
503
|
+
const bounds = await body.boundingBox();
|
|
504
|
+
const cdp = await page.context().newCDPSession(page);
|
|
505
|
+
const [x, y] = [bounds.x + bounds.width / 2, bounds.y + 100];
|
|
506
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
507
|
+
for (const offset of [10, 20, 30]) {
|
|
508
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
|
|
509
|
+
}
|
|
510
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
511
|
+
await cdp.detach();
|
|
512
|
+
} else {
|
|
513
|
+
await body.hover();
|
|
514
|
+
await page.mouse.wheel(0, -20);
|
|
515
|
+
}
|
|
516
|
+
await expect.poll(fromBottom).toBeGreaterThan(1);
|
|
517
|
+
await settleLayout(body);
|
|
518
|
+
await appendReply();
|
|
519
|
+
expect(await fromBottom()).toBeGreaterThan(1);
|
|
520
|
+
|
|
521
|
+
// Returning to the end resumes following new replies.
|
|
522
|
+
await body.press("End");
|
|
523
|
+
await expect.poll(fromBottom).toBeLessThanOrEqual(1);
|
|
524
|
+
await appendReply();
|
|
525
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
526
|
+
|
|
527
|
+
// The first frame of an upward scroll lands inside the follow threshold;
|
|
528
|
+
// the rest of it (a fling or smooth scroll) moves the reader into history
|
|
529
|
+
// without further input events.
|
|
530
|
+
await body.press("ArrowUp");
|
|
531
|
+
await expect.poll(fromBottom).toBeGreaterThan(1);
|
|
532
|
+
await settleLayout(body);
|
|
533
|
+
expect(await fromBottom()).toBeLessThanOrEqual(48);
|
|
534
|
+
await body.evaluate(element => { element.scrollTop = Math.round(element.scrollTop / 2); });
|
|
535
|
+
await settleLayout(body);
|
|
536
|
+
await appendReply();
|
|
537
|
+
expect(await fromBottom()).toBeGreaterThan(1_000);
|
|
538
|
+
await body.press("End");
|
|
539
|
+
await expect.poll(fromBottom).toBeLessThanOrEqual(1);
|
|
540
|
+
await appendReply();
|
|
541
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
542
|
+
|
|
543
|
+
// The scroll event of the transcript's own follow scroll can arrive after
|
|
544
|
+
// the key press, before the reader has moved; it must not cancel the
|
|
545
|
+
// reader's scroll.
|
|
546
|
+
await body.evaluate(element => {
|
|
547
|
+
element.addEventListener("keydown", () => element.dispatchEvent(new Event("scroll")), { once: true });
|
|
548
|
+
});
|
|
549
|
+
await body.press("Home");
|
|
550
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
|
|
551
|
+
await appendReply();
|
|
552
|
+
expect(await body.evaluate(element => element.scrollTop)).toBe(0);
|
|
553
|
+
assert.deepEqual(errors, []);
|
|
554
|
+
await page.close();
|
|
555
|
+
}
|
|
556
|
+
} finally {
|
|
557
|
+
await browser.close();
|
|
558
|
+
await stopProcess(vite);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
446
562
|
test("retained hidden transcripts preserve readers and follow only at the latest message", {
|
|
447
563
|
skip: !RUN_BROWSER_TEST,
|
|
448
564
|
timeout: 90_000
|
|
@@ -265,7 +265,7 @@ async function fixture(t, options = {}) {
|
|
|
265
265
|
await writeFile(account, "owner@example.test");
|
|
266
266
|
await writeFile(command, `#!${process.execPath}
|
|
267
267
|
import { createInterface } from "node:readline";
|
|
268
|
-
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
268
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
269
269
|
import path from "node:path";
|
|
270
270
|
const args = process.argv.slice(2);
|
|
271
271
|
const structuredResponse = ${JSON.stringify(options.structuredResponse || null)};
|
|
@@ -308,6 +308,12 @@ async function fixture(t, options = {}) {
|
|
|
308
308
|
if (frame.request.subtype === "interrupt") emit({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
|
|
309
309
|
return;
|
|
310
310
|
}
|
|
311
|
+
// A crash after reading the prompt but before recording it in history.
|
|
312
|
+
const dropped = path.join(process.env.CLAUDE_CONFIG_DIR, "dropped-once");
|
|
313
|
+
if (JSON.stringify(frame.message.content).includes("drop once") && !existsSync(dropped)) {
|
|
314
|
+
writeFileSync(dropped, "");
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
311
317
|
record(frame);
|
|
312
318
|
const text = typeof frame.message.content === "string" ? frame.message.content
|
|
313
319
|
: frame.message.content.filter(part => part.type === "text").map(part => part.text).join(" ");
|
|
@@ -783,6 +789,25 @@ test("uncertain native delivery is reconciled from history without a second infe
|
|
|
783
789
|
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
784
790
|
});
|
|
785
791
|
|
|
792
|
+
test("a Claude prompt missing from history after its process ended is not sent, and Retry sends it", async t => {
|
|
793
|
+
const f = await fixture(t);
|
|
794
|
+
const dropped = { messageId: "dropped-before-history", text: "drop once" };
|
|
795
|
+
await assert.rejects(f.conversation.send(dropped), error => error.delivery === "uncertain");
|
|
796
|
+
assert.equal((await f.conversation.wait()).status, "unconfirmed");
|
|
797
|
+
const receipt = await f.conversation.inspectDelivery({ messageId: dropped.messageId });
|
|
798
|
+
assert.equal(receipt.status, "not-sent");
|
|
799
|
+
assert.equal(receipt.messageCode, "assistant.conversation.messageNotSent");
|
|
800
|
+
// The proof is saved: a reload no longer restores an unconfirmed message.
|
|
801
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
802
|
+
assert.equal((await f.conversation.inspectDelivery({ messageId: dropped.messageId })).status, "not-sent");
|
|
803
|
+
const sent = await f.conversation.send(dropped);
|
|
804
|
+
assert.equal(sent.messageId, dropped.messageId);
|
|
805
|
+
const finished = await f.conversation.wait();
|
|
806
|
+
assert.equal(finished.conversationLog.length, 1);
|
|
807
|
+
assert.equal(finished.conversationLog[0].assistant.text, "Answer: drop once");
|
|
808
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
|
|
809
|
+
});
|
|
810
|
+
|
|
786
811
|
test("inspecting an older accepted Claude message leaves a different pending receipt untouched", async t => {
|
|
787
812
|
const f = await fixture(t);
|
|
788
813
|
const first = await f.conversation.send(input);
|