Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0a91001
Add Copilot provider and fix thread provider binding
zortos293 Mar 20, 2026
6223687
Add clean Copilot reasoning and merge no-response tool calls
zortos293 Mar 20, 2026
8571a8c
Improve Copilot session cleanup and model slug mapping
zortos293 Mar 20, 2026
8c7274c
Default Copilot reasoning to high when supported
zortos293 Mar 21, 2026
0790466
Merge branch 'main' into feat/copilot-integration
zortos293 Mar 23, 2026
590720b
Refactor Copilot adapter via shared tools and SDK v0.2.0 to fix icon …
zortos293 Mar 23, 2026
b5db9b5
Reduce copilot integration diff
zortos293 Mar 23, 2026
0adeb68
Downgrade mockServiceWorker package version to 2.12.10
zortos293 Mar 23, 2026
c58be52
Merge branch 'main' into feat/copilot-integration
zortos293 Mar 24, 2026
c126b6d
feat: enhance Copilot integration with dynamic tool titles and improv…
zortos293 Mar 24, 2026
a49626a
fix: merge conflicts
zortos293 Mar 24, 2026
bcc20c3
Merge branch 'main' of https://github.com/pingdotgg/t3code into feat/…
zortos293 Mar 24, 2026
9d7fd1e
Format merged settings route
zortos293 Mar 24, 2026
c8ff188
Merge branch 'main' into feat/copilot-integration
zortos293 Mar 24, 2026
ed307b5
feat: add GitHub Copilot settings to provider options and update rela…
zortos293 Mar 24, 2026
0dd1ed8
Keep prior work log entries visible
zortos293 Mar 24, 2026
79262e2
feat: enhance file change handling for dynamic tools and update relat…
zortos293 Mar 24, 2026
8003f65
feat: implement file change handling for Copilot integration and upda…
zortos293 Mar 24, 2026
f483761
feat: enhance extractChangedFilesFromDiff to handle undefined detail …
zortos293 Mar 24, 2026
c40d8c3
Merge branch 'main' into feat/copilot-integration
zortos293 Mar 24, 2026
ae69ec0
feat: update work log entry handling and clean up unused code in Mess…
zortos293 Mar 24, 2026
fe0aa61
feat: enhance CopilotTraitsPicker UI and improve reasoning effort dis…
zortos293 Mar 24, 2026
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
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"@anthropic-ai/claude-agent-sdk": "^0.2.77",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@github/copilot": "1.0.10",
"@github/copilot-sdk": "0.2.0",
"@pierre/diffs": "^1.1.0-beta.16",
"effect": "catalog:",
"node-pty": "^1.1.0",
Expand Down
116 changes: 81 additions & 35 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ describe("ProviderCommandReactor", () => {
typeof input === "object" &&
input !== null &&
"provider" in input &&
(input.provider === "codex" || input.provider === "claudeAgent")
(input.provider === "codex" ||
input.provider === "claudeAgent" ||
input.provider === "copilot")
? input.provider
: "codex";
const resumeCursor =
Expand Down Expand Up @@ -227,10 +229,10 @@ describe("ProviderCommandReactor", () => {
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(NodeServices.layer),
);
const runtime = ManagedRuntime.make(layer);
const testRuntime = (runtime = ManagedRuntime.make(layer));

const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
const engine = await testRuntime.runPromise(Effect.service(OrchestrationEngineService));
const reactor = await testRuntime.runPromise(Effect.service(ProviderCommandReactor));
scope = await Effect.runPromise(Scope.make("sequential"));
await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
const drain = () => Effect.runPromise(reactor.drain);
Expand Down Expand Up @@ -298,8 +300,8 @@ describe("ProviderCommandReactor", () => {
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 1);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
await waitFor(() => harness.startSession.mock.calls.length > 0);
await waitFor(() => harness.sendTurn.mock.calls.length > 0);
expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
cwd: "/tmp/provider-project",
Expand Down Expand Up @@ -342,8 +344,8 @@ describe("ProviderCommandReactor", () => {
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 1);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
await waitFor(() => harness.startSession.mock.calls.length > 0);
await waitFor(() => harness.sendTurn.mock.calls.length > 0);
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
model: "gpt-5.3-codex",
modelOptions: {
Expand Down Expand Up @@ -503,7 +505,7 @@ describe("ProviderCommandReactor", () => {
});
});

it("rejects a first turn when requested provider conflicts with the thread model", async () => {
it("binds a pristine thread to the explicitly requested provider on first turn", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

Expand All @@ -515,44 +517,34 @@ describe("ProviderCommandReactor", () => {
message: {
messageId: asMessageId("user-message-provider-first"),
role: "user",
text: "hello claude",
text: "hello copilot",
attachments: [],
},
provider: "claudeAgent",
provider: "copilot",
model: "claude-sonnet-4.6",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(async () => {
const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find(
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
);
return (
thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
false
);
});
await waitFor(() => harness.startSession.mock.calls.length === 1);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);

expect(harness.startSession).not.toHaveBeenCalled();
expect(harness.sendTurn).not.toHaveBeenCalled();
expect(harness.startSession).toHaveBeenCalledWith(
ThreadId.makeUnsafe("thread-1"),
expect.objectContaining({
provider: "copilot",
model: "claude-sonnet-4.6",
}),
);

const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
expect(thread?.session).toBeNull();
expect(
thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
).toMatchObject({
summary: "Provider turn start failed",
payload: {
detail: expect.stringContaining("cannot switch to 'claudeAgent'"),
},
});
expect(thread?.session?.providerName).toBe("copilot");
});

it("rejects a turn when the requested model belongs to a different provider", async () => {
it("infers the provider from the selected model on a pristine thread", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

Expand All @@ -574,6 +566,60 @@ describe("ProviderCommandReactor", () => {
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 1);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);

expect(harness.startSession).toHaveBeenCalledWith(
ThreadId.makeUnsafe("thread-1"),
expect.objectContaining({
provider: "claudeAgent",
model: "claude-sonnet-4-6",
}),
);
});

it("rejects a provider-scoped model change after the thread is already bound", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-established-model-provider-1"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-established-model-provider-1"),
role: "user",
text: "first",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 1);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-established-model-provider-2"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-established-model-provider-2"),
role: "user",
text: "second",
attachments: [],
},
model: "claude-sonnet-4-6",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(async () => {
const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find(
Expand All @@ -585,8 +631,8 @@ describe("ProviderCommandReactor", () => {
);
});

expect(harness.startSession).not.toHaveBeenCalled();
expect(harness.sendTurn).not.toHaveBeenCalled();
expect(harness.startSession.mock.calls.length).toBe(1);
expect(harness.sendTurn.mock.calls.length).toBe(1);

const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
Expand Down
34 changes: 29 additions & 5 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
ProviderCommandReactor,
type ProviderCommandReactorShape,
} from "../Services/ProviderCommandReactor.ts";
import { inferProviderForModel } from "@t3tools/shared/model";
import { getModelOptions, inferProviderForModel, normalizeModelSlug } from "@t3tools/shared/model";

type ProviderIntentEvent = Extract<
OrchestrationEvent,
Expand Down Expand Up @@ -139,6 +139,13 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
}

function isBuiltInModelForProvider(provider: ProviderKind, model: string | undefined): boolean {
const normalized = normalizeModelSlug(model, provider);
return (
normalized !== null && getModelOptions(provider).some((option) => option.slug === normalized)
);
}

const make = Effect.gen(function* () {
const orchestrationEngine = yield* OrchestrationEngineService;
const providerService = yield* ProviderService;
Expand Down Expand Up @@ -233,8 +240,23 @@ const make = Effect.gen(function* () {
)
? thread.session.providerName
: undefined;
const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model);
if (options?.provider !== undefined && options.provider !== threadProvider) {
const defaultThreadProvider = inferProviderForModel(thread.model);
const isThreadProviderLocked = currentProvider !== undefined || thread.latestTurn !== null;
const requestedProvider =
options?.provider ??
(options?.model !== undefined
? inferProviderForModel(options.model, defaultThreadProvider)
: undefined);
const threadProvider: ProviderKind =
currentProvider ??
(isThreadProviderLocked
? defaultThreadProvider
: (requestedProvider ?? defaultThreadProvider));
if (
isThreadProviderLocked &&
options?.provider !== undefined &&
options.provider !== threadProvider
) {
return yield* new ProviderAdapterRequestError({
provider: threadProvider,
method: "thread.turn.start",
Expand All @@ -243,15 +265,17 @@ const make = Effect.gen(function* () {
}
if (
options?.model !== undefined &&
inferProviderForModel(options.model, threadProvider) !== threadProvider
inferProviderForModel(options.model, threadProvider) !== threadProvider &&
!isBuiltInModelForProvider(threadProvider, options.model)
) {
return yield* new ProviderAdapterRequestError({
provider: threadProvider,
method: "thread.turn.start",
detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`,
});
}
const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
const preferredProvider: ProviderKind =
currentProvider ?? requestedProvider ?? defaultThreadProvider;
const desiredModel = options?.model ?? thread.model;
const effectiveCwd = resolveThreadWorkspaceCwd({
thread,
Expand Down
Loading