Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8269308
feat(cli): move a thread into another project
jonathanperis Oct 22, 2026
a438dfc
fix(cli): detach the provider session when a thread move changes proj…
jonathanperis Oct 22, 2026
1965578
feat(web): move a thread into another project from the menu
jonathanperis Oct 22, 2026
c12b6d1
fix(web): hide a project move when the server would drop it
jonathanperis Oct 22, 2026
bd466ac
fix(web): disable project move in the palette without operate access
jonathanperis Oct 22, 2026
b202ceb
Merge remote-tracking branch 'upstream/main' into feat/cli-thread-move
jonathanperis Oct 24, 2026
05cf057
Merge upstream/main into feat/cli-thread-move
jonathanperis Oct 25, 2026
4ab4361
Merge remote-tracking branch 'upstream/main' into feat/cli-thread-move
jonathanperis Oct 25, 2026
3b569f1
Merge remote-tracking branch 'upstream/main' into feat/cli-thread-move
jonathanperis Oct 25, 2026
d9e4b81
Merge remote-tracking branch 'upstream/main' into feat/cli-thread-move
jonathanperis Oct 26, 2026
e52cde4
Merge remote-tracking branch 'upstream/main' into feat/cli-thread-move
jonathanperis Oct 26, 2026
2ffdafe
Merge remote-tracking branch 'upstream/main' into enh/thread-move-dis…
jonathanperis Oct 26, 2026
d2fa4b8
feat(server): queue a thread's own project move until its turn ends
jonathanperis Oct 26, 2026
7b26605
feat(server): move a No project chat out of its scratch folder
jonathanperis Oct 26, 2026
aa51db0
feat(web): create the destination project from the move picker
jonathanperis Oct 26, 2026
9cba2a4
Merge remote-tracking branch 'upstream/main' into enh/thread-move-dis…
jonathanperis Oct 26, 2026
519f0f4
Merge remote-tracking branch 'upstream/main' into enh/thread-move-dis…
jonathanperis Oct 26, 2026
762141d
fix(web): hide palette empty state under the new-project form
jonathanperis Oct 26, 2026
b6b27a3
fix(server): start a fresh session when a moved thread's provider can…
jonathanperis Oct 26, 2026
78f9316
Merge branch 'main' into feat/cli-thread-move
jonathanperis Oct 26, 2026
0923c20
Merge remote-tracking branch 'upstream/main' into enh/thread-move-dis…
jonathanperis Oct 26, 2026
7e54bf4
fix(cli): name --keep-worktree when thread move refuses a worktree
jonathanperis Oct 26, 2026
7cde3b2
test(server): let the ACP mock agent mint a new session id per process
jonathanperis Oct 26, 2026
5500a9b
test(server): build the mock session id without crypto and narrow it …
jonathanperis Oct 26, 2026
1a79e8c
test(server): key the unique mock session id on the process id only
jonathanperis Oct 26, 2026
6a9f1aa
Merge remote-tracking branch 'upstream/main' into feat/cli-thread-move
jonathanperis Oct 26, 2026
11ee50b
fix(server): hand history to a fresh ACP session that reuses the stor…
jonathanperis Oct 26, 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
22 changes: 16 additions & 6 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ const exitAfterRunningCommandLaunch = process.env.T3_ACP_EXIT_AFTER_RUNNING_COMM
const omitXAiPromptCompleteStopReason =
process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1";
const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1";
// Advertise no session capabilities, so the agent can neither load nor resume.
const omitSessionCapabilities = process.env.T3_ACP_OMIT_SESSION_CAPABILITIES === "1";
const failLoadSessionAfterConfigReplay =
process.env.T3_ACP_FAIL_LOAD_SESSION_AFTER_CONFIG_REPLAY === "1";
const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1";
Expand Down Expand Up @@ -127,7 +129,11 @@ const permissionRequestCount = Math.max(
1,
Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1,
);
const sessionId = "mock-session-1";
// Real agents mint a new id for every session/new. The fixed id is the default
// because most tests assert it; set T3_ACP_UNIQUE_SESSION_IDS=1 to get a fresh
// id per process, as a real agent that cannot reopen sessions would.
const sessionId =
process.env.T3_ACP_UNIQUE_SESSION_IDS === "1" ? `mock-session-${process.pid}` : "mock-session-1";

let currentModeId = antigravityProfile ? "default" : "ask";
let currentModelId = antigravityProfile ? "gemini-test-low" : "default";
Expand Down Expand Up @@ -578,11 +584,15 @@ const program = Effect.gen(function* () {
info: { name: "t3-acp-mock-agent", version: "0.0.0" },
...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }),
capabilities: {
session: {
...(supportsSessionLifecycle ? { fork: {}, additionalDirectories: {} } : {}),
...(supportsV2Management ? { delete: {} } : {}),
...(supportsAcpMcp ? { mcp: { acp: {} } } : {}),
},
...(omitSessionCapabilities
? {}
: {
session: {
...(supportsSessionLifecycle ? { fork: {}, additionalDirectories: {} } : {}),
...(supportsV2Management ? { delete: {} } : {}),
...(supportsAcpMcp ? { mcp: { acp: {} } } : {}),
},
}),
...(supportsV2Management ? { providers: {} } : {}),
},
...(initializeAuthMethodId
Expand Down
31 changes: 29 additions & 2 deletions apps/server/src/cli/thread.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { assert, it } from "@effect/vitest";
import { RunId, ThreadId } from "@t3tools/contracts";
import { ProjectId, RunId, ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import { type ThreadTools, waitForRun } from "./thread.ts";
import { moveThread, ThreadToolFailedError, type ThreadTools, waitForRun } from "./thread.ts";

const threadId = ThreadId.make("thread-1");
const runId = RunId.make("run-1");
Expand Down Expand Up @@ -73,3 +73,30 @@ it.effect("--wait reports its run's result when a later run is the one waiting",
});
}),
);

it.effect("move names the --keep-worktree flag when the server refuses a worktree", () =>
Effect.gen(function* () {
const calls: Array<unknown> = [];
const call = ((tool: string, args: unknown) => {
calls.push(args);
return Effect.fail(
new ThreadToolFailedError({
tool,
detail: `Unable to move_project for thread ${threadId}: Thread ${threadId} has a worktree at /work/tree. Pass keepWorktree to move it without changing that checkout.`,
}),
);
}) as ThreadTools["call"];

const error = yield* Effect.flip(
moveThread({ call }, threadId, ProjectId.make("project-2"), false),
);

assert.strictEqual(
error.message,
`Unable to move_project for thread ${threadId}: Thread ${threadId} has a worktree at /work/tree. Pass --keep-worktree to move it without changing that checkout.`,
);
assert.deepStrictEqual(calls, [
{ threadId, action: "move_project", projectId: ProjectId.make("project-2") },
]);
}),
);
88 changes: 87 additions & 1 deletion apps/server/src/cli/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ const ThreadLaunchResult = Schema.Struct({
runId: Schema.NullOr(RunId),
});

const ThreadMoveResult = Schema.Struct({
threadId: ThreadId,
projectId: ProjectId,
worktreePath: Schema.NullOr(Schema.String),
/** Set when a No project chat moved out; its files stay in this folder. */
scratchFolder: Schema.optional(Schema.String),
/** Set when the thread's own agent ran this mid-turn; the move applies at turn end. */
movesWhenTurnEnds: Schema.optional(Schema.Literal(true)),
});

const toolFailureDetail = (result: typeof ToolResult.Type) => {
const text = result.content?.find((part) => part.text !== undefined)?.text ?? "";
return Option.match(decodeFailureText(text), {
Expand Down Expand Up @@ -699,12 +709,88 @@ const threadShowCommand = Command.make("show", {
),
);

const keepWorktreeFlag = Flag.Boolean("keep-worktree").pipe(
Flag.withDescription(
"Move a thread that has a worktree and leave that checkout in place. The next turn still runs there. A No project chat does not need it: its scratch folder stays on disk and the next turn starts in the destination.",
),
Flag.withDefault(false),
);

/**
* The server names the MCP argument (`keepWorktree`) when it refuses a
* worktree; from the CLI that argument is the `--keep-worktree` flag.
*/
export const moveThread = (
tools: ThreadTools,
threadId: ThreadId,
projectId: ProjectId,
keepWorktree: boolean,
) =>
tools
.call(
"t3_thread_update",
{
threadId,
action: "move_project",
projectId,
...(keepWorktree ? { keepWorktree: true } : {}),
},
ThreadMoveResult,
)
.pipe(
Effect.mapError((error) =>
error._tag === "ThreadToolFailedError"
? new ThreadToolFailedError({
tool: error.tool,
detail: error.detail.replace("Pass keepWorktree", "Pass --keep-worktree"),
})
: error,
),
);

const threadMoveCommand = Command.make("move", {
...projectLocationFlags,
thread: threadArgument,
project: Argument.String("project").pipe(
Argument.withDescription("Destination project id or a path inside that project."),
),
keepWorktree: keepWorktreeFlag,
json: jsonFlag,
}).pipe(
Command.withDescription(
"Move an idle thread into another project. Refuses a turn in progress, a subagent, a thread that still has a subagent, or a worktree unless --keep-worktree. A No project chat leaves its scratch folder behind, undeleted.",
),
Command.withHandler((flags) =>
Effect.gen(function* () {
const threadId = yield* decodeThreadId(flags.thread);
yield* withThreadTools(
flags,
Effect.fn("cli.thread.move")(function* (tools) {
const project = yield* resolveProject(tools, flags.project);
const moved = yield* moveThread(tools, threadId, project.id, flags.keepWorktree);
yield* flags.json
? printJson(moved)
: Console.log(
[
`${moved.threadId} ${moved.projectId}${moved.movesWhenTurnEnds === true ? " (moves when this turn ends)" : ""}`,
...(moved.scratchFolder === undefined
? []
: [`Earlier files stay in ${moved.scratchFolder}`]),
].join("\n"),
);
}),
);
}).pipe(reportThreadErrors),
),
);

export const threadCommand = Command.make("thread").pipe(
Command.withDescription("Start, message, and read threads on the running server."),
Command.withDescription("Start, message, read, and move threads on the running server."),
Command.withSubcommands([
threadListCommand,
threadStartCommand,
threadSendCommand,
threadShowCommand,
threadMoveCommand,
]),
);
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.usagePriceOverrides).toBe(true);
expect(second.capabilities.threadActiveReorder).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadProjectMove).toBe(true);
expect(second.capabilities.threadPullRequests).toBe(true);
expect(second.capabilities.threadPullRequestLinking).toBe(true);
expect(second.capabilities.serverResolvedCommandContext).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export const make = Effect.gen(function* () {
threadActiveReorder: true,
threadAutoSettleOptOut: true,
threadTitleRegeneration: true,
threadProjectMove: true,
commandOutputStreaming: true,
threadVisitedTracking: true,
threadPullRequests: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,16 @@ import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";

const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }),
),
);

// Effect returns a declared tool failure as `isError` with its encoded payload
Expand Down
Loading
Loading