@jskit-ai/assistant-core 0.1.205 → 0.1.206

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.
Files changed (39) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/package.json +19 -9
  4. package/src/client/conversation/AssistantTranscript.vue +177 -16
  5. package/src/server/conversation/claudeTurn.js +16 -7
  6. package/src/server/conversation/codexConversations.js +94 -0
  7. package/src/server/conversation/codexProvider.js +15 -5
  8. package/src/server/conversation/codexProviderOwner.js +70 -47
  9. package/src/server/conversation/codexProviderSelection.js +5 -2
  10. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  11. package/src/server/conversation/codexTurn.js +10 -1
  12. package/src/server/conversation/codexTurnControl.js +1 -1
  13. package/src/server/conversation/codexTurnRecovery.js +21 -8
  14. package/src/server/conversation/openCodeRuntime.js +107 -64
  15. package/src/server/conversation/openCodeTurn.js +33 -16
  16. package/src/server/conversation/providers/api.js +1 -0
  17. package/src/server/conversation/providers/claudeDriver.js +13 -0
  18. package/src/server/conversation/providers/codex.js +90 -47
  19. package/src/server/conversation/providers/codexDriver.js +4 -1
  20. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  21. package/src/server/conversation/runtime.js +32 -5
  22. package/src/server/hosts/codexEvents.js +15 -0
  23. package/src/server/hosts/codexTurn.js +5 -0
  24. package/src/server/hosts/openCodeClient.js +4 -0
  25. package/src/server/lib/aiConnectionClient.js +9 -11
  26. package/test/aiConnectionClient.test.js +209 -0
  27. package/test/assistantScroll.browser.test.js +255 -1
  28. package/test/claudeConversationSession.test.js +78 -0
  29. package/test/claudeTurn.test.js +18 -0
  30. package/test/codexConversationSession.test.js +318 -0
  31. package/test/codexTurn.test.js +203 -2
  32. package/test/conversationConfiguration.test.js +27 -0
  33. package/test/conversationReplacement.test.js +76 -1
  34. package/test/conversationRuntime.test.js +173 -4
  35. package/test/conversationSession.test.js +139 -0
  36. package/test/conversationTools.test.js +17 -3
  37. package/test/opencodeConversationSession.test.js +200 -1
  38. package/test/packageMetadata.test.js +75 -0
  39. package/test/serviceToolCatalog.test.js +31 -0
@@ -111,7 +111,7 @@ async function fixture(t, options = {}) {
111
111
  if (req.url.startsWith("/auth/")) return reply(true);
112
112
  if (req.url === "/path") return reply({ directory: req.headers["x-opencode-directory"] });
113
113
  if (req.url === "/provider") return reply({ all: Object.entries(configuration.provider).map(([id, provider]) => ({
114
- id, models: Object.fromEntries(Object.entries(provider.models || { "test-model": {} }).map(([id, model]) => [id, { ...model, id, variants: { low: {}, high: {} } }]))
114
+ id, models: Object.fromEntries(Object.entries({ ...(provider.models || { "test-model": {} }), ...${JSON.stringify(options.modelDefinitions || {})} }).map(([id, model]) => [id, { ...model, id, variants: { low: {}, high: {} } }]))
115
115
  })) });
116
116
  if (req.url === "/api/session" && req.method === "POST") {
117
117
  if (!body.model?.id || body.location?.directory !== process.cwd()) return reply({ message: "Invalid native session parameters" }, 422);
@@ -787,3 +787,202 @@ test("OpenCode common dispatch preserves original local image input and exact co
787
787
  await f.conversation.wait();
788
788
  assert.equal((await f.trace()).filter(row => row.method === "PATCH" && row.url === `/session/${sessionId}`).length, 1);
789
789
  });
790
+
791
+
792
+ test("OpenCode provider selection rotates only the incompatible binding and retains canonical history", async t => {
793
+ let model = "test-model";
794
+ const f = await fixture(t, { modelDefinitions: { "other-model": {} }, resolve: async ({ integrationId }) => ({
795
+ providerId: integrationId, model, sdkPackage: "@ai-sdk/openai-compatible",
796
+ modelLimits: { context: 32000, output: 4000 }, apiKey: `key-${integrationId}`
797
+ }) });
798
+ await f.conversation.send(input);
799
+ const before = await f.conversation.wait();
800
+ const original = await f.binding();
801
+ model = "other-model";
802
+ await f.conversation.select({ operationId: "same-provider-model", expectedSegmentId: before.segmentId,
803
+ engine: "opencode", configuration: { ...configuration, model, effort: "low" } });
804
+ assert.deepEqual(await f.binding(), original, "A model change within the same provider retains its native session and account");
805
+ assert.equal((await f.conversation.read()).segmentId, before.segmentId);
806
+ await f.conversation.send({ messageId: "second", text: "Same account" });
807
+ const current = await f.conversation.wait();
808
+ const selection = { operationId: "different-provider", expectedSegmentId: current.segmentId,
809
+ engine: "opencode", configuration: { ...configuration, integrationId: "flash", model, effort: "low" } };
810
+ const selected = await f.conversation.select(selection);
811
+ assert.notEqual(selected.segmentId, current.segmentId);
812
+ const inert = await f.binding();
813
+ assert.notEqual(inert.directory, original.directory);
814
+ assert.equal(inert.sessionId, "");
815
+ assert.equal(inert.accountIdentity, undefined);
816
+ assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 2,
817
+ "Selecting another provider must not run inference");
818
+ assert.deepEqual((await f.conversation.read()).conversationLog, current.conversationLog);
819
+ assert.equal((await f.conversation.select(selection)).duplicate, true);
820
+ await f.conversation.send({ messageId: "third", text: "Continue with Flash" });
821
+ const after = await f.conversation.wait();
822
+ assert.equal(after.id, current.id);
823
+ assert.equal(after.error, "");
824
+ assert.deepEqual(after.conversationLog.slice(0, 2), current.conversationLog);
825
+ const fresh = await f.binding();
826
+ assert.notEqual(fresh.sessionId, original.sessionId);
827
+ assert.notEqual(fresh.accountIdentity, original.accountIdentity);
828
+ const prompts = (await f.trace()).filter(row => row.url?.endsWith("/prompt_async"));
829
+ assert.equal(prompts.length, 3);
830
+ assert.match(prompts[2].body.parts[0].text, /Hello/);
831
+ assert.match(prompts[2].body.parts[0].text, /Same account/);
832
+ assert.match(prompts[2].body.parts[0].text, /Continue with Flash/);
833
+ await f.first.close();
834
+ const reopened = await f.runtime().open({ id: "conversation" });
835
+ assert.equal((await reopened.select(selection)).duplicate, true);
836
+ assert.deepEqual(await f.binding(), { ...fresh, executionId: "", processDirectory: "" });
837
+ assert.deepEqual((await reopened.read()).conversationLog, after.conversationLog);
838
+ });
839
+
840
+ for (const change of ["model", "effort"]) {
841
+ test(`OpenCode selection honors a consumer's fresh native policy for ${change} changes without losing written history`, async t => {
842
+ let model = "test-model";
843
+ const f = await fixture(t, { modelDefinitions: { "other-model": {} }, resolve: async () => ({
844
+ providerId: "test", model, sdkPackage: "@ai-sdk/openai-compatible", apiKey: "same-account"
845
+ }) });
846
+ await f.conversation.send(input);
847
+ const before = await f.conversation.wait();
848
+ const original = await f.binding();
849
+ if (change === "model") model = "other-model";
850
+ const selection = { operationId: `fresh-${change}`, expectedSegmentId: before.segmentId, engine: "opencode",
851
+ configuration: { ...configuration, model, effort: change === "effort" ? "high" : "low" }, retireNative: true };
852
+ await assert.rejects(f.conversation.select({ ...selection, retireNative: "yes" }), { code: "conversation_invalid_replacement" });
853
+ assert.deepEqual(await f.binding(), original);
854
+ const selected = await f.conversation.select(selection);
855
+ assert.notEqual(selected.segmentId, before.segmentId);
856
+ const inert = await f.binding();
857
+ assert.notEqual(inert.directory, original.directory);
858
+ assert.equal(inert.sessionId, "");
859
+ assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1,
860
+ "Changing the selection cannot submit another authored request");
861
+ assert.deepEqual((await f.conversation.read()).conversationLog, before.conversationLog);
862
+ assert.equal((await f.conversation.select(selection)).duplicate, true);
863
+ await f.conversation.send({ messageId: "after-change", text: "Continue our discussion" });
864
+ const after = await f.conversation.wait();
865
+ assert.equal(after.error, "");
866
+ assert.equal(after.id, before.id);
867
+ assert.notEqual((await f.binding()).sessionId, original.sessionId);
868
+ assert.equal((await f.binding()).accountIdentity, original.accountIdentity);
869
+ assert.deepEqual(after.conversationLog[0], before.conversationLog[0]);
870
+ const prompts = (await f.trace()).filter(row => row.url?.endsWith("/prompt_async"));
871
+ assert.equal(prompts.length, 2);
872
+ assert.match(prompts[1].body.parts[0].text, /Hello/);
873
+ assert.match(prompts[1].body.parts[0].text, /Continue our discussion/);
874
+ assert.deepEqual(prompts[1].body.model, { providerID: "test", modelID: model });
875
+ assert.equal(prompts[1].body.variant, change === "effort" ? "high" : "low");
876
+ await f.first.close();
877
+ const reopened = await f.runtime().open({ id: "conversation" });
878
+ assert.equal((await reopened.select(selection)).duplicate, true);
879
+ assert.deepEqual((await reopened.read()).conversationLog, after.conversationLog);
880
+ });
881
+ }
882
+
883
+ test("OpenCode explicit selection recovers an older mismatched configuration without replaying its rejected request", async t => {
884
+ const f = await fixture(t, { resolve: async ({ integrationId }) => ({
885
+ providerId: integrationId, model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
886
+ modelLimits: { context: 32000, output: 4000 }, apiKey: `key-${integrationId}`
887
+ }) });
888
+ await f.conversation.send(input);
889
+ const original = await f.conversation.wait();
890
+ const oldBinding = await f.binding();
891
+ // Earlier same-engine selection wrote the new configuration but kept this
892
+ // binding. Configure reproduces those exact persisted facts without replacing
893
+ // the original identity guard or running an alternative sender.
894
+ await f.conversation.configure({ integrationId: "flash" });
895
+ await assert.rejects(f.conversation.send({ messageId: "rejected-greeting", text: "Do not replay this greeting" }),
896
+ /another OpenCode account/);
897
+ await f.conversation.wait();
898
+ const rejected = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.request);
899
+ assert.equal(rejected.messageId, "rejected-greeting");
900
+ assert.equal(rejected.attempted, false);
901
+ assert.deepEqual(await f.binding(), { ...oldBinding, executionId: "", processDirectory: "" });
902
+ const restoring = { operationId: "restore-original-provider", expectedSegmentId: original.segmentId,
903
+ engine: "opencode", configuration };
904
+ const restored = await f.conversation.select(restoring);
905
+ const switchToFlash = { operationId: "select-flash-after-restore", expectedSegmentId: restored.segmentId,
906
+ engine: "opencode", configuration: { ...configuration, integrationId: "flash" } };
907
+ await f.conversation.select(switchToFlash);
908
+ assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
909
+ await f.conversation.send({ messageId: "new-authored-message", text: "Continue the same conversation" });
910
+ const after = await f.conversation.wait();
911
+ assert.equal(after.id, original.id);
912
+ assert.equal(after.error, "");
913
+ assert.deepEqual(after.conversationLog[0], original.conversationLog[0]);
914
+ const saved = await f.storage.read("conversation", tx => tx.readMetadata());
915
+ assert.deepEqual(saved.runtime.predecessors.find(segment => segment.request?.messageId === rejected.messageId).request, rejected,
916
+ "The original rejected request remains with its predecessor, not a new dispatch");
917
+ const prompts = (await f.trace()).filter(row => row.url?.endsWith("/prompt_async"));
918
+ assert.equal(prompts.length, 2);
919
+ assert.doesNotMatch(prompts[1].body.parts[0].text, /Do not replay this greeting|rejected-greeting/);
920
+ assert.match(prompts[1].body.parts[0].text, /Hello/);
921
+ });
922
+
923
+ test("OpenCode provider selection retains an uncertain predecessor without resending its authored ID", async t => {
924
+ const f = await fixture(t, { unobservedInput: true, resolve: async ({ integrationId }) => ({
925
+ providerId: integrationId, model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
926
+ modelLimits: { context: 32000, output: 4000 }, apiKey: `key-${integrationId}`
927
+ }) });
928
+ await assert.rejects(f.conversation.send({ messageId: "unknown-old-id", text: "lost" }));
929
+ const before = await f.conversation.wait();
930
+ const metadata = await f.storage.read("conversation", tx => tx.readMetadata());
931
+ assert.equal(metadata.runtime.request.attempted, true);
932
+ await f.conversation.select({ operationId: "switch-with-unknown", expectedSegmentId: before.segmentId,
933
+ engine: "opencode", configuration: { ...configuration, integrationId: "flash" } });
934
+ const saved = await f.storage.read("conversation", tx => tx.readMetadata());
935
+ assert.deepEqual(saved.runtime.predecessors.at(-1).request, metadata.runtime.request);
936
+ await assert.rejects(f.conversation.send({ messageId: "unknown-old-id", text: "lost" }),
937
+ /pending.*another|another.*pending|another native|uncertain/i);
938
+ assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
939
+ });
940
+
941
+
942
+ test("OpenCode fresh native selection retains an uncertain predecessor without resending its authored ID", async t => {
943
+ const f = await fixture(t, { unobservedInput: true });
944
+ await assert.rejects(f.conversation.send({ messageId: "unknown-old-id", text: "lost" }));
945
+ const before = await f.conversation.wait();
946
+ const metadata = await f.storage.read("conversation", tx => tx.readMetadata());
947
+ assert.equal(metadata.runtime.request.attempted, true);
948
+ await f.conversation.select({ operationId: "fresh-with-unknown", expectedSegmentId: before.segmentId,
949
+ engine: "opencode", configuration: { ...configuration, effort: "high" }, retireNative: true });
950
+ const saved = await f.storage.read("conversation", tx => tx.readMetadata());
951
+ assert.deepEqual(saved.runtime.predecessors.at(-1).request, metadata.runtime.request);
952
+ assert.notEqual(saved.runtime.binding.directory, metadata.runtime.binding.directory);
953
+ assert.equal(saved.runtime.binding.sessionId, "");
954
+ await assert.rejects(f.conversation.send({ messageId: "unknown-old-id", text: "lost" }),
955
+ /pending.*another|another.*pending|another native|uncertain/i);
956
+ assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
957
+ });
958
+
959
+ test("returning to OpenCode restores only a retained binding with the selected integration", async t => {
960
+ const f = await fixture(t, { resolve: async ({ integrationId }) => ({
961
+ providerId: integrationId, model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
962
+ modelLimits: { context: 32000, output: 4000 }, apiKey: `key-${integrationId}`
963
+ }) });
964
+ await f.conversation.send(input);
965
+ const before = await f.conversation.wait();
966
+ const original = await f.binding();
967
+ const otherEngine = await f.conversation.select({ operationId: "api-between", expectedSegmentId: before.segmentId,
968
+ engine: "api", configuration });
969
+ const selected = await f.conversation.select({ operationId: "return-to-flash", expectedSegmentId: otherEngine.segmentId,
970
+ engine: "opencode", configuration: { ...configuration, integrationId: "flash" } });
971
+ assert.notEqual(selected.segmentId, before.segmentId);
972
+ assert.notEqual((await f.binding()).directory, original.directory);
973
+ assert.equal((await f.binding()).sessionId, "");
974
+ await f.conversation.send({ messageId: "after-api", text: "The same discussion on Flash" });
975
+ const after = await f.conversation.wait();
976
+ assert.equal(after.error, "");
977
+ assert.equal(after.id, before.id);
978
+ assert.deepEqual(after.conversationLog[0], before.conversationLog[0]);
979
+ const flash = await f.binding();
980
+ const apiAgain = await f.conversation.select({ operationId: "api-again", expectedSegmentId: after.segmentId,
981
+ engine: "api", configuration });
982
+ const restored = await f.conversation.select({ operationId: "restore-flash", expectedSegmentId: apiAgain.segmentId,
983
+ engine: "opencode", configuration: { ...configuration, integrationId: "flash" } });
984
+ assert.equal(restored.segmentId, selected.segmentId);
985
+ assert.deepEqual(await f.binding(), { ...flash, executionId: "", processDirectory: "" },
986
+ "Returning to the same integration restores native identity after verified process cleanup");
987
+ assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 2);
988
+ });
@@ -14,3 +14,78 @@ test("assistant-core owns its portable json-rest-schema dependency directly", ()
14
14
  );
15
15
  assert.equal(Object.hasOwn(packageMetadata, "mutations"), false);
16
16
  });
17
+
18
+ test("original assistant-core import contracts forward to the same native and submission owners", async () => {
19
+ const contracts = [
20
+ {
21
+ subpath: "server/codex-events",
22
+ owner: "../src/server/conversation/codexEvents.js",
23
+ names: [
24
+ "classifyCodexAppServerEvent",
25
+ "codexAppServerAssistantItemText",
26
+ "codexAppServerContentText",
27
+ "codexAppServerContextRefreshReason",
28
+ "codexAppServerErrorText",
29
+ "codexAppServerNotificationError",
30
+ "codexAppServerNotificationEvent",
31
+ "codexAppServerNotificationEventPayload",
32
+ "codexAppServerNotificationEventType",
33
+ "codexAppServerNotificationItem",
34
+ "codexAppServerNotificationItemId",
35
+ "codexAppServerNotificationParams",
36
+ "codexAppServerNotificationThreadId",
37
+ "codexAppServerNotificationTurnId",
38
+ "codexAppServerNotificationTurnStatus",
39
+ "codexAppServerNotificationUsageLimitExceeded",
40
+ "codexAppServerOutputOwnerTurnId",
41
+ "codexAppServerProviderThreadAssistantSegments",
42
+ "codexAppServerStatusFromValue",
43
+ "codexAppServerUserMessageText"
44
+ ]
45
+ },
46
+ {
47
+ subpath: "server/codex-turn",
48
+ owner: "../src/server/conversation/codexTurn.js",
49
+ names: [
50
+ "codexAppServerTurnStatusIsActive",
51
+ "codexAppServerTurnStatusIsComplete",
52
+ "codexAppServerTurnStatusIsProviderFailure",
53
+ "codexAppServerTurnStatusIsSuccessfulComplete",
54
+ "createCodexAppServerDetachedTurnWatcher"
55
+ ]
56
+ },
57
+ {
58
+ subpath: "server/codex-client",
59
+ owner: "../src/server/conversation/codexClient.js",
60
+ names: [
61
+ "CodexAppServerJsonRpcClient",
62
+ "socketPathFromCodexAppServerEndpoint"
63
+ ]
64
+ },
65
+ {
66
+ subpath: "server/opencode-client",
67
+ owner: "../src/server/conversation/openCodeClient.js",
68
+ names: [
69
+ "OPENCODE_RESPONSE_LIMIT_BYTES",
70
+ "createOpenCodeServerClient",
71
+ "openCodeAssistantMessageText",
72
+ "readBoundedResponse"
73
+ ]
74
+ },
75
+ {
76
+ subpath: "client/conversation-submit",
77
+ owner: "../src/client/conversation/submitText.js",
78
+ names: [
79
+ "createAssistantTextSubmission"
80
+ ]
81
+ }
82
+ ];
83
+ for (const { subpath, owner, names } of contracts) {
84
+ const namespace = await import(`@jskit-ai/assistant-core/${subpath}`);
85
+ const implementation = await import(owner);
86
+ for (const name of names) {
87
+ assert.notEqual(implementation[name], undefined, `${subpath} original owner exports ${name}`);
88
+ assert.equal(namespace[name], implementation[name], `${subpath} forwards the original ${name}`);
89
+ }
90
+ }
91
+ });
@@ -667,3 +667,34 @@ test("catalogue retains unrestricted matching for externally supplied empty surf
667
667
  assert.equal(catalog.resolveToolSet({ surface }).tools.length, 0);
668
668
  }
669
669
  });
670
+
671
+ test("synchronous failure observers preserve thrown identity and exclude unknown descriptors", async () => {
672
+ const originalFailure = Object.assign(new Error("Private database failure"), { statusCode: 500, code: "DATABASE_FAILED" });
673
+ const observerFailure = new Error("Observer stopped this tool call");
674
+ let executions = 0;
675
+ const actions = createActions([action({
676
+ execute: async () => { executions++; throw originalFailure; }
677
+ })]);
678
+ const catalog = createServiceToolCatalog(actions);
679
+ const context = { actor: { id: "7" }, surface: "admin" };
680
+ const toolSet = catalog.resolveToolSet(context);
681
+ const failures = [];
682
+ const onFailure = error => { failures.push(error); throw observerFailure; };
683
+ assert.deepEqual(await catalog.executeToolCall({ toolName: "missing", context, toolSet, onFailure }), {
684
+ ok: false,
685
+ error: { code: "assistant_tool_unknown", message: "Unknown tool." }
686
+ });
687
+ assert.deepEqual(failures, [], "unknown exposed descriptors return before the failure observer");
688
+ assert.equal(executions, 0);
689
+ await assert.rejects(catalog.executeToolCall({ toolName: toolSet.tools[0].name, context, toolSet, onFailure }),
690
+ error => error === observerFailure);
691
+ assert.equal(failures.length, 1);
692
+ assert.equal(failures[0], originalFailure, "the callback receives the exact caught application error");
693
+ assert.equal(executions, 1);
694
+ assert.deepEqual(await catalog.executeToolCall({ toolName: toolSet.tools[0].name, context, toolSet }), {
695
+ ok: false,
696
+ error: { code: "DATABASE_FAILED", message: "Tool call failed.", status: 500 }
697
+ });
698
+ assert.equal(executions, 2);
699
+ assert.equal(failures.length, 1, "no-callback safe failure conversion keeps its original behavior");
700
+ });