Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ const make = Effect.gen(function* () {

const desiredRuntimeMode = thread.runtimeMode;
const currentProvider: ProviderKind | undefined =
thread.session?.providerName === "codex" ? thread.session.providerName : undefined;
thread.session?.providerName === "codex" || thread.session?.providerName === "claude"
? thread.session.providerName
: undefined;
const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
const desiredModel = options?.model ?? thread.model;
const effectiveCwd = resolveThreadWorkspaceCwd({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";
import type { OrchestrationReadModel, ProviderKind, ProviderRuntimeEvent } from "@t3tools/contracts";
import {
ApprovalRequestId,
CommandId,
Expand Down Expand Up @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
type LegacyProviderRuntimeEvent = {
readonly type: string;
readonly eventId: EventId;
readonly provider: "codex";
readonly provider: ProviderKind;
readonly createdAt: string;
readonly threadId: ThreadId;
readonly turnId?: string | undefined;
Expand Down
195 changes: 195 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import assert from "node:assert/strict";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { ThreadId } from "@t3tools/contracts";
import { it } from "@effect/vitest";
import { Effect, Layer } from "effect";
import { vi } from "vitest";

const mockState = vi.hoisted(() => {
class MockEmitter {
private readonly listeners = new Map<string, Set<(...args: Array<any>) => void>>();

on(event: string, listener: (...args: Array<any>) => void) {
const listeners = this.listeners.get(event) ?? new Set<(...args: Array<any>) => void>();
listeners.add(listener);
this.listeners.set(event, listeners);
return this;
}

once(event: string, listener: (...args: Array<any>) => void) {
const wrapped = (...args: Array<any>) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}

off(event: string, listener: (...args: Array<any>) => void) {
this.listeners.get(event)?.delete(listener);
return this;
}

emit(event: string, ...args: Array<any>) {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
}

class MockReadable extends MockEmitter {
write(data: string) {
this.emit("data", {
toString: () => data,
});
}
}

class MockChild extends MockEmitter {
readonly stdout = new MockReadable();
readonly stderr = new MockReadable();
readonly killCalls: Array<string | undefined> = [];

kill(signal?: string) {
this.killCalls.push(signal);
return true;
}

emitExit(code: number | null, signal: string | null) {
this.emit("exit", code, signal);
}
}

const spawnCalls: Array<{
binaryPath: string;
args: string[];
child: MockChild;
}> = [];

const spawnMock = vi.fn((binaryPath: string, args: string[]) => {
const child = new MockChild();
spawnCalls.push({
binaryPath,
args: [...args],
child,
});
return child;
});

return {
spawnCalls,
spawnMock,
};
});

vi.mock("node:child_process", () => ({
spawn: mockState.spawnMock,
}));

import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
import { makeClaudeAdapterLive } from "./ClaudeAdapter.ts";

const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
const waitForAsyncEffects = () => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0)));

const claudeAdapterLayer = it.layer(
makeClaudeAdapterLive().pipe(Layer.provideMerge(NodeServices.layer)),
);

claudeAdapterLayer("ClaudeAdapterLive lifecycle", (it) => {
it.effect("keeps the new turn interruptible after a stale exit from the previous session", () =>
Effect.gen(function* () {
mockState.spawnCalls.length = 0;
mockState.spawnMock.mockClear();

const adapter = yield* ClaudeAdapter;
const threadId = asThreadId("thread-stale-exit");

yield* adapter.startSession({
provider: "claude",
threadId,
runtimeMode: "full-access",
});
const firstTurn = yield* adapter.sendTurn({
threadId,
input: "first turn",
attachments: [],
});
const firstChild = mockState.spawnCalls[0]?.child;
assert.ok(firstChild);

yield* adapter.startSession({
provider: "claude",
threadId,
runtimeMode: "full-access",
});
assert.deepEqual(firstChild.killCalls, ["SIGTERM"]);

const secondTurn = yield* adapter.sendTurn({
threadId,
input: "second turn",
attachments: [],
});
const secondChild = mockState.spawnCalls[1]?.child;
assert.ok(secondChild);

firstChild.emitExit(null, "SIGTERM");
yield* waitForAsyncEffects();

const sessions = yield* adapter.listSessions();
assert.equal(sessions[0]?.threadId, threadId);
assert.equal(sessions[0]?.status, "running");
assert.equal(sessions[0]?.activeTurnId, secondTurn.turnId);

yield* adapter.interruptTurn(threadId);
assert.deepEqual(secondChild.killCalls, ["SIGTERM"]);
assert.equal(firstTurn.turnId === secondTurn.turnId, false);
}),
);

it.effect("does not mark a replaced session resumable from a stale completed-process exit", () =>
Effect.gen(function* () {
mockState.spawnCalls.length = 0;
mockState.spawnMock.mockClear();

const adapter = yield* ClaudeAdapter;
const threadId = asThreadId("thread-stale-complete");

yield* adapter.startSession({
provider: "claude",
threadId,
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId,
input: "first turn",
attachments: [],
});
const firstChild = mockState.spawnCalls[0]?.child;
assert.ok(firstChild);

firstChild.stdout.write(`${JSON.stringify({ type: "result", subtype: "success" })}\n`);
yield* waitForAsyncEffects();

yield* adapter.startSession({
provider: "claude",
threadId,
runtimeMode: "full-access",
});

firstChild.emitExit(0, null);

yield* adapter.sendTurn({
threadId,
input: "second turn",
attachments: [],
});

const secondSpawn = mockState.spawnCalls[1];
assert.ok(secondSpawn);
assert.equal(secondSpawn.binaryPath, "claude");
assert.equal(secondSpawn.args.includes("--session-id"), true);
assert.equal(secondSpawn.args.includes("--resume"), false);
}),
);
});
Loading