diff --git a/README.md b/README.md index 722d1fa..4fa55cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @bvdm/t3code-cli -`t3code` hands the current folder or Git repository to a new thread in [T3 Code](https://github.com/pingdotgg/t3code). +`t3code` hands the current folder or Git repository to a new thread in [T3 Code](https://github.com/pingdotgg/t3code), and lets automation discover, inspect, and message existing threads. It does not fake a handover by copying text or opening a generic app URL. It connects to the running local T3 server, resolves the workspace against T3 projects, optionally creates the missing project, creates a fresh thread, and starts its first prompt through T3's orchestration API. @@ -97,6 +97,47 @@ 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. +## Existing threads + +List threads across projects, or restrict discovery by project id or workspace: + +```bash +t3code threads list +t3code threads list --status active --cwd . +t3code threads list --status settled --project +``` + +`--status` accepts `active`, `settled`, or `all` (the default). Results include the exact thread id, project, title, model, and update time. Inspect the exact target before sending: + +```bash +t3code threads inspect --thread +``` + +Start a new turn on that thread with one of `--prompt`, `--prompt-file`, or `--stdin`: + +```bash +printf '%s' "Review findings from the other thread..." \ + | t3code threads send --thread --stdin +``` + +Sending to a settled thread requires confirmation. Non-interactive and JSON callers must explicitly opt in with `--wake-settled`: + +```bash +printf '%s' "New findings that require more work..." \ + | t3code --json threads send --thread --stdin --wake-settled +``` + +The send command does not report success from the HTTP response alone. It waits until the exact message is visible in T3's thread projection. Archived threads are rejected. + +Manage settlement explicitly without starting a new turn: + +```bash +t3code threads settle --thread +t3code threads unsettle --thread +``` + +`settle` refuses a thread with a running/starting session or a pending approval or user-input request. `unsettle` marks the thread manually active but does not send a message or start its provider session. Both commands require the server to advertise the `threadSettlement` capability and wait for the requested lifecycle state to appear in T3's projection before succeeding. + ## Settings ```bash @@ -136,12 +177,17 @@ t3code config path|show|set t3code projects list t3code projects resolve --cwd . t3code projects ensure --cwd . --project-policy create +t3code threads list --status active --cwd . +t3code threads inspect --thread +t3code threads send --thread --stdin +t3code threads settle --thread +t3code threads unsettle --thread t3code threads create --stdin t3code handover --stdin t3code request get /api/orchestration/snapshot ``` -Every command supports human-readable output. `--json` produces `{ "ok": true, "data": ... }` on success and a stable error envelope on failure. +Every command supports human-readable output. `--json` produces `{ "ok": true, "data": ... }` on success and a stable error envelope on failure. Thread targeting uses exit code `3` for a missing target, `4` for a lifecycle/confirmation refusal, and `5` when dispatch returned but turn acceptance could not be verified. ## Origin and optional UI example diff --git a/package.json b/package.json index 01edf4a..a62ec6a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "@bvdm/t3code-cli", "version": "0.1.2", - "description": "Open a folder as a T3 Code project and start a new handover thread.", + "description": "Manage T3 Code projects, handover threads, and cross-thread messages.", "license": "MIT", "keywords": [ "t3-code", "cli", "handover", - "codex" + "codex", + "threads", + "agents" ], "repository": { "type": "git", diff --git a/skills/use-t3code-cli/SKILL.md b/skills/use-t3code-cli/SKILL.md index e0f7f24..afdb87c 100644 --- a/skills/use-t3code-cli/SKILL.md +++ b/skills/use-t3code-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: use-t3code-cli -description: Operate the t3code CLI to resolve folders or Git repositories into T3 Code projects, create missing projects according to policy, start new handover threads with prompts, inspect project state, and diagnose the local T3 connection. Use when an agent needs to hand current work to T3 Code or automate T3 project/thread creation from a terminal or application. +description: Operate the t3code CLI to resolve folders or Git repositories into T3 Code projects, create missing projects according to policy, start new handover threads with prompts, inspect project state, and diagnose the local T3 connection. Use when an agent needs to hand current work to T3 Code or automate T3 project/thread creation from a terminal or application. Also use when an agent needs to discover, inspect, message, settle, or unsettle an existing T3 Code thread. --- # Use T3 Code CLI @@ -57,6 +57,35 @@ Use `--project-policy existing` when creating a project is not authorized. The d Use `--dry-run --open none` to inspect the proposed project and thread commands without changing T3 state. +## Work with existing threads + +Discover candidate threads in the relevant project, then inspect the exact target id before changing it: + +```bash +t3code --json threads list --cwd . --status all +t3code --json threads inspect --thread "$TARGET_THREAD_ID" +``` + +Use `--project ` instead of `--cwd` when the caller provides an exact project id. Filter with `--status active` or `--status settled` when useful. Do not select a target from its title alone because titles are not unique. + +Pass messages over stdin: + +```bash +printf '%s' "$THREAD_MESSAGE" \ + | t3code --json threads send --thread "$TARGET_THREAD_ID" --stdin +``` + +Sending is an external state change. Keep the target and message within the caller's authorization. A settled thread requires interactive confirmation or `--wake-settled`; JSON and stdin workflows are non-interactive, so use that override only when waking the inspected target is authorized. Archived threads cannot receive a turn. + +Manage lifecycle state without sending a message: + +```bash +t3code --json threads settle --thread "$TARGET_THREAD_ID" +t3code --json threads unsettle --thread "$TARGET_THREAD_ID" +``` + +Settle only after the caller authorizes that lifecycle change. T3 refuses settlement while a session is starting/running or the thread has a blocking approval or user-input request. Unsettling marks the thread manually active; it does not start a turn or provider session. + ## Optional front-end integration The CLI can be called from a trusted application backend to power a **Send to T3 Code** button. This pattern was initially built for the [Delano viewer](https://github.com/MajesteitBart/delano). The optional `integrations/` example in this repository includes a React split button and Node bridge; it is not required to install or operate the CLI. @@ -67,10 +96,16 @@ Keep the repository root server-owned, pass CLI options as process arguments, an Read `data.project.id`, `data.thread.id`, `data.projectCreated`, and `data.opened`. A successful current stable desktop reveal can report `opened.exactThread: false`; the thread is still created in the resolved project. +For existing-thread writes, require `data.verification.accepted: true`. Record `data.thread.id` and, for sends, `data.message.messageId` when reporting the result. The CLI verifies the requested projection state rather than treating HTTP submission as success. + On `{ "ok": false }`, report `error.code` and `error.message`. Do not retry write commands blindly. `THREAD_START_FAILED` already attempts to delete the newly-created thread. +`THREAD_TURN_NOT_VERIFIED` or `THREAD_SETTLEMENT_NOT_VERIFIED` means dispatch returned but projection verification timed out. Do not retry automatically because the first operation may still appear later. + ## Current compatibility boundary T3 0.0.28 and later support new-worktree handovers through the atomic bootstrap contract. Worktree creation follows the current installation's explicit `newWorktreesStartFromOrigin` setting. When it is absent, use the installed version's default: `false` on 0.0.28 and `true` on 0.0.29 and later. `WORKTREE_REQUIRES_BRANCH` means the selected folder is not a Git repository on a branch; retry with `--checkout current` only with explicit user or caller authority. +Thread settlement commands require a T3 server that exposes the `threadSettlement` capability. Existing-thread sends preserve the target's saved model, runtime mode, and interaction mode. + Use `t3code --json request get ` only as a read-only escape hatch. diff --git a/skills/use-t3code-cli/agents/openai.yaml b/skills/use-t3code-cli/agents/openai.yaml index 3189869..65ba973 100644 --- a/skills/use-t3code-cli/agents/openai.yaml +++ b/skills/use-t3code-cli/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "T3 Code CLI" - short_description: "Create T3 project handover threads from any repo" - default_prompt: "Use $use-t3code-cli to hand this repository over to a new T3 Code thread." + short_description: "Create handovers and manage T3 Code threads" + default_prompt: "Use $use-t3code-cli to hand this repository over to a new T3 Code thread or manage an existing T3 Code thread." diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..c416f91 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,120 @@ +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +interface CliResult { + stdout: string; + stderr: string; + exitCode: number | string | undefined; +} + +async function runCli(args: string[]): Promise { + const originalArgv = process.argv; + const originalExitCode = process.exitCode; + let stdout = ""; + let stderr = ""; + + process.argv = [process.execPath, "t3code", ...args]; + process.exitCode = undefined; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += chunk.toString(); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += chunk.toString(); + return true; + }); + + vi.resetModules(); + try { + await import("./cli.js"); + return { stdout, stderr, exitCode: process.exitCode }; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe.sequential("CLI parsing", () => { + it("writes a JSON usage envelope for a missing required option", async () => { + const result = await runCli(["--json", "threads", "inspect"]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + error: { + code: "INVALID_USAGE", + message: "required option '--thread ' not specified", + }, + }); + }); + + it("writes a JSON usage envelope for an invalid choice", async () => { + const result = await runCli(["--json", "threads", "list", "--status", "archived"]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + error: { + code: "INVALID_USAGE", + message: "option '--status ' argument 'archived' is invalid. Allowed choices are active, settled, all.", + }, + }); + }); + + it("writes a JSON usage envelope for an unknown option", async () => { + const result = await runCli(["--json", "threads", "inspect", "--thread", "thread-1", "--bogus"]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + error: { + code: "INVALID_USAGE", + message: "unknown option '--bogus'", + }, + }); + }); + + it("keeps human-readable usage errors", async () => { + const result = await runCli(["threads", "inspect"]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe("t3code: required option '--thread ' not specified\n"); + }); + + it("keeps help and version successful", async () => { + const help = await runCli(["--help"]); + const version = await runCli(["--version"]); + + expect(help.exitCode).toBe(0); + expect(help.stderr).toBe(""); + expect(help.stdout).toContain("Usage: t3code [options] [command]"); + expect(version).toEqual({ stdout: "0.1.2\n", stderr: "", exitCode: 0 }); + }); + + it("leaves action-level JSON errors unchanged", async () => { + const missingConfig = path.join(os.tmpdir(), "t3code-cli-cli-test-missing.json"); + const result = await runCli(["--json", "--config", missingConfig, "handover"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + error: { + code: "PROMPT_SOURCE_REQUIRED", + message: "Use exactly one of --prompt, --prompt-file, or --stdin.", + }, + }); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index d0ac224..4a1ee02 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,11 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; import path from "node:path"; -import { stdin as input } from "node:process"; +import { stdin as input, stderr as errorOutput } from "node:process"; +import { createInterface } from "node:readline/promises"; -import { Command, Option } from "commander"; +import { Command, CommanderError, Option } from "commander"; +import packageMetadata from "../package.json" with { type: "json" }; import { CONFIG_KEYS, @@ -19,10 +21,16 @@ import { writeError, writeSuccess } from "./output.js"; import { createHandoverThread, ensureProject, + inspectThread, listProjects, + listThreads, rawGet, resolveProject, + sendThreadMessage, + settleThread, type ThreadCreateOptions, + type ThreadListStatus, + unsettleThread, } from "./service.js"; import type { CliConfig, @@ -32,18 +40,22 @@ import type { RuntimeMode, SpeedMode, ThreadEnvMode, + T3Project, + T3Thread, WorkspaceMode, } from "./types.js"; const program = new Command(); +const jsonRequested = process.argv.slice(2).includes("--json"); program .name("t3code") - .description("Create T3 Code projects and handover threads from the current folder.") - .version("0.1.0") + .description("Manage T3 Code projects, handover threads, and cross-thread messages.") + .version(packageMetadata.version) .option("--json", "Emit stable JSON envelopes.") .option("--config ", "Use a specific config file.") .option("--t3-home ", "Override T3CODE_HOME for this command.") .option("--origin ", "Override the running T3 server origin."); +program.configureOutput({ outputError: () => undefined }).exitOverride(); interface GlobalOptions { json?: boolean; @@ -140,6 +152,22 @@ interface ThreadCommandOptions extends WorkspaceCommandOptions { thinkingEffort?: string; } +interface PromptOptions { + prompt?: string; + promptFile?: string; + stdin?: boolean; +} + +interface ThreadListCommandOptions extends WorkspaceCommandOptions { + project?: string; + status?: ThreadListStatus; +} + +interface ThreadSendCommandOptions extends PromptOptions { + thread: string; + wakeSettled?: boolean; +} + async function readStdin(): Promise { input.setEncoding("utf8"); let value = ""; @@ -147,7 +175,7 @@ async function readStdin(): Promise { return value; } -async function resolvePrompt(options: ThreadCommandOptions): Promise { +async function resolvePrompt(options: PromptOptions): Promise { const sources = [options.prompt !== undefined, options.promptFile !== undefined, options.stdin === true].filter(Boolean); if (sources.length !== 1) { throw new CliError("PROMPT_SOURCE_REQUIRED", "Use exactly one of --prompt, --prompt-file, or --stdin."); @@ -157,6 +185,26 @@ async function resolvePrompt(options: ThreadCommandOptions): Promise { return await readStdin(); } +async function confirmSettledThread(thread: T3Thread, project: T3Project | null): Promise { + if (!input.isTTY || !errorOutput.isTTY) { + throw new CliError( + "SETTLED_THREAD_CONFIRMATION_REQUIRED", + `Thread ${thread.id} is settled. Re-run with --wake-settled to send and wake it.`, + { exitCode: 4, details: { threadId: thread.id, settledAt: thread.settledAt } }, + ); + } + const readline = createInterface({ input, output: errorOutput }); + try { + const projectLabel = project ? ` in ${project.title}` : ""; + const answer = await readline.question( + `Thread “${thread.title}”${projectLabel} is settled. Send this message and wake it? [y/N] `, + ); + return /^(?:y|yes)$/iu.test(answer.trim()); + } finally { + readline.close(); + } +} + function threadCreateOptions(options: ThreadCommandOptions, prompt: string): ThreadCreateOptions { return { prompt, @@ -254,7 +302,121 @@ addProjectPolicyOption(addWorkspaceOptions(projects.command("ensure"))) }), ); -const threads = program.command("threads").description("Create T3 Code threads."); +const threads = program.command("threads").description("Create, inspect, and message T3 Code threads."); +threads.command("list") + .description("List active and settled threads.") + .option("--cwd ", "Filter by the T3 project resolved from this folder.") + .addOption(new Option("--workspace-mode ").choices(["repo", "folder"])) + .option("--project ", "Filter by an exact T3 project id.") + .addOption( + new Option("--status ", "Filter by thread lifecycle status.") + .choices(["active", "settled", "all"]) + .default("all"), + ) + .action((options: ThreadListCommandOptions) => + action(async () => { + const context = await commandContext(); + const result = await listThreads(context.config, options); + const projectById = new Map(result.projects.map((project) => [project.id, project])); + const lines = result.threads.map((thread) => { + const project = projectById.get(thread.projectId); + return [ + thread.status, + thread.id, + project?.title ?? thread.projectId, + thread.title, + thread.modelSelection?.model ?? "unknown-model", + thread.updatedAt ?? "unknown-time", + ].join("\t"); + }); + writeSuccess(result, context, lines.length > 0 ? lines.join("\n") : "No matching threads."); + }), + ); + +threads + .command("inspect") + .description("Inspect a thread before targeting it.") + .requiredOption("--thread ", "Exact T3 thread id.") + .action((options: { thread: string }) => + action(async () => { + const context = await commandContext(); + const result = await inspectThread(context.config, options.thread); + const latestTurn = result.thread.latestTurn; + writeSuccess( + result, + context, + [ + `Thread: ${result.thread.id}`, + `Title: ${result.thread.title}`, + `Project: ${result.project?.title ?? result.thread.projectId}`, + `Status: ${result.thread.status}`, + `Model: ${result.thread.modelSelection?.instanceId ?? "unknown"}/${result.thread.modelSelection?.model ?? "unknown"}`, + `Session: ${result.thread.session?.status ?? "none"}`, + `Latest turn: ${latestTurn ? `${latestTurn.state} (${latestTurn.turnId})` : "none"}`, + `Updated: ${result.thread.updatedAt ?? "unknown"}`, + ].join("\n"), + ); + }), + ); + +threads + .command("send") + .description("Start a new turn on an existing thread.") + .requiredOption("--thread ", "Exact T3 thread id.") + .option("--prompt ", "Message text.") + .option("--prompt-file ", "Read the message from a UTF-8 file.") + .option("--stdin", "Read the message from stdin.") + .option("--wake-settled", "Explicitly allow this message to wake a settled thread.") + .action((options: ThreadSendCommandOptions) => + action(async () => { + const context = await commandContext(); + const prompt = await resolvePrompt(options); + const result = await sendThreadMessage(context.config, { + threadId: options.thread, + prompt, + ...(options.wakeSettled ? { wakeSettled: true } : {}), + ...(!context.json && !options.stdin ? { confirmSettled: confirmSettledThread } : {}), + }); + writeSuccess( + result, + context, + `Sent message ${result.message.messageId} to thread ${result.thread.id}; T3 accepted and projected the turn.`, + ); + }), + ); + +threads + .command("settle") + .description("Mark a thread as settled after verifying it can be settled.") + .requiredOption("--thread ", "Exact T3 thread id.") + .action((options: { thread: string }) => + action(async () => { + const context = await commandContext(); + const result = await settleThread(context.config, options.thread); + writeSuccess( + result, + context, + `Settled thread ${result.thread.id}; T3 projected the lifecycle change.`, + ); + }), + ); + +threads + .command("unsettle") + .description("Mark a settled thread as active without starting a turn.") + .requiredOption("--thread ", "Exact T3 thread id.") + .action((options: { thread: string }) => + action(async () => { + const context = await commandContext(); + const result = await unsettleThread(context.config, options.thread); + writeSuccess( + result, + context, + `Marked thread ${result.thread.id} active; T3 projected the lifecycle change.`, + ); + }), + ); + addThreadOptions(threads.command("create")) .description("Create a new project thread and start its first turn.") .action((options: ThreadCommandOptions) => @@ -298,5 +460,18 @@ program }), ); -await program.parseAsync(process.argv); -process.exit(process.exitCode ?? 0); +try { + await program.parseAsync(process.argv); +} catch (error) { + if (!(error instanceof CommanderError)) throw error; + if (error.exitCode === 0) { + process.exitCode = 0; + } else { + const message = error.message.replace(/^error:\s*/u, ""); + const cliError = writeError(new CliError("INVALID_USAGE", message, { exitCode: 2 }), { + json: jsonRequested, + }); + process.exitCode = cliError.exitCode; + } +} +process.exitCode ??= 0; diff --git a/src/runtime.ts b/src/runtime.ts index 18e453f..997c37e 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -10,6 +10,10 @@ import type { CliConfig, RuntimeState, T3Runtime } from "./types.js"; interface EnvironmentDescriptor { environmentId: string; serverVersion: string; + capabilities: { + threadSettlement?: boolean; + [key: string]: unknown; + }; } export function resolveT3Home(config: CliConfig): string { @@ -43,7 +47,13 @@ async function fetchDescriptor(origin: string): Promise; if (typeof value.environmentId !== "string" || typeof value.serverVersion !== "string") return null; - return value as EnvironmentDescriptor; + const capabilities = + value.capabilities !== null && + typeof value.capabilities === "object" && + !Array.isArray(value.capabilities) + ? value.capabilities + : {}; + return { environmentId: value.environmentId, serverVersion: value.serverVersion, capabilities }; } catch { return null; } diff --git a/src/service.ts b/src/service.ts index ec32493..131a04d 100644 --- a/src/service.ts +++ b/src/service.ts @@ -7,6 +7,7 @@ import { CliError } from "./errors.js"; import { readLocalProjects } from "./localProjects.js"; import { openThread } from "./open.js"; import { discoverRuntime } from "./runtime.js"; +import { T3ThreadApi, type ThreadSettlementState } from "./threadApi.js"; import type { CliConfig, EffectiveThreadEnvMode, @@ -18,6 +19,7 @@ import type { RuntimeMode, SpeedMode, T3Project, + T3Thread, ThreadEnvMode, WorkspaceMode, } from "./types.js"; @@ -27,6 +29,8 @@ const LEGACY_DEFAULT_MODEL_SELECTION: ModelSelection = { instanceId: "codex", mo const CURRENT_DEFAULT_MODEL_SELECTION: ModelSelection = { instanceId: "codex", model: "gpt-5.6-sol" }; const MINIMUM_WORKTREE_BOOTSTRAP_VERSION = "0.0.28"; const MODERN_DEFAULTS_VERSION = "0.0.29"; +const INSPECT_RECENT_MESSAGE_LIMIT = 6; +const INSPECT_MESSAGE_TEXT_LIMIT = 2_000; export interface WorkspaceOptions { cwd?: string; @@ -47,6 +51,20 @@ export interface ThreadCreateOptions extends WorkspaceOptions { dryRun?: boolean; } +export type ThreadListStatus = "active" | "settled" | "all"; + +export interface ThreadListOptions extends WorkspaceOptions { + project?: string; + status?: ThreadListStatus; +} + +export interface ThreadSendOptions { + threadId: string; + prompt: string; + wakeSettled?: boolean; + confirmSettled?: (thread: T3Thread, project: T3Project | null) => Promise; +} + interface EffectiveT3Settings { defaultThreadEnvMode: EffectiveThreadEnvMode; newWorktreesStartFromOrigin: boolean; @@ -123,6 +141,48 @@ function activeProjects(projects: readonly T3Project[]): T3Project[] { return projects.filter((project) => project.deletedAt == null); } +function nonArchivedThread(thread: T3Thread): boolean { + return thread.archivedAt == null && thread.deletedAt == null; +} + +function threadStatus(thread: T3Thread): Exclude { + return thread.settledAt == null ? "active" : "settled"; +} + +function requireThreadId(value: string): string { + const threadId = value.trim(); + if (!threadId) { + throw new CliError("THREAD_ID_REQUIRED", "A non-empty thread id is required.", { exitCode: 2 }); + } + return threadId; +} + +function threadInspectionView(thread: T3Thread) { + const messages = thread.messages ?? []; + const summary = { ...thread }; + delete summary.messages; + delete summary.activities; + delete summary.checkpoints; + delete summary.proposedPlans; + return { + ...summary, + status: threadStatus(thread), + messageCount: messages.length, + recentMessages: messages.slice(-INSPECT_RECENT_MESSAGE_LIMIT).map((message) => ({ + id: message.id, + role: message.role, + turnId: message.turnId, + text: + message.text.length <= INSPECT_MESSAGE_TEXT_LIMIT + ? message.text + : `${message.text.slice(0, INSPECT_MESSAGE_TEXT_LIMIT - 1)}…`, + textTruncated: message.text.length > INSPECT_MESSAGE_TEXT_LIMIT, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + })), + }; +} + function projectForWorkspace(projects: readonly T3Project[], workspaceRoot: string): T3Project | null { return activeProjects(projects).find((project) => pathsEqual(project.workspaceRoot, workspaceRoot)) ?? null; } @@ -345,6 +405,227 @@ export async function ensureProject(config: CliConfig, options: WorkspaceOptions })); } +export async function listThreads(config: CliConfig, options: ThreadListOptions = {}) { + const requestedProjectId = options.project?.trim(); + if (options.project !== undefined && !requestedProjectId) { + throw new CliError("PROJECT_ID_REQUIRED", "--project requires a non-empty project id.", { + exitCode: 2, + }); + } + if (requestedProjectId && options.cwd) { + throw new CliError("THREAD_FILTER_CONFLICT", "Use either --project or --cwd, not both.", { + exitCode: 2, + }); + } + const runtime = await discoverRuntime(config, { startDesktopIfNeeded: false }); + return await withT3Api(runtime, config, async (api, invocation) => { + const catalog = await new T3ThreadApi(api).catalog(); + const projects = activeProjects(catalog.projects); + let project: T3Project | null = null; + let workspace = null; + + if (requestedProjectId) { + project = projects.find((candidate) => candidate.id === requestedProjectId) ?? null; + } else if (options.cwd) { + workspace = await resolveWorkspace(options.cwd, options.workspaceMode ?? config.workspaceMode); + project = projectForWorkspace(projects, workspace.workspaceRoot); + } + + if ((requestedProjectId || options.cwd) && !project) { + throw new CliError( + "PROJECT_NOT_FOUND", + requestedProjectId + ? `No active T3 Code project exists with id ${requestedProjectId}.` + : `No T3 Code project exists for ${workspace!.workspaceRoot}.`, + { exitCode: 3 }, + ); + } + + const requestedStatus = options.status ?? "all"; + const threads = catalog.threads + .filter(nonArchivedThread) + .filter((thread) => project === null || thread.projectId === project.id) + .filter((thread) => requestedStatus === "all" || threadStatus(thread) === requestedStatus) + .sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "")) + .map((thread) => ({ ...thread, status: threadStatus(thread) })); + + return { + runtime, + auth: { source: invocation.source, version: invocation.version }, + snapshotSequence: catalog.snapshotSequence, + filter: { + status: requestedStatus, + projectId: project?.id ?? null, + workspaceRoot: workspace?.workspaceRoot ?? null, + }, + projects, + threads, + }; + }); +} + +export async function inspectThread(config: CliConfig, rawThreadId: string) { + const threadId = requireThreadId(rawThreadId); + const runtime = await discoverRuntime(config, { startDesktopIfNeeded: false }); + return await withT3Api(runtime, config, async (api, invocation) => { + const inspected = await new T3ThreadApi(api).inspect(threadId); + const snapshot = await api.shellSnapshot().catch(() => api.snapshot().catch(() => null)); + const projects = snapshot && Array.isArray(snapshot.projects) ? snapshot.projects : []; + const project = projects.find((candidate) => candidate.id === inspected.thread.projectId) ?? null; + return { + runtime, + auth: { source: invocation.source, version: invocation.version }, + snapshotSequence: inspected.snapshotSequence, + project, + thread: threadInspectionView(inspected.thread), + }; + }); +} + +export async function sendThreadMessage(config: CliConfig, options: ThreadSendOptions) { + const threadId = requireThreadId(options.threadId); + const prompt = options.prompt.trim(); + if (!prompt) { + throw new CliError("PROMPT_REQUIRED", "A non-empty thread message is required.", { exitCode: 2 }); + } + + const runtime = await discoverRuntime(config, { startDesktopIfNeeded: true }); + return await withT3Api(runtime, config, async (api, invocation) => { + const adapter = new T3ThreadApi(api); + const inspected = await adapter.inspect(threadId); + const thread = inspected.thread; + if (thread.archivedAt != null) { + throw new CliError("THREAD_ARCHIVED", `Thread ${threadId} is archived and cannot receive a new turn.`, { + exitCode: 4, + details: { threadId, archivedAt: thread.archivedAt }, + }); + } + + const snapshot = await api.shellSnapshot().catch(() => api.snapshot().catch(() => null)); + const projects = snapshot && Array.isArray(snapshot.projects) ? snapshot.projects : []; + const project = projects.find((candidate) => candidate.id === thread.projectId) ?? null; + if (threadStatus(thread) === "settled" && !options.wakeSettled) { + if (!options.confirmSettled) { + throw new CliError( + "SETTLED_THREAD_CONFIRMATION_REQUIRED", + `Thread ${threadId} is settled. Re-run with --wake-settled to send and wake it.`, + { exitCode: 4, details: { threadId, settledAt: thread.settledAt } }, + ); + } + if (!(await options.confirmSettled(thread, project))) { + throw new CliError("SETTLED_THREAD_DECLINED", `Did not send a message to settled thread ${threadId}.`, { + exitCode: 4, + details: { threadId }, + }); + } + } + + const command = adapter.buildTurnStart(thread, prompt); + const sent = await adapter.dispatchTurn(command); + return { + runtime, + auth: { source: invocation.source, version: invocation.version }, + project, + thread: { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + statusBeforeSend: threadStatus(thread), + }, + message: { + messageId: command.message.messageId, + textLength: command.message.text.length, + }, + command: { + type: command.type, + commandId: command.commandId, + threadId: command.threadId, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + createdAt: command.createdAt, + }, + dispatch: sent.dispatch, + verification: sent.verification, + }; + }); +} + +async function changeThreadSettlement( + config: CliConfig, + rawThreadId: string, + state: ThreadSettlementState, +) { + const threadId = requireThreadId(rawThreadId); + const runtime = await discoverRuntime(config, { startDesktopIfNeeded: true }); + if (runtime.capabilities.threadSettlement !== true) { + throw new CliError( + "THREAD_SETTLEMENT_UNSUPPORTED", + "This T3 Code server does not advertise thread settlement support.", + { + exitCode: 4, + details: { capability: "threadSettlement", serverVersion: runtime.serverVersion }, + }, + ); + } + return await withT3Api(runtime, config, async (api, invocation) => { + const adapter = new T3ThreadApi(api); + const inspected = await adapter.inspect(threadId); + const thread = inspected.thread; + if (thread.archivedAt != null) { + throw new CliError("THREAD_ARCHIVED", `Thread ${threadId} is archived and cannot change settlement state.`, { + exitCode: 4, + details: { threadId, archivedAt: thread.archivedAt }, + }); + } + if ( + state === "settled" && + (thread.session?.status === "starting" || + thread.session?.status === "running" || + thread.hasPendingApprovals === true || + thread.hasPendingUserInput === true) + ) { + throw new CliError("THREAD_SETTLE_BLOCKED", `Thread ${threadId} still has active or blocked work.`, { + exitCode: 4, + details: { + threadId, + sessionStatus: thread.session?.status ?? null, + hasPendingApprovals: thread.hasPendingApprovals ?? false, + hasPendingUserInput: thread.hasPendingUserInput ?? false, + }, + }); + } + + const snapshot = await api.shellSnapshot().catch(() => api.snapshot().catch(() => null)); + const projects = snapshot && Array.isArray(snapshot.projects) ? snapshot.projects : []; + const project = projects.find((candidate) => candidate.id === thread.projectId) ?? null; + const command = adapter.buildSettlement(threadId, state); + const changed = await adapter.dispatchSettlement(command, thread.updatedAt); + return { + runtime, + auth: { source: invocation.source, version: invocation.version }, + project, + thread: { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + statusBefore: threadStatus(thread), + statusAfter: threadStatus(changed.thread), + }, + command, + dispatch: changed.dispatch, + verification: changed.verification, + }; + }); +} + +export async function settleThread(config: CliConfig, threadId: string) { + return await changeThreadSettlement(config, threadId, "settled"); +} + +export async function unsettleThread(config: CliConfig, threadId: string) { + return await changeThreadSettlement(config, threadId, "active"); +} + export async function createHandoverThread(config: CliConfig, options: ThreadCreateOptions) { const prompt = options.prompt.trim(); if (!prompt) throw new CliError("PROMPT_REQUIRED", "A non-empty handover prompt is required."); diff --git a/src/threadApi.test.ts b/src/threadApi.test.ts new file mode 100644 index 0000000..3390b2b --- /dev/null +++ b/src/threadApi.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; + +import type { T3Api } from "./api.js"; +import { CliError } from "./errors.js"; +import { T3ThreadApi } from "./threadApi.js"; +import type { OrchestrationSnapshot, T3Thread, ThreadDetailSnapshot } from "./types.js"; + +function thread(overrides: Partial = {}): T3Thread { + return { + id: "thread-1", + projectId: "project-1", + title: "Implementation", + modelSelection: { instanceId: "codex", model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-09-04T10:00:00.000Z", + updatedAt: "2026-09-04T10:00:00.000Z", + archivedAt: null, + settledAt: null, + messages: [], + deletedAt: null, + ...overrides, + }; +} + +function snapshot(threads: T3Thread[], snapshotSequence = 1): OrchestrationSnapshot { + return { + snapshotSequence, + projects: [{ + id: "project-1", + title: "Project", + workspaceRoot: "/project", + defaultModelSelection: { instanceId: "codex", model: "gpt-5.6-sol" }, + deletedAt: null, + }], + threads, + updatedAt: "2026-09-04T10:00:00.000Z", + }; +} + +function mockApi(overrides: Partial = {}): T3Api { + return { + shellSnapshot: async () => snapshot([thread()]), + snapshot: async () => snapshot([thread()]), + request: async () => ({ snapshotSequence: 1, thread: thread() } satisfies ThreadDetailSnapshot), + dispatch: async () => ({ sequence: 2 }), + ...overrides, + } as unknown as T3Api; +} + +describe("T3ThreadApi", () => { + it("builds the exact existing-thread turn payload without creation fields", () => { + const adapter = new T3ThreadApi(mockApi()); + const command = adapter.buildTurnStart(thread(), "Review findings"); + + expect(command).toMatchObject({ + type: "thread.turn.start", + threadId: "thread-1", + message: { role: "user", text: "Review findings", attachments: [] }, + runtimeMode: "full-access", + interactionMode: "default", + }); + expect(command).not.toHaveProperty("bootstrap"); + expect(command).not.toHaveProperty("titleSeed"); + expect(command).not.toHaveProperty("modelSelection"); + }); + + it("preserves a saved auto runtime mode", () => { + const adapter = new T3ThreadApi(mockApi()); + + const command = adapter.buildTurnStart(thread({ runtimeMode: "auto" }), "Review findings"); + + expect(command.runtimeMode).toBe("auto"); + }); + + it("builds the installed settlement command payloads", () => { + const adapter = new T3ThreadApi(mockApi()); + + expect(adapter.buildSettlement("thread-1", "settled")).toMatchObject({ + type: "thread.settle", + threadId: "thread-1", + }); + expect(adapter.buildSettlement("thread-1", "active")).toMatchObject({ + type: "thread.unsettle", + threadId: "thread-1", + reason: "user", + }); + }); + + it("verifies acceptance by the exact projected message id", async () => { + let projected: T3Thread = thread(); + const api = mockApi({ + dispatch: async (value: unknown) => { + const command = value as ReturnType; + projected = thread({ + updatedAt: command.createdAt, + messages: [{ + id: command.message.messageId, + role: "user", + text: command.message.text, + turnId: null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }], + }); + return { sequence: 2 }; + }, + request: async () => ({ snapshotSequence: 2, thread: projected }), + }); + const adapter = new T3ThreadApi(api); + const command = adapter.buildTurnStart(thread(), "Review findings"); + + const result = await adapter.dispatchTurn(command); + + expect(result.verification).toEqual({ + accepted: true, + method: "message-id", + snapshotSequence: 2, + messageId: command.message.messageId, + }); + }); + + it("does not report success when dispatch returns before the target projection changes", async () => { + const adapter = new T3ThreadApi(mockApi({ + request: async () => ({ snapshotSequence: 2, thread: thread() }), + }), { + verificationTimeoutMs: 1, + verificationIntervalMs: 0, + }); + const command = adapter.buildTurnStart(thread(), "Review findings"); + + await expect(adapter.dispatchTurn(command)).rejects.toMatchObject({ + code: "THREAD_TURN_NOT_VERIFIED", + exitCode: 5, + details: { + threadId: "thread-1", + messageId: command.message.messageId, + dispatchSequence: 2, + }, + } satisfies Partial); + }); + + it("does not accept a projection watermark without the exact message id", async () => { + let projected = thread(); + const adapter = new T3ThreadApi(mockApi({ + dispatch: async (value: unknown) => { + const command = value as ReturnType; + projected = thread({ + updatedAt: command.createdAt, + latestUserMessageAt: command.createdAt, + messages: [], + }); + return { sequence: 2 }; + }, + request: async () => ({ snapshotSequence: 2, thread: projected }), + }), { + verificationTimeoutMs: 1, + verificationIntervalMs: 0, + }); + const command = adapter.buildTurnStart(thread(), "Review findings"); + + await expect(adapter.dispatchTurn(command)).rejects.toMatchObject({ + code: "THREAD_TURN_NOT_VERIFIED", + exitCode: 5, + details: { + threadId: "thread-1", + messageId: command.message.messageId, + dispatchSequence: 2, + }, + } satisfies Partial); + }); + + it("verifies settlement against the projected lifecycle state", async () => { + let projected = thread(); + const adapter = new T3ThreadApi(mockApi({ + dispatch: async () => { + projected = thread({ + settledOverride: "settled", + settledAt: "2026-09-04T12:00:00.000Z", + updatedAt: "2026-09-04T12:00:00.000Z", + }); + return { sequence: 2 }; + }, + request: async () => ({ snapshotSequence: 2, thread: projected }), + })); + + const result = await adapter.dispatchSettlement( + adapter.buildSettlement("thread-1", "settled"), + "2026-09-04T10:00:00.000Z", + ); + + expect(result.verification).toEqual({ + accepted: true, + state: "settled", + snapshotSequence: 2, + settledAt: "2026-09-04T12:00:00.000Z", + unsettledAt: null, + }); + }); + + it("falls back to the full snapshot when the detail endpoint is unavailable", async () => { + const expected = thread(); + const adapter = new T3ThreadApi(mockApi({ + request: async () => { + throw new CliError("T3_API_ERROR", "not found", { details: { status: 404 } }); + }, + snapshot: async () => snapshot([expected], 7), + })); + + await expect(adapter.inspect("thread-1")).resolves.toEqual({ + snapshotSequence: 7, + thread: expected, + }); + }); +}); diff --git a/src/threadApi.ts b/src/threadApi.ts new file mode 100644 index 0000000..8cf20ca --- /dev/null +++ b/src/threadApi.ts @@ -0,0 +1,307 @@ +import { randomUUID } from "node:crypto"; + +import { T3Api } from "./api.js"; +import { CliError } from "./errors.js"; +import type { + InteractionMode, + OrchestrationSnapshot, + RuntimeMode, + T3Message, + T3Project, + T3Thread, + ThreadDetailSnapshot, +} from "./types.js"; + +const DEFAULT_VERIFICATION_TIMEOUT_MS = 5_000; +const DEFAULT_VERIFICATION_INTERVAL_MS = 100; + +export interface ThreadCatalog { + snapshotSequence: number; + projects: T3Project[]; + threads: T3Thread[]; + updatedAt: string; +} + +export interface ExistingThreadTurnCommand { + type: "thread.turn.start"; + commandId: string; + threadId: string; + message: { + messageId: string; + role: "user"; + text: string; + attachments: []; + }; + runtimeMode: RuntimeMode; + interactionMode: InteractionMode; + createdAt: string; +} + +export interface ExistingThreadTurnVerification { + accepted: true; + method: "message-id"; + snapshotSequence: number; + messageId: string; +} + +export type ThreadSettlementState = "active" | "settled"; + +export type ThreadSettlementCommand = + | { + type: "thread.settle"; + commandId: string; + threadId: string; + } + | { + type: "thread.unsettle"; + commandId: string; + threadId: string; + reason: "user"; + }; + +export interface ThreadSettlementVerification { + accepted: true; + state: ThreadSettlementState; + snapshotSequence: number; + settledAt: string | null; + unsettledAt: string | null; +} + +interface T3ThreadApiOptions { + verificationTimeoutMs?: number; + verificationIntervalMs?: number; +} + +function asSnapshot(value: unknown): OrchestrationSnapshot { + if (value === null || typeof value !== "object") { + throw new CliError("T3_INVALID_SNAPSHOT", "T3 returned an invalid orchestration snapshot."); + } + const snapshot = value as Partial; + if (!Array.isArray(snapshot.projects) || !Array.isArray(snapshot.threads)) { + throw new CliError("T3_INVALID_SNAPSHOT", "T3 returned a snapshot without projects or threads."); + } + return { + snapshotSequence: + typeof snapshot.snapshotSequence === "number" ? snapshot.snapshotSequence : 0, + projects: snapshot.projects, + threads: snapshot.threads, + updatedAt: typeof snapshot.updatedAt === "string" ? snapshot.updatedAt : "1970-01-01T00:00:00.000Z", + }; +} + +function asThreadDetailSnapshot(value: unknown): ThreadDetailSnapshot | null { + if (value === null || typeof value !== "object") return null; + const snapshot = value as Partial; + if ( + typeof snapshot.snapshotSequence !== "number" || + snapshot.thread === null || + typeof snapshot.thread !== "object" || + typeof snapshot.thread.id !== "string" + ) { + return null; + } + return snapshot as ThreadDetailSnapshot; +} + +function activeThreads(snapshot: OrchestrationSnapshot): T3Thread[] { + return snapshot.threads.filter((thread) => thread.deletedAt == null && thread.archivedAt == null); +} + +function threadById(snapshot: OrchestrationSnapshot, threadId: string): T3Thread | null { + return snapshot.threads.find((thread) => thread.id === threadId && thread.deletedAt == null) ?? null; +} + +function requireTurnSettings(thread: T3Thread): { + runtimeMode: RuntimeMode; + interactionMode: InteractionMode; +} { + const runtimeMode = thread.runtimeMode; + const interactionMode = thread.interactionMode; + if ( + !["approval-required", "auto", "auto-accept-edits", "full-access"].includes(runtimeMode ?? "") || + !["default", "plan"].includes(interactionMode ?? "") + ) { + throw new CliError( + "T3_INVALID_THREAD", + `T3 thread ${thread.id} is missing its runtime or interaction mode.`, + { details: { threadId: thread.id } }, + ); + } + return { runtimeMode: runtimeMode!, interactionMode: interactionMode! }; +} + +function dispatchSequence(value: unknown): number { + if (value === null || typeof value !== "object") { + throw new CliError("T3_INVALID_DISPATCH", "T3 returned an invalid dispatch result."); + } + const sequence = (value as { sequence?: unknown }).sequence; + if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 0) { + throw new CliError("T3_INVALID_DISPATCH", "T3 did not return a valid orchestration sequence."); + } + return sequence; +} + +function messageWasProjected(messages: readonly T3Message[] | undefined, messageId: string): boolean { + return messages?.some((message) => message.id === messageId && message.role === "user") ?? false; +} + +function settlementWasProjected( + thread: T3Thread, + state: ThreadSettlementState, + previousUpdatedAt: string | undefined, +): boolean { + if (state === "settled") return thread.settledAt != null; + if (thread.settledAt != null) return false; + if (thread.settledOverride === "active") return true; + return previousUpdatedAt !== undefined && (thread.updatedAt ?? "") > previousUpdatedAt; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export class T3ThreadApi { + private readonly verificationTimeoutMs: number; + private readonly verificationIntervalMs: number; + + constructor( + private readonly api: T3Api, + options: T3ThreadApiOptions = {}, + ) { + this.verificationTimeoutMs = options.verificationTimeoutMs ?? DEFAULT_VERIFICATION_TIMEOUT_MS; + this.verificationIntervalMs = options.verificationIntervalMs ?? DEFAULT_VERIFICATION_INTERVAL_MS; + } + + async catalog(): Promise { + const shell = await this.api.shellSnapshot().catch(() => null); + const snapshot = asSnapshot(shell ?? (await this.api.snapshot())); + return { ...snapshot, threads: activeThreads(snapshot) }; + } + + async inspect(threadId: string): Promise<{ snapshotSequence: number; thread: T3Thread }> { + const requestPath = `/api/orchestration/threads/${encodeURIComponent(threadId)}?turnLimit=10`; + const detail = await this.api.request("GET", requestPath).catch(() => null); + const parsedDetail = asThreadDetailSnapshot(detail); + if (parsedDetail) { + return { snapshotSequence: parsedDetail.snapshotSequence, thread: parsedDetail.thread }; + } + + const snapshot = asSnapshot(await this.api.snapshot()); + const thread = threadById(snapshot, threadId); + if (!thread) { + throw new CliError("THREAD_NOT_FOUND", `No T3 Code thread exists with id ${threadId}.`, { + exitCode: 3, + details: { threadId }, + }); + } + return { snapshotSequence: snapshot.snapshotSequence, thread }; + } + + buildTurnStart(thread: T3Thread, prompt: string): ExistingThreadTurnCommand { + const { runtimeMode, interactionMode } = requireTurnSettings(thread); + return { + type: "thread.turn.start", + commandId: randomUUID(), + threadId: thread.id, + message: { + messageId: randomUUID(), + role: "user", + text: prompt, + attachments: [], + }, + runtimeMode, + interactionMode, + createdAt: new Date().toISOString(), + }; + } + + buildSettlement(threadId: string, state: ThreadSettlementState): ThreadSettlementCommand { + return state === "settled" + ? { type: "thread.settle", commandId: randomUUID(), threadId } + : { type: "thread.unsettle", commandId: randomUUID(), threadId, reason: "user" }; + } + + async dispatchTurn( + command: ExistingThreadTurnCommand, + ): Promise<{ dispatch: unknown; verification: ExistingThreadTurnVerification }> { + const dispatch = await this.api.dispatch(command); + const sequence = dispatchSequence(dispatch); + const deadline = Date.now() + this.verificationTimeoutMs; + + do { + const inspected = await this.inspect(command.threadId).catch(() => null); + if (inspected && inspected.snapshotSequence >= sequence) { + if (messageWasProjected(inspected.thread.messages, command.message.messageId)) { + return { + dispatch, + verification: { + accepted: true, + method: "message-id", + snapshotSequence: inspected.snapshotSequence, + messageId: command.message.messageId, + }, + }; + } + } + await sleep(this.verificationIntervalMs); + } while (Date.now() < deadline); + + throw new CliError( + "THREAD_TURN_NOT_VERIFIED", + `T3 did not project the new turn for thread ${command.threadId} within ${this.verificationTimeoutMs}ms.`, + { + exitCode: 5, + details: { + threadId: command.threadId, + messageId: command.message.messageId, + dispatchSequence: sequence, + }, + }, + ); + } + + async dispatchSettlement( + command: ThreadSettlementCommand, + previousUpdatedAt: string | undefined, + ): Promise<{ + dispatch: unknown; + thread: T3Thread; + verification: ThreadSettlementVerification; + }> { + const state: ThreadSettlementState = command.type === "thread.settle" ? "settled" : "active"; + const dispatch = await this.api.dispatch(command); + const sequence = dispatchSequence(dispatch); + const deadline = Date.now() + this.verificationTimeoutMs; + + do { + const inspected = await this.inspect(command.threadId).catch(() => null); + if ( + inspected && + inspected.snapshotSequence >= sequence && + settlementWasProjected(inspected.thread, state, previousUpdatedAt) + ) { + return { + dispatch, + thread: inspected.thread, + verification: { + accepted: true, + state, + snapshotSequence: inspected.snapshotSequence, + settledAt: inspected.thread.settledAt ?? null, + unsettledAt: inspected.thread.unsettledAt ?? null, + }, + }; + } + await sleep(this.verificationIntervalMs); + } while (Date.now() < deadline); + + throw new CliError( + "THREAD_SETTLEMENT_NOT_VERIFIED", + `T3 did not project thread ${command.threadId} as ${state} within ${this.verificationTimeoutMs}ms.`, + { + exitCode: 5, + details: { threadId: command.threadId, state, dispatchSequence: sequence }, + }, + ); + } +} diff --git a/src/threads.test.ts b/src/threads.test.ts new file mode 100644 index 0000000..30bcbb0 --- /dev/null +++ b/src/threads.test.ts @@ -0,0 +1,393 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { DEFAULT_CONFIG } from "./config.js"; +import { CliError } from "./errors.js"; +import { runProcess } from "./process.js"; +import { + inspectThread, + listThreads, + sendThreadMessage, + settleThread, + unsettleThread, +} from "./service.js"; +import type { CliConfig, T3Message, T3Project, T3Thread } from "./types.js"; + +const cleanup: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((run) => run())); +}); + +async function bodyOf(request: IncomingMessage): Promise { + let body = ""; + request.setEncoding("utf8"); + for await (const chunk of request) body += chunk; + return JSON.parse(body) as unknown; +} + +function json(response: ServerResponse, status: number, value: unknown): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(value)); +} + +function makeThread(id: string, overrides: Partial = {}): T3Thread { + return { + id, + projectId: "project-1", + title: `Thread ${id}`, + modelSelection: { instanceId: "codex", model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-09-04T10:00:00.000Z", + updatedAt: "2026-09-04T10:00:00.000Z", + archivedAt: null, + settledAt: null, + latestUserMessageAt: null, + messages: [], + deletedAt: null, + ...overrides, + }; +} + +async function testHarness( + initialThreads: T3Thread[], + options: { omitCapabilities?: boolean; threadSettlement?: boolean } = {}, +) { + const root = await mkdtemp(path.join(os.tmpdir(), "t3code-cli-threads-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + await runProcess("git", ["init", "-b", "main"], { cwd: root }); + + const mockT3 = path.join(root, "mock-t3.mjs"); + await writeFile( + mockT3, + `const args = process.argv.slice(2);\nif (args.includes("issue")) process.stdout.write(JSON.stringify({sessionId:"mock-session",token:"mock-token"}));\n`, + "utf8", + ); + + const project: T3Project = { + id: "project-1", + title: "Project One", + workspaceRoot: await realpath(root), + defaultModelSelection: { instanceId: "codex", model: "gpt-5.6-sol" }, + deletedAt: null, + }; + const projects = [project]; + const threads = initialThreads; + const commands: Array> = []; + let sequence = 10; + const shell = () => ({ + snapshotSequence: sequence, + projects, + threads: threads.filter((thread) => thread.archivedAt == null && thread.deletedAt == null), + updatedAt: new Date().toISOString(), + }); + const full = () => ({ + snapshotSequence: sequence, + projects, + threads, + updatedAt: new Date().toISOString(), + }); + + const server = createServer(async (request, response) => { + if (request.url === "/.well-known/t3/environment") { + json(response, 200, { + environmentId: "environment-1", + serverVersion: "0.0.38", + ...(options.omitCapabilities + ? {} + : { capabilities: { threadSettlement: options.threadSettlement ?? true } }), + }); + return; + } + if (request.headers.authorization !== "Bearer mock-token") { + json(response, 401, { error: "unauthorized" }); + return; + } + if (request.method === "GET" && request.url === "/api/orchestration/shell") { + json(response, 200, shell()); + return; + } + if (request.method === "GET" && request.url === "/api/orchestration/snapshot") { + json(response, 200, full()); + return; + } + const detailMatch = request.url?.match(/^\/api\/orchestration\/threads\/([^?]+)/u); + if (request.method === "GET" && detailMatch) { + const threadId = decodeURIComponent(detailMatch[1]!); + const thread = threads.find((candidate) => candidate.id === threadId && candidate.deletedAt == null); + if (!thread) json(response, 404, { error: "not found" }); + else json(response, 200, { snapshotSequence: sequence, thread }); + return; + } + if (request.method === "POST" && request.url === "/api/orchestration/dispatch") { + const command = (await bodyOf(request)) as Record; + commands.push(command); + sequence += 2; + if (command.type === "thread.turn.start") { + const target = threads.find((thread) => thread.id === command.threadId)!; + const message = command.message as T3Message & { messageId: string }; + const projectedMessage: T3Message = { + id: message.messageId, + role: "user", + text: message.text, + turnId: null, + streaming: false, + createdAt: command.createdAt as string, + updatedAt: command.createdAt as string, + }; + target.messages = [...(target.messages ?? []), projectedMessage]; + target.updatedAt = command.createdAt as string; + target.latestUserMessageAt = command.createdAt as string; + target.settledAt = null; + } + if (command.type === "thread.settle") { + const target = threads.find((thread) => thread.id === command.threadId)!; + const updatedAt = new Date().toISOString(); + target.settledOverride = "settled"; + target.settledAt = updatedAt; + target.unsettledAt = null; + target.updatedAt = updatedAt; + } + if (command.type === "thread.unsettle") { + const target = threads.find((thread) => thread.id === command.threadId)!; + const updatedAt = new Date().toISOString(); + target.settledOverride = "active"; + target.settledAt = null; + target.unsettledAt = updatedAt; + target.updatedAt = updatedAt; + } + json(response, 200, { sequence }); + return; + } + json(response, 404, { error: "not found" }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanup.push(() => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing server address"); + + const origin = `http://127.0.0.1:${address.port}`; + const stateDir = path.join(root, ".t3", "userdata"); + await mkdir(stateDir, { recursive: true }); + await writeFile(path.join(stateDir, "server-runtime.json"), JSON.stringify({ + version: 1, + pid: process.pid, + port: address.port, + origin, + startedAt: new Date().toISOString(), + }), "utf8"); + + const config: CliConfig = { + ...DEFAULT_CONFIG, + origin, + t3Home: path.join(root, ".t3"), + t3Command: [process.execPath, mockT3], + }; + return { config, root, project, threads, commands }; +} + +describe("thread discovery and messaging", () => { + it("lists threads by project and active/settled status", async () => { + const harness = await testHarness([ + makeThread("active", { updatedAt: "2026-09-04T12:00:00.000Z" }), + makeThread("settled", { settledAt: "2026-09-04T11:00:00.000Z" }), + ]); + + const active = await listThreads(harness.config, { project: "project-1", status: "active" }); + const settled = await listThreads(harness.config, { cwd: harness.root, status: "settled" }); + + expect(active.threads.map((thread) => thread.id)).toEqual(["active"]); + expect(settled.threads.map((thread) => thread.id)).toEqual(["settled"]); + expect(settled.filter).toMatchObject({ projectId: "project-1", status: "settled" }); + }); + + it("inspects an exact thread with its project", async () => { + const harness = await testHarness([makeThread("target", { + messages: [{ + id: "message-1", + role: "user", + text: "A".repeat(2_001), + turnId: null, + streaming: false, + createdAt: "2026-09-04T10:00:00.000Z", + updatedAt: "2026-09-04T10:00:00.000Z", + }], + activities: [{ large: "internal detail" }], + })]); + + const result = await inspectThread(harness.config, "target"); + + expect(result.thread).toMatchObject({ + id: "target", + status: "active", + messageCount: 1, + recentMessages: [{ id: "message-1", textTruncated: true }], + }); + expect(result.thread.recentMessages[0]?.text).toHaveLength(2_000); + expect(result.thread).not.toHaveProperty("messages"); + expect(result.thread).not.toHaveProperty("activities"); + expect(result.project).toMatchObject({ id: "project-1", title: "Project One" }); + }); + + it("sends and verifies a turn on an active thread", async () => { + const harness = await testHarness([makeThread("target")]); + + const result = await sendThreadMessage(harness.config, { + threadId: "target", + prompt: "Review findings", + }); + + expect(harness.commands).toHaveLength(1); + expect(harness.commands[0]).toMatchObject({ + type: "thread.turn.start", + threadId: "target", + message: { role: "user", text: "Review findings", attachments: [] }, + runtimeMode: "full-access", + interactionMode: "default", + }); + expect(result.verification).toMatchObject({ accepted: true, method: "message-id" }); + }); + + it("requires confirmation before waking a settled thread", async () => { + const harness = await testHarness([ + makeThread("settled", { settledAt: "2026-09-04T11:00:00.000Z" }), + ]); + + await expect(sendThreadMessage(harness.config, { + threadId: "settled", + prompt: "New findings", + })).rejects.toMatchObject({ + code: "SETTLED_THREAD_CONFIRMATION_REQUIRED", + exitCode: 4, + } satisfies Partial); + expect(harness.commands).toHaveLength(0); + }); + + it("allows an explicit settled-thread override", async () => { + const harness = await testHarness([ + makeThread("settled", { settledAt: "2026-09-04T11:00:00.000Z" }), + ]); + + const result = await sendThreadMessage(harness.config, { + threadId: "settled", + prompt: "New findings", + wakeSettled: true, + }); + + expect(result.thread.statusBeforeSend).toBe("settled"); + expect(result.verification.accepted).toBe(true); + expect(harness.commands).toHaveLength(1); + }); + + it("allows an approved settled-thread confirmation", async () => { + const harness = await testHarness([ + makeThread("settled", { settledAt: "2026-09-04T11:00:00.000Z" }), + ]); + let confirmedProject: T3Project | null = null; + + const result = await sendThreadMessage(harness.config, { + threadId: "settled", + prompt: "Confirmed findings", + confirmSettled: async (_thread, project) => { + confirmedProject = project; + return true; + }, + }); + + expect(confirmedProject).toMatchObject({ id: "project-1" }); + expect(result.verification.accepted).toBe(true); + expect(harness.commands).toHaveLength(1); + }); + + it("rejects an archived thread", async () => { + const harness = await testHarness([ + makeThread("archived", { archivedAt: "2026-09-04T11:00:00.000Z" }), + ]); + + await expect(sendThreadMessage(harness.config, { + threadId: "archived", + prompt: "New findings", + wakeSettled: true, + })).rejects.toMatchObject({ code: "THREAD_ARCHIVED", exitCode: 4 } satisfies Partial); + expect(harness.commands).toHaveLength(0); + }); + + it("settles and verifies an active thread", async () => { + const harness = await testHarness([makeThread("target")]); + + const result = await settleThread(harness.config, "target"); + + expect(harness.commands).toHaveLength(1); + expect(harness.commands[0]).toMatchObject({ type: "thread.settle", threadId: "target" }); + expect(result.thread).toMatchObject({ statusBefore: "active", statusAfter: "settled" }); + expect(result.verification).toMatchObject({ accepted: true, state: "settled" }); + }); + + it("unsettles and verifies a settled thread", async () => { + const harness = await testHarness([ + makeThread("target", { + settledOverride: "settled", + settledAt: "2026-09-04T11:00:00.000Z", + }), + ]); + + const result = await unsettleThread(harness.config, "target"); + + expect(harness.commands).toHaveLength(1); + expect(harness.commands[0]).toMatchObject({ + type: "thread.unsettle", + threadId: "target", + reason: "user", + }); + expect(result.thread).toMatchObject({ statusBefore: "settled", statusAfter: "active" }); + expect(result.verification).toMatchObject({ accepted: true, state: "active" }); + }); + + it.each([ + ["settle", settleThread, { omitCapabilities: true }], + ["unsettle", unsettleThread, { threadSettlement: false }], + ] as const)("refuses to %s when the capability is not explicitly supported", async (_name, change, options) => { + const harness = await testHarness([ + makeThread("target", { settledAt: change === unsettleThread ? "2026-09-04T11:00:00.000Z" : null }), + ], options); + + await expect(change(harness.config, "target")).rejects.toMatchObject({ + code: "THREAD_SETTLEMENT_UNSUPPORTED", + exitCode: 4, + details: { capability: "threadSettlement", serverVersion: "0.0.38" }, + } satisfies Partial); + expect(harness.commands).toHaveLength(0); + }); + + it("refuses to settle a thread with an active turn", async () => { + const harness = await testHarness([ + makeThread("running", { + session: { + threadId: "running", + status: "running", + providerName: "codex", + providerInstanceId: "codex", + runtimeMode: "full-access", + activeTurnId: "turn-1", + lastError: null, + updatedAt: "2026-09-04T11:00:00.000Z", + }, + }), + ]); + + await expect(settleThread(harness.config, "running")).rejects.toMatchObject({ + code: "THREAD_SETTLE_BLOCKED", + exitCode: 4, + } satisfies Partial); + expect(harness.commands).toHaveLength(0); + }); +}); diff --git a/src/types.ts b/src/types.ts index da96803..008ca74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ export type WorkspaceMode = "repo" | "folder"; export type OpenMode = "auto" | "desktop" | "browser" | "none"; export type ThreadEnvMode = "t3" | "local" | "worktree"; export type EffectiveThreadEnvMode = Exclude; -export type RuntimeMode = "approval-required" | "auto-accept-edits" | "full-access"; +export type RuntimeMode = "approval-required" | "auto" | "auto-accept-edits" | "full-access"; export type InteractionMode = "default" | "plan"; export type SpeedMode = "standard" | "fast"; @@ -40,6 +40,10 @@ export interface T3Runtime { settingsPath: string | null; environmentId: string; serverVersion: string; + capabilities: { + threadSettlement?: boolean; + [key: string]: unknown; + }; } export interface ModelSelection { @@ -67,7 +71,57 @@ export interface T3Thread { id: string; projectId: string; title: string; + modelSelection?: ModelSelection; + runtimeMode?: RuntimeMode; + interactionMode?: InteractionMode; + branch?: string | null; + worktreePath?: string | null; + latestTurn?: T3LatestTurn | null; + session?: T3Session | null; + createdAt?: string; + updatedAt?: string; archivedAt: string | null; + settledOverride?: "settled" | "active" | null; + settledAt?: string | null; + unsettledAt?: string | null; + latestUserMessageAt?: string | null; + hasPendingApprovals?: boolean; + hasPendingUserInput?: boolean; + messages?: T3Message[]; + deletedAt?: string | null; + [key: string]: unknown; +} + +export interface T3LatestTurn { + turnId: string; + state: "running" | "interrupted" | "completed" | "error"; + requestedAt: string; + startedAt: string | null; + completedAt: string | null; + assistantMessageId: string | null; + [key: string]: unknown; +} + +export interface T3Session { + threadId: string; + status: "idle" | "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; + providerName: string | null; + providerInstanceId?: string; + runtimeMode: RuntimeMode; + activeTurnId: string | null; + lastError: string | null; + updatedAt: string; + [key: string]: unknown; +} + +export interface T3Message { + id: string; + role: "user" | "assistant" | "system"; + text: string; + turnId: string | null; + streaming: boolean; + createdAt: string; + updatedAt: string; [key: string]: unknown; } @@ -78,6 +132,17 @@ export interface OrchestrationSnapshot { updatedAt: string; } +export interface ThreadDetailSnapshot { + snapshotSequence: number; + thread: T3Thread; + page?: { + beforeCursor: string | null; + hasMore: boolean; + snapshotSequence: number; + threadSequence?: number; + }; +} + export interface OpenResult { mode: OpenMode; kind: "thread-deep-link" | "desktop-reveal" | "browser" | "none";