diff --git a/README.md b/README.md index 722d1fa..a3961ff 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,51 @@ Command flags override the CLI config, which overrides the T3 project's saved mo Speed and thinking effort are stored as model options. T3 applies the option ids supported by the selected provider/model. If `--provider` changes the project's default provider instance, also pass `--model` because provider instance ids can be user-defined and do not imply a model. +## Continue an existing thread + +Use the **T3 thread ID** from a previous command's JSON `data.thread.id` or the +thread's browser URL (not its provider session ID): + +```bash +t3code --json threads send \ + --thread-id \ + --prompt "Continue from our previous discussion." \ + --open none + +t3code threads send --thread-id --stdin --open none < follow-up.txt +t3code threads send --thread-id --prompt-file follow-up.txt --dry-run +``` + +`threads send` submits another user message to the existing conversation. It +retains the thread's workspace, title, model/provider options, permission level, +and interaction mode. CLI defaults for creating threads do not override these +settings. Exactly one of `--prompt`, `--prompt-file`, or `--stdin` is required. + +The target must exist and be unarchived. Choose how to handle active work: + +- `--if-busy reject` (default) returns `THREAD_BUSY` without sending the prompt + when the thread has an active or pending turn. +- `--if-busy inject` dispatches the prompt immediately even when the thread is + busy, letting T3 and its provider handle the message during active work. + +```bash +t3code threads send --thread-id --if-busy inject \ + --prompt "Additional context for the work in progress…" --open none +``` + +Neither mode adds a CLI queue or explicitly interrupts the turn. Injection does +not guarantee identical steering behavior across providers or special turn types; +T3/provider errors can still occur after dispatch acceptance. Missing or archived +threads still fail with `THREAD_NOT_FOUND` or `THREAD_ARCHIVED` in either mode. +The busy check in reject mode is a snapshot check, not an atomic lock: concurrent +senders must serialize their requests. Provider session resumption and +conversation context are managed by T3 and the provider. + +`--dry-run` validates the target and prints the proposed command without sending +it or opening the UI. Success means T3 accepted the dispatch, not that the agent +finished its turn. A failed or timed-out dispatch never deletes the existing +thread; check T3 before retrying an ambiguous failure to avoid duplicate prompts. + ## Settings ```bash @@ -137,6 +182,7 @@ t3code projects list t3code projects resolve --cwd . t3code projects ensure --cwd . --project-policy create t3code threads create --stdin +t3code threads send --thread-id --stdin t3code handover --stdin t3code request get /api/orchestration/snapshot ``` diff --git a/src/cli.ts b/src/cli.ts index d0ac224..d3c3374 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import { listProjects, rawGet, resolveProject, + sendThreadPrompt, type ThreadCreateOptions, } from "./service.js"; import type { @@ -254,7 +255,30 @@ addProjectPolicyOption(addWorkspaceOptions(projects.command("ensure"))) }), ); -const threads = program.command("threads").description("Create T3 Code threads."); +const threads = program.command("threads").description("Create and continue T3 Code threads."); +threads.command("send") + .description("Send a prompt to an existing T3 thread, preserving its settings.") + .requiredOption("--thread-id ", "Existing T3 thread ID (not the provider session ID).") + .option("--prompt ", "Prompt to send.") + .option("--prompt-file ", "Read the prompt from a UTF-8 file.") + .option("--stdin", "Read the prompt from stdin.") + .addOption(new Option("--if-busy ", "Reject a busy thread or inject into its active work.").choices(["reject", "inject"]).default("reject")) + .addOption(new Option("--open ").choices(["auto", "desktop", "browser", "none"])) + .option("--dry-run", "Validate the target and print the command without dispatching it.") + .action((options: ThreadCommandOptions & { threadId: string; ifBusy: "reject" | "inject" }) => + action(async () => { + const context = await commandContext(); + const result = await sendThreadPrompt(context.config, { + threadId: options.threadId, + ifBusy: options.ifBusy, + prompt: await resolvePrompt(options), + ...(options.open ? { openMode: options.open } : {}), + ...(options.dryRun ? { dryRun: true } : {}), + }); + writeSuccess(result, context, `${result.dryRun ? "Would send" : "Sent"} prompt to thread ${result.thread.id}.`); + }), + ); + addThreadOptions(threads.command("create")) .description("Create a new project thread and start its first turn.") .action((options: ThreadCommandOptions) => diff --git a/src/service.test.ts b/src/service.test.ts index 38753e6..dc57f1b 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_CONFIG } from "./config.js"; import { CliError } from "./errors.js"; import { runProcess } from "./process.js"; -import { createHandoverThread } from "./service.js"; +import { createHandoverThread, sendThreadPrompt } from "./service.js"; import type { CliConfig, T3Project, T3Thread } from "./types.js"; const cleanup: Array<() => Promise> = []; @@ -35,6 +35,7 @@ async function testHarness( failTurn?: boolean; serverVersion?: string; settings?: Record; + shellUnavailable?: boolean; } = {}, ) { const root = await mkdtemp(path.join(os.tmpdir(), "t3code-cli-service-")); @@ -63,7 +64,11 @@ async function testHarness( json(response, 401, { error: "unauthorized" }); return; } - if (request.method === "GET" && request.url === "/api/orchestration/shell") { + if (request.url === "/api/orchestration/shell" && options.shellUnavailable) { + json(response, 404, { error: "not found" }); + return; + } + if (request.method === "GET" && ["/api/orchestration/shell", "/api/orchestration/snapshot"].includes(request.url ?? "")) { json(response, 200, { snapshotSequence: commands.length, projects, @@ -112,7 +117,7 @@ async function testHarness( } if (command.type === "thread.turn.start" && options.failTurn) { const index = threads.findIndex((thread) => thread.id === command.threadId); - if (index >= 0) threads.splice(index, 1); + if (index >= 0 && command.bootstrap) threads.splice(index, 1); json(response, 500, { error: "turn failed" }); return; } @@ -512,3 +517,145 @@ describe("createHandoverThread", () => { expect(harness.threads).toHaveLength(0); }); }); + +function existingThread(overrides: Partial = {}): T3Thread { + return { + id: "existing-thread", + projectId: "existing-project", + title: "Existing conversation", + archivedAt: null, + runtimeMode: "approval-required", + interactionMode: "plan", + modelSelection: { instanceId: "custom-provider", model: "saved-model" }, + session: { status: "ready", activeTurnId: null }, + latestTurn: { state: "completed" }, + ...overrides, + }; +} + +describe("sendThreadPrompt", () => { + it.each([ + { session: { status: "starting" } }, + { session: { status: "running", activeTurnId: "active-turn" } }, + { session: { status: "ready", activeTurnId: "active-turn" } }, + { latestTurn: { state: "pending" } }, + { latestTurn: { state: "running" } }, + ] satisfies Array>)("allows explicit injection into busy state %j", async (state) => { + const harness = await testHarness(); + harness.threads.push(existingThread(state)); + await expect(sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Reject this", ifBusy: "reject", + })).rejects.toMatchObject({ code: "THREAD_BUSY" }); + expect(harness.commands).toEqual([]); + const preview = await sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Preview injection", ifBusy: "inject", dryRun: true, + }); + expect(preview.thread.dispatch).toBeNull(); + expect(harness.commands).toEqual([]); + await sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Incorporate this update", ifBusy: "inject", + }); + expect(harness.commands).toHaveLength(1); + expect(harness.commands[0]).toMatchObject({ + type: "thread.turn.start", threadId: "existing-thread", + runtimeMode: "approval-required", interactionMode: "plan", + message: { text: "Incorporate this update" }, + }); + expect(harness.commands[0]).not.toHaveProperty("modelSelection"); + expect(harness.threads).toEqual([existingThread(state)]); + }); + + it.each([ + [{ archivedAt: "2026-01-01T00:00:00Z" }, "THREAD_ARCHIVED"], + [{ deletedAt: "2026-01-01T00:00:00Z" }, "THREAD_NOT_FOUND"], + ] satisfies Array<[Partial, string]>)("injection still rejects unavailable targets %j", async (state, code) => { + const harness = await testHarness(); + harness.threads.push(existingThread(state)); + await expect(sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Update", ifBusy: "inject", + })).rejects.toMatchObject({ code }); + expect(harness.commands).toEqual([]); + }); + + it("sends only a turn to the exact existing thread and preserves its settings", async () => { + const harness = await testHarness(); + harness.threads.push(existingThread()); + const result = await sendThreadPrompt({ ...harness.config, model: "creation-default", runtimeMode: "full-access" }, { + threadId: "existing-thread", prompt: "Continue the previous discussion. $HOME `literal`", + }); + expect(harness.commands).toHaveLength(1); + expect(harness.commands[0]).toMatchObject({ + type: "thread.turn.start", threadId: "existing-thread", + runtimeMode: "approval-required", interactionMode: "plan", + message: { role: "user", text: "Continue the previous discussion. $HOME `literal`", attachments: [] }, + }); + for (const key of ["modelSelection", "bootstrap", "titleSeed"]) { + expect(harness.commands[0]).not.toHaveProperty(key); + } + expect(harness.threads).toEqual([existingThread()]); + expect(result.thread.id).toBe("existing-thread"); + expect(result.thread.dispatch).toEqual({ sequence: 1 }); + expect(result.opened.kind).toBe("none"); + expect(JSON.stringify(result)).not.toContain("mock-token"); + }); + + it("validates and previews without dispatching, including snapshot fallback", async () => { + const harness = await testHarness([], { shellUnavailable: true }); + harness.threads.push(existingThread({ session: { status: "stopped", activeTurnId: null } })); + const result = await sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Preview", dryRun: true, openMode: "browser", + }); + expect(harness.commands).toEqual([]); + expect(result.thread.command.message.text).toBe("Preview"); + expect(result.thread.dispatch).toBeNull(); + expect(result.opened.kind).toBe("none"); + }); + + it.each([ + [{ archivedAt: "2026-01-01T00:00:00Z" }, "THREAD_ARCHIVED"], + [{ deletedAt: "2026-01-01T00:00:00Z" }, "THREAD_NOT_FOUND"], + [{ session: { status: "starting" } }, "THREAD_BUSY"], + [{ session: { status: "running" } }, "THREAD_BUSY"], + [{ session: { status: "ready", activeTurnId: "active-turn" } }, "THREAD_BUSY"], + [{ latestTurn: { state: "pending" } }, "THREAD_BUSY"], + [{ latestTurn: { state: "running" } }, "THREAD_BUSY"], + ] satisfies Array<[Partial, string]>)("rejects unavailable thread state %j", async (overrides, code) => { + const harness = await testHarness(); + harness.threads.push(existingThread(overrides)); + await expect(sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Do work", + })).rejects.toMatchObject({ code }); + expect(harness.commands).toEqual([]); + }); + + it("rejects missing threads and incomplete settings without creating or changing anything", async () => { + const harness = await testHarness(); + await expect(sendThreadPrompt(harness.config, { + threadId: "missing", prompt: "Do work", + })).rejects.toMatchObject({ code: "THREAD_NOT_FOUND" }); + const thread = existingThread(); + delete thread.runtimeMode; + harness.threads.push(thread); + await expect(sendThreadPrompt(harness.config, { + threadId: thread.id, prompt: "Do work", + })).rejects.toMatchObject({ code: "T3_INVALID_SNAPSHOT" }); + expect(harness.commands).toEqual([]); + }); + + it("does not delete the existing thread when dispatch fails", async () => { + const harness = await testHarness([], { failTurn: true }); + harness.threads.push(existingThread()); + await expect(sendThreadPrompt(harness.config, { + threadId: "existing-thread", prompt: "Do work", + })).rejects.toMatchObject({ code: "T3_API_ERROR" }); + expect(harness.commands.map((command) => command.type)).toEqual(["thread.turn.start"]); + expect(harness.threads).toEqual([existingThread()]); + }); + + it.each([ + [" ", "hello", "THREAD_ID_REQUIRED"], + ["existing-thread", " \n ", "PROMPT_REQUIRED"], + ])("rejects invalid input before contacting T3", async (threadId, prompt, code) => { + await expect(sendThreadPrompt(DEFAULT_CONFIG, { threadId, prompt })).rejects.toMatchObject({ code }); + }); +}); diff --git a/src/service.ts b/src/service.ts index ec32493..c806408 100644 --- a/src/service.ts +++ b/src/service.ts @@ -47,6 +47,14 @@ export interface ThreadCreateOptions extends WorkspaceOptions { dryRun?: boolean; } +export interface ThreadSendOptions { + threadId: string; + prompt: string; + ifBusy?: "reject" | "inject"; + openMode?: OpenMode; + dryRun?: boolean; +} + interface EffectiveT3Settings { defaultThreadEnvMode: EffectiveThreadEnvMode; newWorktreesStartFromOrigin: boolean; @@ -508,6 +516,69 @@ export async function createHandoverThread(config: CliConfig, options: ThreadCre return { ...result, opened, dryRun: options.dryRun ?? false }; } +export async function sendThreadPrompt(config: CliConfig, options: ThreadSendOptions) { + const threadId = options.threadId.trim(); + if (!threadId) throw new CliError("THREAD_ID_REQUIRED", "A non-empty T3 thread ID is required."); + const prompt = options.prompt.trim(); + if (!prompt) throw new CliError("PROMPT_REQUIRED", "A non-empty prompt is required."); + const ifBusy = options.ifBusy ?? "reject"; + if (ifBusy !== "reject" && ifBusy !== "inject") { + throw new CliError("INVALID_THREAD_OPTION", "ifBusy must be reject or inject."); + } + + const runtime = await discoverRuntime(config, { startDesktopIfNeeded: false }); + const result = await withT3Api(runtime, config, async (api, invocation) => { + const shell = await api.shellSnapshot().catch(() => null); + const snapshot = shell && Array.isArray(shell.threads) ? shell : await api.snapshot(); + if (!Array.isArray(snapshot.threads)) { + throw new CliError("T3_INVALID_SNAPSHOT", "T3 returned a snapshot without threads."); + } + const thread = snapshot.threads.find((entry) => entry.id === threadId && entry.deletedAt == null); + if (!thread) throw new CliError("THREAD_NOT_FOUND", `No T3 Code thread exists with ID ${threadId}.`); + if (thread.archivedAt != null) { + throw new CliError("THREAD_ARCHIVED", "Unarchive the thread in T3 Code before sending a prompt.", { + details: { threadId }, + }); + } + const busy = + thread.session?.status === "starting" || thread.session?.status === "running" || + thread.session?.activeTurnId != null || + thread.latestTurn?.state === "pending" || thread.latestTurn?.state === "running"; + if (busy && ifBusy === "reject") { + throw new CliError("THREAD_BUSY", "The thread has an active or pending turn. Retry when idle or use --if-busy inject.", { + details: { threadId }, + }); + } + if ( + !["approval-required", "auto-accept-edits", "full-access"].includes(thread.runtimeMode ?? "") || + !["default", "plan"].includes(thread.interactionMode ?? "") + ) { + throw new CliError("T3_INVALID_SNAPSHOT", "T3 returned a thread without valid permission and interaction modes."); + } + // Omit modelSelection so T3 retains the thread's model and provider options. + // Creation defaults must not change an existing conversation's permissions. + const command = { + type: "thread.turn.start", + commandId: randomUUID(), + threadId, + message: { messageId: randomUUID(), role: "user", text: prompt, attachments: [] }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt: new Date().toISOString(), + }; + const dispatch = options.dryRun ? null : await api.dispatch(command); + return { + runtime, + auth: { source: invocation.source, version: invocation.version }, + thread: { id: threadId, title: thread.title, command, dispatch }, + }; + }); + const opened = options.dryRun + ? { mode: options.openMode ?? config.openMode, kind: "none" as const, url: null, exactThread: false } + : await openThread(options.openMode ?? config.openMode, runtime, threadId); + return { ...result, opened, dryRun: options.dryRun ?? false }; +} + export async function rawGet(config: CliConfig, requestPath: string) { if (!requestPath.startsWith("/") || requestPath.startsWith("//")) { throw new CliError("INVALID_REQUEST_PATH", "Request path must start with one slash."); diff --git a/src/types.ts b/src/types.ts index da96803..2a01431 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,11 @@ export interface T3Thread { projectId: string; title: string; archivedAt: string | null; + deletedAt?: string | null; + runtimeMode?: RuntimeMode; + interactionMode?: InteractionMode; + session?: { status: string; activeTurnId?: string | null } | null; + latestTurn?: { state: string } | null; [key: string]: unknown; }