@jskit-ai/assistant-core 0.2.17 → 0.2.19
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 +1 -0
- package/examples/conversation/package.json +5 -5
- package/package.json +6 -6
- package/src/server/conversation/codexNotificationObserver.js +3 -1
- package/src/server/conversation/codexPromptDispatch.js +5 -1
- package/src/server/conversation/codexRunJournal.js +3 -0
- package/src/server/conversation/codexThreadStatus.js +22 -1
- package/src/server/conversation/codexTurnRecovery.js +12 -1
- package/test/apiConversationDriver.test.js +8 -4
- package/test/codexTurn.test.js +45 -0
- package/test/conversationApiFacilities.test.js +4 -3
- package/test/conversationAttachments.test.js +4 -4
- package/test/conversationReplacement.test.js +1 -1
- package/test/conversationSession.test.js +11 -10
- package/test/conversationTools.test.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- The API driver's state note, which carries the current time, follows the conversation as its last message instead of ending the system prompt, so the provider's prompt cache keeps the whole history from one turn to the next instead of charging it uncached on each turn's first model call (`@jskit-ai/agent` `runLoop`).
|
|
5
6
|
- Assistant failures that reach a person translate: 63 more undotted codes render from `jskit.errors.<code>`, and 98 sentences that share a code with others, or are sent without one, carry a `messageCode` (`assistant.claude.*`, `assistant.codex.*`, `assistant.opencode.*`, `assistant.conversation.*`, `assistant.turn.*`). The English messages are unchanged.
|
|
6
7
|
- The OpenCode version mismatch, Claude control-request timeout and unreadable saved Claude conversation errors carry `params` for their catalog English.
|
|
7
8
|
- The assistant settings resource sends its system prompt field messages in the coded `{ code, message, params }` form.
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
13
|
-
"@jskit-ai/kernel": "0.2.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.2.19",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.2.9",
|
|
13
|
+
"@jskit-ai/kernel": "0.2.9",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.2.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.2.10",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.2.10"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
51
51
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
52
52
|
"@ai-sdk/xai": "^4.0.58",
|
|
53
|
-
"@jskit-ai/agent": "0.1.
|
|
54
|
-
"@jskit-ai/resource-core": "0.1.
|
|
55
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
53
|
+
"@jskit-ai/agent": "0.1.1",
|
|
54
|
+
"@jskit-ai/resource-core": "0.1.200",
|
|
55
|
+
"@jskit-ai/resource-crud-core": "0.1.200",
|
|
56
56
|
"@mdi/js": "^7.4.47",
|
|
57
57
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
58
58
|
"ai": "^7.0.152",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"ws": "^8.20.1"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
65
|
-
"@jskit-ai/kernel": "0.2.
|
|
64
|
+
"@jskit-ai/http-runtime": "0.2.9",
|
|
65
|
+
"@jskit-ai/kernel": "0.2.9",
|
|
66
66
|
"vue": "^3.5.13",
|
|
67
67
|
"vuetify": "^4.0.0"
|
|
68
68
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerTurnKey, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerProviderConnectionGeneration } from "./codexModelCatalog.js";
|
|
3
3
|
import {
|
|
4
4
|
codexAppServerAssistantItemText,
|
|
@@ -239,6 +239,8 @@ export function createCodexNotificationObserver({
|
|
|
239
239
|
threadId: normalizedThreadId,
|
|
240
240
|
turnId: codexAppServerNotificationTurnId(notification)
|
|
241
241
|
};
|
|
242
|
+
// Anything Codex reports about a turn means it has started that turn.
|
|
243
|
+
journal.unstartedTurns.delete(codexAppServerTurnKey(normalizedThreadId, notificationContext.turnId));
|
|
242
244
|
if (method === "thread/tokenUsage/updated") {
|
|
243
245
|
notificationQueue.run(notificationContext, async () => {
|
|
244
246
|
const store = await createStore(normalizedSessionId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred } from "./codexTurnState.js";
|
|
1
|
+
import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred, codexAppServerTurnKey } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerRequestIsInvalid } from "./codexProviderThreads.js";
|
|
3
3
|
import { codexLocalImageInput, codexAppServerDeliveredTurn, sendCodexAppServerPrompt } from "./codexProviderTurns.js";
|
|
4
4
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
@@ -74,6 +74,10 @@ export function createCodexPromptDispatch({
|
|
|
74
74
|
if (!deliveredTurnId) {
|
|
75
75
|
throw new Error("Codex app-server accepted the prompt without returning a turn id.");
|
|
76
76
|
}
|
|
77
|
+
// Codex answers turn/start before it starts the turn; its notifications about the turn say when it has.
|
|
78
|
+
if (!codexAppServerTurnStatusIsComplete(deliveredTurnStatus)) {
|
|
79
|
+
journal.unstartedTurns.add(codexAppServerTurnKey(threadId, deliveredTurnId));
|
|
80
|
+
}
|
|
77
81
|
delivery.pendingUserMessage(promptSessionId, clientUserMessageId)?.onNativeReceipt();
|
|
78
82
|
await recovery.markTurnActive(sessionId, {
|
|
79
83
|
requireTrackedTurn: true,
|
|
@@ -88,6 +88,8 @@ export function createCodexRunJournal({
|
|
|
88
88
|
isClosing
|
|
89
89
|
}) {
|
|
90
90
|
const codexAppServerCompletedTurns = new Set();
|
|
91
|
+
// Turns Codex accepted with turn/start but has not yet reported: until it does, its thread still reads idle.
|
|
92
|
+
const codexAppServerUnstartedTurns = new Set();
|
|
91
93
|
const codexAppServerProcessedTurns = new Set();
|
|
92
94
|
const codexAppServerActiveTimers = new Map();
|
|
93
95
|
const codexAppServerFinalizingTimers = new Map();
|
|
@@ -637,6 +639,7 @@ export function createCodexRunJournal({
|
|
|
637
639
|
|
|
638
640
|
return Object.freeze({
|
|
639
641
|
completedTurns: codexAppServerCompletedTurns,
|
|
642
|
+
unstartedTurns: codexAppServerUnstartedTurns,
|
|
640
643
|
processedTurns: codexAppServerProcessedTurns,
|
|
641
644
|
activeTimers: codexAppServerActiveTimers,
|
|
642
645
|
finalizingTimers: codexAppServerFinalizingTimers,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage } from "./codexTurnState.js";
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage, codexAppServerTurnKey } from "./codexTurnState.js";
|
|
2
2
|
import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
import { codexAppServerStatusFromValue } from "./codexEvents.js";
|
|
4
4
|
import { codexAppServerThreadRawValue, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
|
|
@@ -200,6 +200,27 @@ export function createCodexThreadStatus({ journal, settlement, recovery, output,
|
|
|
200
200
|
turnId
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
|
+
// Codex has accepted the tracked turn but not started it. Until it does, its thread reads idle and lists the
|
|
204
|
+
// turn as interrupted, so only Codex's own report about the turn settles it.
|
|
205
|
+
if (
|
|
206
|
+
trackedProviderTurn?.state === "active" &&
|
|
207
|
+
!codexAppServerTurnStatusIsActive(status) &&
|
|
208
|
+
journal.unstartedTurns.has(codexAppServerTurnKey(trackedProviderTurn.threadId, trackedProviderTurn.turnId))
|
|
209
|
+
) {
|
|
210
|
+
debugLog("appServerThread.reconcile.awaitingProviderStart", {
|
|
211
|
+
sessionId: normalizedSessionId,
|
|
212
|
+
source: normalizeText(source),
|
|
213
|
+
status,
|
|
214
|
+
threadId: trackedProviderTurn.threadId,
|
|
215
|
+
turnId: trackedProviderTurn.turnId
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
reason: "provider_turn_not_started",
|
|
220
|
+
status: trackedProviderTurn.status || "inProgress",
|
|
221
|
+
turnId: trackedProviderTurn.turnId
|
|
222
|
+
};
|
|
223
|
+
}
|
|
203
224
|
if (
|
|
204
225
|
trackedProviderTurn &&
|
|
205
226
|
turnId &&
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor } from "./codexTurnState.js";
|
|
1
|
+
import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor, codexAppServerTurnKey } from "./codexTurnState.js";
|
|
2
2
|
import { codexAppServerProviderTurnForOperation, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
|
|
3
3
|
import { createCodexTurnActivity } from "./codexTurnActivity.js";
|
|
4
4
|
import { createCodexTurnEndings } from "./codexTurnEndings.js";
|
|
@@ -270,6 +270,17 @@ export function createCodexTurnRecovery({
|
|
|
270
270
|
if (!codexAppServerTurnStatusIsComplete(status)) {
|
|
271
271
|
return currentSession;
|
|
272
272
|
}
|
|
273
|
+
if (journal.unstartedTurns.has(codexAppServerTurnKey(currentTurn.threadId, currentTurn.turnId))) {
|
|
274
|
+
// Codex has accepted this turn but not started it, and its thread reads idle until it does.
|
|
275
|
+
debugLog("appServerTurn.reconcile.awaitingProviderStart", {
|
|
276
|
+
sessionId,
|
|
277
|
+
status,
|
|
278
|
+
threadId: currentTurn.threadId,
|
|
279
|
+
turnId: currentTurn.turnId
|
|
280
|
+
});
|
|
281
|
+
scheduleCodexAppServerActiveRecovery(sessionId);
|
|
282
|
+
return currentSession;
|
|
283
|
+
}
|
|
273
284
|
const completedTurnId = providerTurnId || currentTurn.turnId;
|
|
274
285
|
debugLog("appServerTurn.reconcile.complete", {
|
|
275
286
|
sessionId,
|
|
@@ -6,7 +6,8 @@ import { createApiConversationDriver } from "../src/server/conversation/provider
|
|
|
6
6
|
const CHECKS = checkTexts("the app");
|
|
7
7
|
const frame = (delta, finish_reason) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
|
|
8
8
|
const schema = tool => ({ type: "function", function: { name: tool.name, parameters: tool.parameters } });
|
|
9
|
-
|
|
9
|
+
// The engine's state note ends every request's conversation; what the app sent last comes before it.
|
|
10
|
+
const lastText = body => body.messages.at(-2).content;
|
|
10
11
|
|
|
11
12
|
// One step of a streamed reply: words, an action call or both. Calls the engine
|
|
12
13
|
// makes without streaming (accounts, checks, summaries) answer with `once`.
|
|
@@ -113,7 +114,10 @@ test("admission precedes every model call, the state note gives the time, and a
|
|
|
113
114
|
await f.run("What day is it today?");
|
|
114
115
|
assert.deepEqual(f.order, ["accept", "model"]);
|
|
115
116
|
assert.deepEqual(f.executions, [], "No action runs before the model asks for one");
|
|
116
|
-
assert.
|
|
117
|
+
assert.equal(f.requests[0].messages[0].content, "Use the supplied actions.", "The state note is not in the system prompt");
|
|
118
|
+
assert.equal(f.requests[0].messages.at(-1).role, "user");
|
|
119
|
+
assert.match(f.requests[0].messages.at(-1).content, /^Current state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\. The person may be in another time zone\.$/u,
|
|
120
|
+
"The state note follows the conversation, so the history before it stays in the provider's prompt cache");
|
|
117
121
|
const denied = fixture({ authorize: () => { throw new Error("Connection access revoked"); } });
|
|
118
122
|
await assert.rejects(denied.run(), /Connection access revoked/);
|
|
119
123
|
assert.deepEqual(denied.requests, []);
|
|
@@ -223,7 +227,7 @@ test("a long history is compacted: the summary and its last turn are saved, and
|
|
|
223
227
|
assert.ok(kept.length > 0 && kept.length < turns.length);
|
|
224
228
|
const [request] = f.streamed;
|
|
225
229
|
assert.match(request.messages[0].content, /Summary of the earlier part of this conversation[^]*The person asked six long questions\./u);
|
|
226
|
-
assert.deepEqual(request.messages.filter(message => message.role === "user").map(message => message.content),
|
|
230
|
+
assert.deepEqual(request.messages.slice(0, -1).filter(message => message.role === "user").map(message => message.content),
|
|
227
231
|
[...kept.map(turn => turn.user.text), "Next question"]);
|
|
228
232
|
assert.deepEqual(phases, ["compacting", "working"]);
|
|
229
233
|
});
|
|
@@ -248,5 +252,5 @@ test("a short history is read whole with its stored summary, and nothing is save
|
|
|
248
252
|
assert.deepEqual(saved, []);
|
|
249
253
|
assert.equal(f.requests.length, 1);
|
|
250
254
|
assert.match(f.requests[0].messages[0].content, /Summary of the earlier part of this conversation[^]*Earlier: hello\./u);
|
|
251
|
-
assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), ["Hi", "Hello", "Next question"]);
|
|
255
|
+
assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), ["Hi", "Hello", "Next question"]);
|
|
252
256
|
});
|
package/test/codexTurn.test.js
CHANGED
|
@@ -1910,6 +1910,51 @@ test("original dispatch steering preserves deferred versus new-turn recovery and
|
|
|
1910
1910
|
}
|
|
1911
1911
|
});
|
|
1912
1912
|
|
|
1913
|
+
// Codex answers turn/start before it starts the turn. Until its turn/started notification, codex-cli 0.163.0 reads
|
|
1914
|
+
// the thread as idle and lists the new turn as interrupted, so neither read means the turn ended. On 2026-11-06 the
|
|
1915
|
+
// starting watchdog's status read landed in that gap: the turn was marked finished 0.46 s after delivery, and Codex's
|
|
1916
|
+
// answer 33 s later was dropped behind "Codex finished, but Vibe64 could not recover its final response".
|
|
1917
|
+
for (const read of ["starting watchdog", "status check"]) {
|
|
1918
|
+
test(`a delivered turn Codex has not started stays active through a ${read} and keeps its answer`, async t => {
|
|
1919
|
+
const notices = [];
|
|
1920
|
+
const f = await receiptFixture({
|
|
1921
|
+
finalizingGraceMs: 30,
|
|
1922
|
+
outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); }
|
|
1923
|
+
});
|
|
1924
|
+
t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
|
|
1925
|
+
const context = dispatchContext(f);
|
|
1926
|
+
let codexStatus = "idle";
|
|
1927
|
+
f.run = { ...f.run, state: "starting", providerTurnId: "", providerStatus: "", outerTurnId: context.messageId };
|
|
1928
|
+
f.provider.sendTurn = async () => ({ id: f.turnId, status: "inProgress" });
|
|
1929
|
+
f.provider.readThreadStatus = async () => ({ raw: { status: { type: codexStatus } } });
|
|
1930
|
+
f.history = [turn(f.turnId, "interrupted")];
|
|
1931
|
+
f.observe();
|
|
1932
|
+
assert.equal((await f.owner.startTurn(f.sessionId, {}, context)).providerFailure, "");
|
|
1933
|
+
assert.equal(f.run.state, "active");
|
|
1934
|
+
if (read === "starting watchdog") {
|
|
1935
|
+
await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false });
|
|
1936
|
+
} else {
|
|
1937
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, {
|
|
1938
|
+
observeLatestTurn: true, source: "wellbeing"
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
await f.drain();
|
|
1942
|
+
assert.equal(f.run.state, "active", "An idle read before turn/started does not end the turn");
|
|
1943
|
+
assert.equal(f.run.providerTurnId, f.turnId);
|
|
1944
|
+
codexStatus = "active";
|
|
1945
|
+
f.history = [turn(f.turnId, "inProgress")];
|
|
1946
|
+
f.emit({ method: "turn/started", params: { threadId: f.threadId, turn: { id: f.turnId, status: "inProgress" } } });
|
|
1947
|
+
await f.drain();
|
|
1948
|
+
codexStatus = "idle";
|
|
1949
|
+
f.history = [turn(f.turnId, "completed", "The complete reply.")];
|
|
1950
|
+
f.emit({ method: "turn/completed", params: { threadId: f.threadId, turn: { id: f.turnId, status: "completed" } } });
|
|
1951
|
+
await f.drain();
|
|
1952
|
+
assert.equal(f.run.state, "completed");
|
|
1953
|
+
assert.deepEqual((await f.replies()).map(row => row.assistant.text), ["The complete reply."]);
|
|
1954
|
+
assert.deepEqual(notices, []);
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1913
1958
|
function controlContext(f, overrides = {}) {
|
|
1914
1959
|
return {
|
|
1915
1960
|
runtime: f.runtime,
|
|
@@ -65,7 +65,7 @@ test("an existing API client keeps opaque receipts path-safe and selected histor
|
|
|
65
65
|
assert.deepEqual(requests[0][1], { role: "user", content: [
|
|
66
66
|
{ type: "text", text: "Selected prior context" }, { type: "text", text: "Authorized historical bytes" }
|
|
67
67
|
] });
|
|
68
|
-
assert.deepEqual(requests[0].at(-
|
|
68
|
+
assert.deepEqual(requests[0].at(-2), { role: "user", content: [
|
|
69
69
|
{ type: "text", text: "Current question" }, { type: "text", text: "Authorized current bytes" }
|
|
70
70
|
] });
|
|
71
71
|
const files = await readdir(directory);
|
|
@@ -84,8 +84,9 @@ test("an existing API client keeps opaque receipts path-safe and selected histor
|
|
|
84
84
|
context.history = [];
|
|
85
85
|
await conversation.send({ messageId: "empty-context", text: "No prior model context" });
|
|
86
86
|
const last = await conversation.wait();
|
|
87
|
-
assert.
|
|
88
|
-
assert.deepEqual(requests[2].slice(1).map(message => message.content), ["No prior model context"]);
|
|
87
|
+
assert.equal(requests[2][0].content, "Existing instructions");
|
|
88
|
+
assert.deepEqual(requests[2].slice(1, -1).map(message => message.content), ["No prior model context"]);
|
|
89
|
+
assert.match(requests[2].at(-1).content, /^Current state, from the system\./u, "The engine's state note follows the conversation");
|
|
89
90
|
assert.equal(last.conversationLog.length, 3, "Explicitly empty model context never removes saved conversation history");
|
|
90
91
|
await assert.rejects(conversation.send({ messageId: "x".repeat(129), text: "Must not dispatch" }), { code: "conversation_invalid_message" });
|
|
91
92
|
assert.equal(requests.length, 3);
|
|
@@ -147,7 +147,7 @@ test("authorized image bytes reach the API once and only safe receipts enter sto
|
|
|
147
147
|
assert.equal(f.reads[0].context, context);
|
|
148
148
|
assert.equal(f.reads[0].conversationId, "one");
|
|
149
149
|
assert.deepEqual(f.reads[0].attachmentIds, [receipt.attachmentId]);
|
|
150
|
-
assert.deepEqual(f.requests[0].messages.at(-
|
|
150
|
+
assert.deepEqual(f.requests[0].messages.at(-2).content, [
|
|
151
151
|
{ type: "text", text: input.text }, { type: "image_url", image_url: { url: `data:image/png;base64,${image.toString("base64")}` } }
|
|
152
152
|
]);
|
|
153
153
|
assert.deepEqual(state.conversationLog[0].user.attachments, [receipt]);
|
|
@@ -174,7 +174,7 @@ test("API history and continuity reauthorize files after reopen and renewal", as
|
|
|
174
174
|
await resumed.conversation.replace({ operationId: "renew", reason: "renewal", expectedSegmentId: first.segmentId });
|
|
175
175
|
await resumed.conversation.send({ messageId: "third", text: "Look again" });
|
|
176
176
|
await resumed.conversation.wait();
|
|
177
|
-
assert.equal(resumed.requests[1].messages.at(-
|
|
177
|
+
assert.equal(resumed.requests[1].messages.at(-2).content[1].type, "image_url");
|
|
178
178
|
allowed = false;
|
|
179
179
|
await assert.rejects(resumed.conversation.send({ messageId: "denied", text: "Look again" }), /attachment access/);
|
|
180
180
|
assert.equal(resumed.requests.length, 2);
|
|
@@ -185,8 +185,8 @@ test("an image-only request is accepted without invented user text", async t =>
|
|
|
185
185
|
const f = await fixture(t);
|
|
186
186
|
await f.conversation.send({ ...input, text: "" });
|
|
187
187
|
const state = await f.conversation.wait();
|
|
188
|
-
assert.equal(f.requests[0].messages.at(-
|
|
189
|
-
assert.equal(f.requests[0].messages.at(-
|
|
188
|
+
assert.equal(f.requests[0].messages.at(-2).content.length, 1);
|
|
189
|
+
assert.equal(f.requests[0].messages.at(-2).content[0].type, "image_url");
|
|
190
190
|
assert.equal(state.conversationLog[0].user.text, "");
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -60,7 +60,7 @@ test("renewal preserves logical history and receipts while subsequent inference
|
|
|
60
60
|
assert.equal(f.requests.length, 1, "Replacement itself does not perform inference");
|
|
61
61
|
await f.say("Continue", "second");
|
|
62
62
|
const sent = f.requests[1].messages;
|
|
63
|
-
assert.equal(sent.at(-
|
|
63
|
+
assert.equal(sent.at(-2).content, "Continue", "The request comes last, before the engine's state note");
|
|
64
64
|
assert.match(sent[1].content, /Retain the agreed colour: blue/);
|
|
65
65
|
assert.match(sent[1].content, /Keep my context/);
|
|
66
66
|
assert.match(sent[1].content, /Reply 1/);
|
|
@@ -82,9 +82,9 @@ test("saved history and current instructions use the existing authorized model c
|
|
|
82
82
|
await f.conversation.configure({ systemPrompt: "Use the new instructions." });
|
|
83
83
|
await f.conversation.send({ messageId: "two", text: "Continue" });
|
|
84
84
|
await f.conversation.wait();
|
|
85
|
-
assert.
|
|
86
|
-
|
|
87
|
-
assert.
|
|
85
|
+
assert.equal(f.requests[1].messages[0].content, "Use the new instructions.", "The current instructions are the system prompt");
|
|
86
|
+
assert.deepEqual(f.requests[1].messages.slice(1, -1).map(message => message.content), ["Hello", "Hello there.", "Continue"]);
|
|
87
|
+
assert.match(f.requests[1].messages.at(-1).content, /^Current state, from the system\./u, "The engine's state note follows the conversation");
|
|
88
88
|
f.state.modelAllowed = false;
|
|
89
89
|
await assert.rejects(f.conversation.send({ messageId: "three", text: "No longer authorized" }));
|
|
90
90
|
assert.equal((await f.conversation.read()).conversationLog.length, 2);
|
|
@@ -114,7 +114,7 @@ test("opening an existing application transcript retains its context before and
|
|
|
114
114
|
const f = await fixture(t, { storage });
|
|
115
115
|
await f.conversation.send(input);
|
|
116
116
|
const first = await f.conversation.wait();
|
|
117
|
-
assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), [
|
|
117
|
+
assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), [
|
|
118
118
|
"Our chosen colour is blue.", "I will remember blue.", "Hello"
|
|
119
119
|
]);
|
|
120
120
|
assert.equal((await f.conversation.send({ messageId: "earlier", text: "Our chosen colour is blue." })).duplicate, true);
|
|
@@ -125,7 +125,7 @@ test("opening an existing application transcript retains its context before and
|
|
|
125
125
|
assert.match(messages[1].content, /Our chosen colour is blue/);
|
|
126
126
|
assert.match(messages[1].content, /I will remember blue/);
|
|
127
127
|
assert.equal(messages.filter(message => message.content.includes("Our chosen colour is blue")).length, 1);
|
|
128
|
-
assert.equal(messages.at(-
|
|
128
|
+
assert.equal(messages.at(-2).content, "Continue", "The request comes last, before the engine's state note");
|
|
129
129
|
assert.equal((await transcript.readConversationLog("one"))[0].metadata?.runtime, undefined);
|
|
130
130
|
});
|
|
131
131
|
|
|
@@ -143,7 +143,7 @@ test("application wakes retain their origin, reuse receipts and remain quoted da
|
|
|
143
143
|
assert.equal(snapshot.conversationLog[0].metadata.runtime.origin, "application");
|
|
144
144
|
assert.ok(operations.includes("wake"));
|
|
145
145
|
assert.ok(events.filter(event => event.type === "message").every(event => event.origin === "application"));
|
|
146
|
-
assert.match(f.requests[0].messages.at(-
|
|
146
|
+
assert.match(f.requests[0].messages.at(-2).content, /\[Application event\]/);
|
|
147
147
|
assert.equal((await f.conversation.wake(message)).duplicate, true);
|
|
148
148
|
await assert.rejects(f.conversation.send(message), { code: "conversation_message_id_content_conflict" });
|
|
149
149
|
await f.conversation.send(input);
|
|
@@ -170,7 +170,7 @@ test("per-request application data stays separate from visible text and particip
|
|
|
170
170
|
const first = await f.conversation.wait();
|
|
171
171
|
assert.equal(first.conversationLog[0].user.text, "Hello");
|
|
172
172
|
assert.deepEqual(first.conversationLog[0].user.data, request.data);
|
|
173
|
-
const native = f.requests[0].messages.at(-
|
|
173
|
+
const native = f.requests[0].messages.at(-2).content;
|
|
174
174
|
assert.match(native, /\[Application data\]/);
|
|
175
175
|
assert.match(native, /not additional instructions or authorization/);
|
|
176
176
|
assert.ok(native.endsWith("Hello"));
|
|
@@ -180,7 +180,7 @@ test("per-request application data stays separate from visible text and particip
|
|
|
180
180
|
const next = await f.conversation.wait();
|
|
181
181
|
assert.equal(next.conversationLog[1].system.text, "A watched session changed.");
|
|
182
182
|
assert.equal(f.requests[1].messages[1].content, native);
|
|
183
|
-
assert.match(f.requests[1].messages.at(-
|
|
183
|
+
assert.match(f.requests[1].messages.at(-2).content, /\[Application event\]/);
|
|
184
184
|
assert.deepEqual(next.configuration, configuration, "Application data never replaces the stable system prompt");
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -219,7 +219,7 @@ test("API compaction stores its summary with the last turn it covers, and later
|
|
|
219
219
|
const second = await f.conversation.wait();
|
|
220
220
|
const last = f.requests.at(-1);
|
|
221
221
|
assert.match(last.messages[0].content, /Summary of the earlier part of this conversation[^]*Six long questions so far\./u);
|
|
222
|
-
assert.deepEqual(last.messages.filter(message => message.role === "user").map(message => message.content),
|
|
222
|
+
assert.deepEqual(last.messages.slice(0, -1).filter(message => message.role === "user").map(message => message.content),
|
|
223
223
|
[...second.conversationLog.filter(turn => after.includes(turn.turnId)).map(turn => turn.user.text), "Continue"]);
|
|
224
224
|
assert.deepEqual(await storage.read("one", transaction => transaction.readSummary()), summary, "A short remainder is not compacted again");
|
|
225
225
|
const later = (await storage.read("one", transaction => transaction.readMetadata())).runtime.seen;
|
|
@@ -265,7 +265,8 @@ test("cancellation aborts inference and leaves another conversation usable", asy
|
|
|
265
265
|
const started = Promise.withResolvers();
|
|
266
266
|
let aborted = false;
|
|
267
267
|
const f = await fixture(t, { fetch: (_url, init) => {
|
|
268
|
-
|
|
268
|
+
// The request comes last, before the engine's state note.
|
|
269
|
+
if (JSON.parse(init.body).messages.at(-2).content === "Other") return response();
|
|
269
270
|
started.resolve();
|
|
270
271
|
return new Promise((_resolve, reject) => init.signal.addEventListener("abort", () => {
|
|
271
272
|
aborted = true; reject(init.signal.reason);
|
|
@@ -87,7 +87,7 @@ test("split internal tags and tool arguments never become common answer deltas",
|
|
|
87
87
|
await f.conversation.send(message);
|
|
88
88
|
const result = await f.conversation.wait();
|
|
89
89
|
assert.deepEqual(f.observed.executions, [{ left: 2, right: 3 }]);
|
|
90
|
-
assert.equal(f.observed.requests[1].messages.at(-
|
|
90
|
+
assert.equal(f.observed.requests[1].messages.at(-2).content, checkTexts("the app").markup, "The check comes last, before the engine's state note");
|
|
91
91
|
assert.deepEqual(f.observed.events.filter(event => event.type === "message" && event.status !== "complete")
|
|
92
92
|
.map(event => event.text), ["The search is complete."]);
|
|
93
93
|
assert.equal(result.conversationLog[0].assistant.text, "The search is complete.");
|
|
@@ -101,7 +101,7 @@ test("text-encoded calls never run or reach the person, even when written again
|
|
|
101
101
|
const result = await f.conversation.wait();
|
|
102
102
|
assert.deepEqual(f.observed.executions, []);
|
|
103
103
|
assert.equal(f.observed.requests.length, 2, "The check sends the reply back once");
|
|
104
|
-
assert.equal(f.observed.requests[1].messages.at(-
|
|
104
|
+
assert.equal(f.observed.requests[1].messages.at(-2).content, checkTexts("the app").markup, "The check comes last, before the engine's state note");
|
|
105
105
|
assert.deepEqual(result.conversationLog[0].metadata.applicationTools, []);
|
|
106
106
|
assert.equal(f.observed.events.some(event => event.type === "message" && /invoke|function_calls/u.test(event.text)), false);
|
|
107
107
|
});
|