Skip to content
Open
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# T3 Code

T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients.
T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro, GitHub Copilot) and serves web, desktop, and mobile clients.

You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.

Expand Down Expand Up @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste

- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Kiro, and GitHub Copilot each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code.
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,12 @@ describe("resolveSubagentStopTarget", () => {
}),
).toBeNull();
expect(resolveSubagentStopTarget({ ...native, runId: null })).toBeNull();
expect(
resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("githubCopilot") }),
).toEqual({ kind: "subagent", input: { threadId: native.threadId, subagentId: native.id } });
expect(
resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("acpRegistry") }),
).toBeNull();
expect(resolveSubagentStopTarget({ ...native, status: "completed" })).toBeNull();
expect(
resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("codex") }),
Expand Down
19 changes: 7 additions & 12 deletions apps/mobile/src/features/threads/threadAgentsPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import {
type SubagentWorkflowGroup,
} from "@t3tools/client-runtime/state/subagentRuntime";
import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution";
import type {
EnvironmentId,
OrchestrationV2Subagent,
ServerProvider,
ThreadId,
import {
canStopNativeSubagent,
type EnvironmentId,
type OrchestrationV2Subagent,
type ServerProvider,
type ThreadId,
} from "@t3tools/contracts";
import { resolveSelectableModel } from "@t3tools/shared/model";

Expand Down Expand Up @@ -238,13 +239,7 @@ export function resolveSubagentStopTarget(
input: { threadId: subagent.threadId, subagentId: subagent.id },
};
}
if (
subagent.origin === "provider_native" &&
subagent.driver === "claudeAgent" &&
subagent.runId !== null &&
subagent.nativeTaskRef?.strength === "strong" &&
subagent.nativeTaskRef.nativeId !== null
) {
if (canStopNativeSubagent(subagent)) {
return {
kind: "subagent" as const,
input: { threadId: subagent.threadId, subagentId: subagent.id },
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@napi-rs/keyring": "^1.3.0",
"@t3tools/provider-acp": "workspace:*",
"@t3tools/provider-acp-registry": "workspace:*",
"@t3tools/provider-copilot": "workspace:*",
"@t3tools/provider-core": "workspace:*",
"@t3tools/provider-cursor": "workspace:*",
"@t3tools/provider-fx": "workspace:*",
Expand Down
281 changes: 281 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3396,6 +3396,70 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("keeps a captured proposed plan actionable after its turn completes", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const instanceId = ProviderInstanceId.make("acp-test-v2-captured-plan");
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
proposedPlanMarkdown: () => "# Plan",
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath: yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
),
}),
},
selfInvocation: yield* resolveSelfInvocation(),
});
const threadId = ThreadId.make("thread-acp-v2-captured-plan");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "plan",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-acp-v2-captured-plan"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
ordinal: 1,
}),
);
const events = Array.from(
yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
),
);
const plans = events.flatMap((event) =>
event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [],
);
assert.deepEqual(
plans.map((plan) => plan.status),
["active"],
);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down Expand Up @@ -5140,6 +5204,223 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("rebuilds the snapshot and resends instructions after a native rewind", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const instanceId = ProviderInstanceId.make("acp-test-native-rewind");
const prompts: Array<string> = [];
let loads = 0;
let extensions: AcpAdapterV2ExtensionContext | undefined;
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
enablePostSettleContinuation: true,
rollbackSession: () => Effect.void,
registerExtensions: (context) =>
Effect.sync(() => {
extensions = context;
}),
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
wrapRuntime: (nativeRuntime) => ({
...nativeRuntime,
prompt: (payload, options) => {
prompts.push(JSON.stringify(payload.prompt));
return nativeRuntime.prompt(payload, options);
},
loadSession: (sessionId, activation) => {
loads += 1;
return extensions!
.applyBackgroundTaskMutation({ sessionId, taskId: "task-1", status: "running" })
.pipe(Effect.andThen(nativeRuntime.loadSession(sessionId, activation)));
},
}),
}),
},
selfInvocation,
continuationRequests: { offer: () => Effect.void },
});
const threadId = ThreadId.make("thread-acp-native-rewind");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-acp-native-rewind"),
modelSelection,
runtimePolicy,
});
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
yield* runtime.events.pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped,
);
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
const runTurn = Effect.fn("runTurn")(function* (ordinal: number) {
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
ordinal,
}),
);
while ((yield* Queue.take(events)).type !== "turn.terminal");
});
yield* runTurn(1);
const [providerTurn] = (yield* runtime.readThreadSnapshot({ providerThread })).providerTurns;
assert.isDefined(providerTurn);
assert.strictEqual(loads, 0);

yield* runtime.rollbackThread({
providerThread,
providerThreadTurns: [],
target: {
type: "provider_turn",
checkpointId: CheckpointId.make("checkpoint-acp-native-rewind"),
appRunOrdinal: 1,
providerTurn: {
...providerTurn!,
nativeTurnRef: {
driver: ACP_TEST_DRIVER,
strength: "strong",
nativeId: "native-turn-1",
},
},
},
});

const snapshot = yield* runtime.readThreadSnapshot({ providerThread });
assert.strictEqual(loads, 1);
assert.deepEqual(snapshot.providerTurns, []);
assert.isTrue(yield* runtime.hasPendingBackgroundWork!);

yield* runTurn(2);
assert.strictEqual(prompts.length, 2);
assert.strictEqual(prompts[1], prompts[0]);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("rewinds the thread's own session, starting afresh if it cannot load", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const instanceId = ProviderInstanceId.make("acp-test-rewind-target");
const rewound: Array<string> = [];
let activeSession = "";
let loadFails = false;
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
rollbackSession: (_runtime, sessionId) =>
Effect.sync(() => {
rewound.push(sessionId);
}),
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
wrapRuntime: (nativeRuntime) => ({
...nativeRuntime,
loadSession: (_sessionId, activation) =>
loadFails
? Effect.fail(
new EffectAcpErrors.AcpTransportError({ cause: new Error("no session") }),
)
: nativeRuntime.loadSession(activeSession, activation),
}),
}),
},
selfInvocation,
});
const threadId = ThreadId.make("thread-acp-rewind-target");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-acp-rewind-target"),
modelSelection,
runtimePolicy,
});
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
yield* runtime.events.pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped,
);
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
activeSession = providerThread.nativeThreadRef!.nativeId!;
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
}),
);
while ((yield* Queue.take(events)).type !== "turn.terminal");
const [providerTurn] = (yield* runtime.readThreadSnapshot({ providerThread })).providerTurns;
const rollback = {
providerThread: {
...providerThread,
nativeThreadRef: { ...providerThread.nativeThreadRef!, nativeId: "other-session" },
},
providerThreadTurns: [],
target: {
type: "provider_turn",
checkpointId: CheckpointId.make("checkpoint-acp-rewind-target"),
appRunOrdinal: 1,
providerTurn: {
...providerTurn!,
nativeTurnRef: {
driver: ACP_TEST_DRIVER,
strength: "strong",
nativeId: "native-turn-1",
},
},
},
} as const;

yield* runtime.rollbackThread(rollback);
assert.deepEqual(rewound, ["other-session"]);

loadFails = true;
const replaced = yield* runtime.rollbackThread(rollback);
assert.deepEqual(rewound, ["other-session"]);
assert.notStrictEqual(replaced.providerThread.nativeThreadRef?.nativeId, "other-session");
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("quarantines callbacks from a failed rollback replacement before retrying", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
Loading
Loading