| author | |
| committer | |
| log | 8e6f4229da96076ac26e904ce9c5fa678c5def83 |
| tree | 873d28dd551d37a28d7c3b24ee6403f438d43797 |
| parent | 38ea6d483ebf0a8c76f7105824f79489871057e5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 173 insertions(+), 169 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+69-59| ... | ... | @@ -28,6 +28,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 28 | 28 | import * as PubSub from "effect/PubSub"; |
| 29 | 29 | import * as Scope from "effect/Scope"; |
| 30 | 30 | import * as Stream from "effect/Stream"; |
| 31 | import { it as effectIt } from "@effect/vitest"; | |
| 31 | 32 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 32 | 33 | |
| 33 | 34 | import { deriveServerPaths, ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -890,70 +891,79 @@ describe("ProviderCommandReactor", () => { |
| 890 | 891 | }); |
| 891 | 892 | }); |
| 892 | 893 | |
| 893 | it("rejects changing models after start when the provider requires a new thread", async () => { | |
| 894 | const harness = await createHarness({ requiresNewThreadForModelChange: true }); | |
| 895 | const now = "2026-01-01T00:00:00.000Z"; | |
| 894 | effectIt.effect( | |
| 895 | "rejects changing models after start when the provider requires a new thread", | |
| 896 | () => | |
| 897 | Effect.gen(function* () { | |
| 898 | const harness = yield* Effect.promise(() => | |
| 899 | createHarness({ requiresNewThreadForModelChange: true }), | |
| 900 | ); | |
| 901 | const now = "2026-01-01T00:00:00.000Z"; | |
| 896 | 902 | |
| 897 | await Effect.runPromise( | |
| 898 | harness.engine.dispatch({ | |
| 899 | type: "thread.turn.start", | |
| 900 | commandId: CommandId.make("cmd-turn-start-restricted-1"), | |
| 901 | threadId: ThreadId.make("thread-1"), | |
| 902 | message: { | |
| 903 | messageId: asMessageId("user-message-restricted-1"), | |
| 904 | role: "user", | |
| 905 | text: "first", | |
| 906 | attachments: [], | |
| 907 | }, | |
| 908 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 909 | runtimeMode: "approval-required", | |
| 910 | createdAt: now, | |
| 911 | }), | |
| 912 | ); | |
| 903 | yield* harness.engine.dispatch({ | |
| 904 | type: "thread.turn.start", | |
| 905 | commandId: CommandId.make("cmd-turn-start-restricted-1"), | |
| 906 | threadId: ThreadId.make("thread-1"), | |
| 907 | message: { | |
| 908 | messageId: asMessageId("user-message-restricted-1"), | |
| 909 | role: "user", | |
| 910 | text: "first", | |
| 911 | attachments: [], | |
| 912 | }, | |
| 913 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 914 | runtimeMode: "approval-required", | |
| 915 | createdAt: now, | |
| 916 | }); | |
| 913 | 917 | |
| 914 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 918 | yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); | |
| 915 | 919 | |
| 916 | await Effect.runPromise( | |
| 917 | harness.engine.dispatch({ | |
| 918 | type: "thread.turn.start", | |
| 919 | commandId: CommandId.make("cmd-turn-start-restricted-2"), | |
| 920 | threadId: ThreadId.make("thread-1"), | |
| 921 | message: { | |
| 922 | messageId: asMessageId("user-message-restricted-2"), | |
| 923 | role: "user", | |
| 924 | text: "second", | |
| 925 | attachments: [], | |
| 926 | }, | |
| 927 | modelSelection: { | |
| 928 | instanceId: ProviderInstanceId.make("codex"), | |
| 929 | model: "gpt-5.1-codex", | |
| 930 | }, | |
| 931 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 932 | runtimeMode: "approval-required", | |
| 933 | createdAt: now, | |
| 934 | }), | |
| 935 | ); | |
| 920 | yield* harness.engine.dispatch({ | |
| 921 | type: "thread.turn.start", | |
| 922 | commandId: CommandId.make("cmd-turn-start-restricted-2"), | |
| 923 | threadId: ThreadId.make("thread-1"), | |
| 924 | message: { | |
| 925 | messageId: asMessageId("user-message-restricted-2"), | |
| 926 | role: "user", | |
| 927 | text: "second", | |
| 928 | attachments: [], | |
| 929 | }, | |
| 930 | modelSelection: { | |
| 931 | instanceId: ProviderInstanceId.make("codex"), | |
| 932 | model: "gpt-5.1-codex", | |
| 933 | }, | |
| 934 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 935 | runtimeMode: "approval-required", | |
| 936 | createdAt: now, | |
| 937 | }); | |
| 936 | 938 | |
| 937 | await waitFor(async () => { | |
| 938 | const readModel = await harness.readModel(); | |
| 939 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 940 | return ( | |
| 941 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | |
| 942 | false | |
| 943 | ); | |
| 944 | }); | |
| 939 | yield* Effect.promise(() => | |
| 940 | waitFor(async () => { | |
| 941 | const readModel = await harness.readModel(); | |
| 942 | const thread = readModel.threads.find( | |
| 943 | (entry) => entry.id === ThreadId.make("thread-1"), | |
| 944 | ); | |
| 945 | return ( | |
| 946 | thread?.activities.some( | |
| 947 | (activity) => activity.kind === "provider.turn.start.failed", | |
| 948 | ) ?? false | |
| 949 | ); | |
| 950 | }), | |
| 951 | ); | |
| 945 | 952 | |
| 946 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); | |
| 947 | const readModel = await harness.readModel(); | |
| 948 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 949 | expect( | |
| 950 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | |
| 951 | ).toMatchObject({ | |
| 952 | payload: { | |
| 953 | detail: expect.stringContaining("cannot switch models after the conversation has started"), | |
| 954 | }, | |
| 955 | }); | |
| 956 | }); | |
| 953 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); | |
| 954 | const readModel = yield* Effect.promise(() => harness.readModel()); | |
| 955 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 956 | expect( | |
| 957 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | |
| 958 | ).toMatchObject({ | |
| 959 | payload: { | |
| 960 | detail: expect.stringContaining( | |
| 961 | "cannot switch models after the conversation has started", | |
| 962 | ), | |
| 963 | }, | |
| 964 | }); | |
| 965 | }), | |
| 966 | ); | |
| 957 | 967 | |
| 958 | 968 | it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { |
| 959 | 969 | const harness = await createHarness({ |
apps/server/src/provider/Layers/GrokAdapter.test.ts+13-12| ... | ... | @@ -45,20 +45,21 @@ exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" |
| 45 | 45 | return wrapperPath; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | async function waitForFileContent(filePath: string, attempts = 40): Promise<string> { | |
| 49 | const readAttempt = async (remainingAttempts: number): Promise<string> => { | |
| 50 | if (remainingAttempts <= 0) { | |
| 51 | throw new Error(`Timed out waiting for file content at ${filePath}`); | |
| 52 | } | |
| 53 | try { | |
| 54 | const raw = await readFile(filePath, "utf8"); | |
| 48 | function waitForFileContent(filePath: string, attempts = 40): Effect.Effect<string> { | |
| 49 | const readAttempt = (remainingAttempts: number): Effect.Effect<string> => | |
| 50 | Effect.gen(function* () { | |
| 51 | if (remainingAttempts <= 0) { | |
| 52 | return yield* Effect.die(new Error(`Timed out waiting for file content at ${filePath}`)); | |
| 53 | } | |
| 54 | const raw = yield* Effect.tryPromise(() => readFile(filePath, "utf8")).pipe( | |
| 55 | Effect.orElseSucceed(() => ""), | |
| 56 | ); | |
| 55 | 57 | if (raw.trim().length > 0) { |
| 56 | 58 | return raw; |
| 57 | 59 | } |
| 58 | } catch {} | |
| 59 | await Effect.runPromise(Effect.sleep("25 millis")); | |
| 60 | return readAttempt(remainingAttempts - 1); | |
| 61 | }; | |
| 60 | yield* Effect.sleep("25 millis"); | |
| 61 | return yield* readAttempt(remainingAttempts - 1); | |
| 62 | }); | |
| 62 | 63 | return readAttempt(attempts); |
| 63 | 64 | } |
| 64 | 65 | |
| ... | ... | @@ -169,7 +170,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 169 | 170 | |
| 170 | 171 | yield* adapter.stopSession(threadId); |
| 171 | 172 | |
| 172 | const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); | |
| 173 | const exitLog = yield* waitForFileContent(exitLogPath); | |
| 173 | 174 | assert.include(exitLog, "SIGTERM"); |
| 174 | 175 | }), |
| 175 | 176 | ); |
apps/server/src/provider/Layers/GrokProvider.test.ts+54-61| ... | ... | @@ -1,67 +1,60 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 5 | import * as Path from "effect/Path"; |
| 5 | 6 | import * as Schema from "effect/Schema"; |
| 6 | import { describe, expect, it } from "vite-plus/test"; | |
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 8 | 7 | import { GrokSettings } from "@t3tools/contracts"; |
| 9 | 8 | |
| 10 | 9 | import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; |
| 11 | 10 | |
| 12 | 11 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 13 | 12 | |
| 14 | const runNode = <A, E>( | |
| 15 | effect: Effect.Effect< | |
| 16 | A, | |
| 17 | E, | |
| 18 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 19 | >, | |
| 20 | ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); | |
| 21 | ||
| 22 | 13 | describe("buildInitialGrokProviderSnapshot", () => { |
| 23 | it("returns a disabled snapshot when settings.enabled is false", async () => { | |
| 24 | const snapshot = await Effect.runPromise( | |
| 25 | buildInitialGrokProviderSnapshot(decodeGrokSettings({ enabled: false })), | |
| 26 | ); | |
| 27 | expect(snapshot.enabled).toBe(false); | |
| 28 | expect(snapshot.status).toBe("disabled"); | |
| 29 | expect(snapshot.installed).toBe(false); | |
| 30 | expect(snapshot.message).toContain("disabled"); | |
| 31 | }); | |
| 14 | it.effect("returns a disabled snapshot when settings.enabled is false", () => | |
| 15 | Effect.gen(function* () { | |
| 16 | const snapshot = yield* buildInitialGrokProviderSnapshot( | |
| 17 | decodeGrokSettings({ enabled: false }), | |
| 18 | ); | |
| 19 | expect(snapshot.enabled).toBe(false); | |
| 20 | expect(snapshot.status).toBe("disabled"); | |
| 21 | expect(snapshot.installed).toBe(false); | |
| 22 | expect(snapshot.message).toContain("disabled"); | |
| 23 | }), | |
| 24 | ); | |
| 32 | 25 | |
| 33 | it("returns a pending snapshot by default", async () => { | |
| 34 | const snapshot = await Effect.runPromise( | |
| 35 | buildInitialGrokProviderSnapshot(decodeGrokSettings({})), | |
| 36 | ); | |
| 37 | expect(snapshot.enabled).toBe(true); | |
| 38 | expect(snapshot.installed).toBe(true); | |
| 39 | expect(snapshot.status).toBe("warning"); | |
| 40 | expect(snapshot.version).toBeNull(); | |
| 41 | expect(snapshot.message).toContain("Checking Grok"); | |
| 42 | expect(snapshot.requiresNewThreadForModelChange).toBe(true); | |
| 43 | }); | |
| 26 | it.effect("returns a pending snapshot by default", () => | |
| 27 | Effect.gen(function* () { | |
| 28 | const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); | |
| 29 | expect(snapshot.enabled).toBe(true); | |
| 30 | expect(snapshot.installed).toBe(true); | |
| 31 | expect(snapshot.status).toBe("warning"); | |
| 32 | expect(snapshot.version).toBeNull(); | |
| 33 | expect(snapshot.message).toContain("Checking Grok"); | |
| 34 | expect(snapshot.requiresNewThreadForModelChange).toBe(true); | |
| 35 | }), | |
| 36 | ); | |
| 44 | 37 | }); |
| 45 | 38 | |
| 46 | describe("checkGrokProviderStatus", () => { | |
| 47 | it("reports the binary as missing when the binary path does not resolve", async () => { | |
| 48 | const snapshot = await runNode( | |
| 49 | checkGrokProviderStatus( | |
| 39 | it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | |
| 40 | it.effect("reports the binary as missing when the binary path does not resolve", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const snapshot = yield* checkGrokProviderStatus( | |
| 50 | 43 | decodeGrokSettings({ |
| 51 | 44 | enabled: true, |
| 52 | 45 | binaryPath: "/definitely/not/installed/grok-binary", |
| 53 | 46 | }), |
| 54 | ), | |
| 55 | ); | |
| 56 | expect(snapshot.enabled).toBe(true); | |
| 57 | expect(snapshot.installed).toBe(false); | |
| 58 | expect(snapshot.status).toBe("error"); | |
| 59 | expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); | |
| 60 | }); | |
| 47 | ); | |
| 48 | expect(snapshot.enabled).toBe(true); | |
| 49 | expect(snapshot.installed).toBe(false); | |
| 50 | expect(snapshot.status).toBe("error"); | |
| 51 | expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); | |
| 52 | }), | |
| 53 | ); | |
| 61 | 54 | |
| 62 | it("reports an installed CLI as unhealthy when --version exits non-zero", async () => { | |
| 63 | const snapshot = await runNode( | |
| 64 | Effect.scoped( | |
| 55 | it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => | |
| 56 | Effect.gen(function* () { | |
| 57 | const snapshot = yield* Effect.scoped( | |
| 65 | 58 | Effect.gen(function* () { |
| 66 | 59 | const fs = yield* FileSystem.FileSystem; |
| 67 | 60 | const path = yield* Path.Path; |
| ... | ... | @@ -77,18 +70,18 @@ describe("checkGrokProviderStatus", () => { |
| 77 | 70 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), |
| 78 | 71 | ); |
| 79 | 72 | }), |
| 80 | ), | |
| 81 | ); | |
| 73 | ); | |
| 82 | 74 | |
| 83 | expect(snapshot.enabled).toBe(true); | |
| 84 | expect(snapshot.installed).toBe(true); | |
| 85 | expect(snapshot.status).toBe("error"); | |
| 86 | expect(snapshot.message).toContain("broken grok install"); | |
| 87 | }); | |
| 75 | expect(snapshot.enabled).toBe(true); | |
| 76 | expect(snapshot.installed).toBe(true); | |
| 77 | expect(snapshot.status).toBe("error"); | |
| 78 | expect(snapshot.message).toContain("broken grok install"); | |
| 79 | }), | |
| 80 | ); | |
| 88 | 81 | |
| 89 | it("reports an error when ACP model discovery is unavailable", async () => { | |
| 90 | const snapshot = await runNode( | |
| 91 | Effect.scoped( | |
| 82 | it.effect("reports an error when ACP model discovery is unavailable", () => | |
| 83 | Effect.gen(function* () { | |
| 84 | const snapshot = yield* Effect.scoped( | |
| 92 | 85 | Effect.gen(function* () { |
| 93 | 86 | const fs = yield* FileSystem.FileSystem; |
| 94 | 87 | const path = yield* Path.Path; |
| ... | ... | @@ -104,12 +97,12 @@ describe("checkGrokProviderStatus", () => { |
| 104 | 97 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), |
| 105 | 98 | ); |
| 106 | 99 | }), |
| 107 | ), | |
| 108 | ); | |
| 100 | ); | |
| 109 | 101 | |
| 110 | expect(snapshot.status).toBe("error"); | |
| 111 | expect(snapshot.installed).toBe(true); | |
| 112 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); | |
| 113 | expect(snapshot.message).toContain("ACP startup failed"); | |
| 114 | }); | |
| 102 | expect(snapshot.status).toBe("error"); | |
| 103 | expect(snapshot.installed).toBe(true); | |
| 104 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); | |
| 105 | expect(snapshot.message).toContain("ACP startup failed"); | |
| 106 | }), | |
| 107 | ); | |
| 115 | 108 | }); |
apps/server/src/provider/acp/GrokAcpSupport.test.ts+37-37| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | applyGrokAcpModelSelection, |
| ... | ... | @@ -49,61 +49,61 @@ describe("applyGrokAcpModelSelection", () => { |
| 49 | 49 | return { runtime, modelCalls }; |
| 50 | 50 | }; |
| 51 | 51 | |
| 52 | it("calls session/set_model when the requested model differs from current", async () => { | |
| 53 | const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 54 | const result = await Effect.runPromise( | |
| 55 | applyGrokAcpModelSelection({ | |
| 52 | it.effect("calls session/set_model when the requested model differs from current", () => | |
| 53 | Effect.gen(function* () { | |
| 54 | const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 55 | const result = yield* applyGrokAcpModelSelection({ | |
| 56 | 56 | runtime, |
| 57 | 57 | currentModelId: "grok-build", |
| 58 | 58 | requestedModelId: "grok-mock-alt", |
| 59 | 59 | mapError: (cause) => cause.message, |
| 60 | }), | |
| 61 | ); | |
| 62 | expect(modelCalls).toEqual(["grok-mock-alt"]); | |
| 63 | expect(result).toBe("grok-mock-alt"); | |
| 64 | }); | |
| 60 | }); | |
| 61 | expect(modelCalls).toEqual(["grok-mock-alt"]); | |
| 62 | expect(result).toBe("grok-mock-alt"); | |
| 63 | }), | |
| 64 | ); | |
| 65 | 65 | |
| 66 | it("skips set_model when requested matches current", async () => { | |
| 67 | const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 68 | const result = await Effect.runPromise( | |
| 69 | applyGrokAcpModelSelection({ | |
| 66 | it.effect("skips set_model when requested matches current", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 69 | const result = yield* applyGrokAcpModelSelection({ | |
| 70 | 70 | runtime, |
| 71 | 71 | currentModelId: "grok-build", |
| 72 | 72 | requestedModelId: "grok-build", |
| 73 | 73 | mapError: (cause) => cause.message, |
| 74 | }), | |
| 75 | ); | |
| 76 | expect(modelCalls).toEqual([]); | |
| 77 | expect(result).toBe("grok-build"); | |
| 78 | }); | |
| 74 | }); | |
| 75 | expect(modelCalls).toEqual([]); | |
| 76 | expect(result).toBe("grok-build"); | |
| 77 | }), | |
| 78 | ); | |
| 79 | 79 | |
| 80 | it("skips set_model when no model is requested", async () => { | |
| 81 | const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 82 | const result = await Effect.runPromise( | |
| 83 | applyGrokAcpModelSelection({ | |
| 80 | it.effect("skips set_model when no model is requested", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 83 | const result = yield* applyGrokAcpModelSelection({ | |
| 84 | 84 | runtime, |
| 85 | 85 | currentModelId: "grok-build", |
| 86 | 86 | requestedModelId: undefined, |
| 87 | 87 | mapError: (cause) => cause.message, |
| 88 | }), | |
| 89 | ); | |
| 90 | expect(modelCalls).toEqual([]); | |
| 91 | expect(result).toBe("grok-build"); | |
| 92 | }); | |
| 88 | }); | |
| 89 | expect(modelCalls).toEqual([]); | |
| 90 | expect(result).toBe("grok-build"); | |
| 91 | }), | |
| 92 | ); | |
| 93 | 93 | |
| 94 | it("propagates session/set_model failures via mapError", async () => { | |
| 95 | const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); | |
| 96 | const { runtime } = makeRecordingRuntime(failure); | |
| 97 | const error = await Effect.runPromise( | |
| 98 | Effect.flip( | |
| 94 | it.effect("propagates session/set_model failures via mapError", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); | |
| 97 | const { runtime } = makeRecordingRuntime(failure); | |
| 98 | const error = yield* Effect.flip( | |
| 99 | 99 | applyGrokAcpModelSelection({ |
| 100 | 100 | runtime, |
| 101 | 101 | currentModelId: "grok-build", |
| 102 | 102 | requestedModelId: "grok-mock-alt", |
| 103 | 103 | mapError: (cause) => cause.message, |
| 104 | 104 | }), |
| 105 | ), | |
| 106 | ); | |
| 107 | expect(error).toBe(failure.message); | |
| 108 | }); | |
| 105 | ); | |
| 106 | expect(error).toBe(failure.message); | |
| 107 | }), | |
| 108 | ); | |
| 109 | 109 | }); |