From df64886856addd88846eeeaa16f5271810606f2d Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 22 Oct 2026 02:37:04 -0700 Subject: [PATCH 01/21] fix(projects): group non-Git copies across environments explicitly --- .../threads/new-task-flow-provider.tsx | 3 +- .../new-task-project-selection.test.ts | 52 +++++----- .../threads/new-task-project-selection.ts | 50 +++------- apps/server/src/cli/project.test.ts | 59 +++++++++++ apps/server/src/cli/project.ts | 97 ++++++++++++++++++- .../src/mcp/toolkits/project/handlers.ts | 1 + apps/server/src/mcp/toolkits/project/tools.ts | 3 +- .../orchestration-v2/ProjectCommands.test.ts | 1 + .../src/orchestration-v2/ProjectCommands.ts | 15 +++ .../src/orchestration-v2/ProjectStore.ts | 10 ++ .../orchestration-v2/RuntimePolicy.test.ts | 1 + .../ThreadLaunchService.test.ts | 1 + .../ThreadTitleRegenerationService.test.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/077_ProjectGroupingIdentity.ts | 7 ++ apps/server/src/project/ProjectMutation.ts | 6 ++ .../server/src/project/ProjectService.test.ts | 59 +++++++++++ apps/server/src/project/ProjectService.ts | 7 ++ docs/user/project-settings.md | 16 +++ .../client-runtime/src/operations/commands.ts | 6 ++ .../src/state/projectGrouping.test.ts | 79 +++++++++++++++ .../src/state/projectGrouping.ts | 38 ++++++-- .../src/state/shellReducer.test.ts | 20 ++++ packages/contracts/src/applicationEvent.ts | 4 +- .../contracts/src/orchestrationProject.ts | 3 +- packages/contracts/src/project.test.ts | 17 ++++ packages/contracts/src/project.ts | 8 ++ 27 files changed, 490 insertions(+), 76 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/077_ProjectGroupingIdentity.ts diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 3876fd2f83dbb..bbee484dc87c5 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,6 +1,7 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { useNowMinuteMs } from "../../lib/clock"; +import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -16,7 +17,6 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, - repositoryGroupingKeyOf, ThreadId, } from "@t3tools/contracts"; import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; @@ -589,6 +589,7 @@ export function NewTaskFlowProvider( const match = resolveEnvironmentProjectMatch( projects.filter((project) => project.environmentId === environmentId), selectedProject, + groupingSettings, ); updateFlow({ draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 99cf9e7d37ef4..e779aa8fab1ca 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -87,34 +87,32 @@ describe("resolveEnvironmentProjectMatch", () => { expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); }); - it("falls back to workspace basename, then title, for unindexed projects", () => { - const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); - const byBasename = [ - makeProject("other", "server"), - makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), - ]; - expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); - - const byTitle = [ - makeProject("other", "server"), - makeProject("srv", "server", { title: "t3code" }), - ]; - expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); + it("requires explicit identity for non-Git copies and respects separate mode", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const selected = { ...makeProject("sdk", "mac"), groupingIdentity }; + const unrelated = makeProject("sdk", "server"); + const target = { ...makeProject("copy", "server"), groupingIdentity }; + expect(resolveEnvironmentProjectMatch([unrelated], selected)).toBeNull(); + expect(resolveEnvironmentProjectMatch([unrelated, target], selected)).toBe(target); + expect( + resolveEnvironmentProjectMatch([target], selected, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }), + ).toBeNull(); + expect( + resolveEnvironmentProjectMatch([unrelated], { ...selected, groupingIdentity: null }), + ).toBeNull(); }); - - it("does not treat a known different repository as a basename or title match", () => { - const selected = makeProject("t3code", "mac", { - repositoryKey: "github.com/t3tools/t3code", - workspaceRoot: "/Users/me/t3code", - }); - const fork = makeProject("fork", "server", { - repositoryKey: "github.com/someone/t3code", - title: "t3code", - workspaceRoot: "/home/me/t3code", - }); - const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); - expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); - expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + it("does not switch to unrelated repositories or first-project fallbacks", () => { + const selected = makeProject("sdk", "mac", { repositoryKey: "github.com/g/sdk" }); + expect( + resolveEnvironmentProjectMatch( + [makeProject("sdk", "server", { repositoryKey: "github.com/other/sdk" })], + selected, + ), + ).toBeNull(); + expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 93c8f1d5f51d6..faa3e48d2423c 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,7 +1,11 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + deriveLogicalProjectKeyFromSettings, + type ProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { repositoryGroupingKeyOf, type EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import { type EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { HomeProjectScope } from "../home/homeThreadList"; @@ -75,45 +79,21 @@ export function resolveNewTaskScratchOption(input: { return environment ? { environmentId: environment.environmentId, ready: ready.length > 0 } : null; } + export function resolveEnvironmentProjectMatch( projectsOnTarget: ReadonlyArray, selectedProject: EnvironmentProject | null, + settings: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, + }, ): EnvironmentProject | null { - const repositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, which supplies no useful basename. - const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - // Use weaker signals only when identity is unknown; different known repositories never match by basename or title. - const isKnownMismatch = (project: EnvironmentProject) => { - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; - }; + if (selectedProject === null) return projectsOnTarget[0] ?? null; + const key = deriveLogicalProjectKeyFromSettings(selectedProject, settings); return ( - (repositoryKey !== null - ? projectsOnTarget.find( - (project) => - (project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null) === repositoryKey, - ) - : undefined) ?? - (workspaceBasename !== null - ? projectsOnTarget.find( - (project) => - !isKnownMismatch(project) && - project.workspaceRoot.split("/").at(-1) === workspaceBasename, - ) - : undefined) ?? - (selectedProject !== null - ? projectsOnTarget.find( - (project) => !isKnownMismatch(project) && project.title === selectedProject.title, - ) - : undefined) ?? - projectsOnTarget[0] ?? - null + projectsOnTarget.find( + (project) => deriveLogicalProjectKeyFromSettings(project, settings) === key, + ) ?? null ); } diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 9367ce7bdf1fe..c162848592247 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -462,3 +462,62 @@ it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) }), ); }); + +it.effect( + "assigns grouping through add and can inspect, change, and clear it while preserving threads", + () => + Effect.gen(function* () { + const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); + const threadId = ThreadId.make("grouping-preserved-thread"); + yield* seedNativeThreads(baseDir, [{ id: threadId, projectId: project.id, archived: false }]); + const before = yield* readNativeThreadState(baseDir, threadId); + const identity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + for (let attempt = 0; attempt < 2; attempt++) { + yield* runCli([ + "project", + "add", + workspaceRoot, + "--title", + "Ignored for existing", + "--grouping-identity", + identity, + "--base-dir", + baseDir, + ]); + } + const assigned = (yield* readProjects(baseDir)).projects; + assert.equal(assigned.length, 1); + assert.equal(assigned[0]?.id, project.id); + assert.equal(assigned[0]?.title, project.title); + assert.equal(assigned[0]?.groupingIdentity, identity); + yield* runCli(["project", "grouping", project.id, "--base-dir", baseDir]); + yield* runCli(["project", "list", "--base-dir", baseDir]); + yield* runCli([ + "project", + "grouping", + workspaceRoot, + "--identity", + "e703c118-3547-41b1-88c5-9774cf40ae86", + "--base-dir", + baseDir, + ]); + assert.equal( + (yield* readProjects(baseDir)).projects[0]?.groupingIdentity, + "e703c118-3547-41b1-88c5-9774cf40ae86", + ); + yield* runCli(["project", "grouping", workspaceRoot, "--clear", "--base-dir", baseDir]); + assert.isNull((yield* readProjects(baseDir)).projects[0]?.groupingIdentity); + const invalid = yield* runCli([ + "project", + "grouping", + workspaceRoot, + "--identity", + "group/sdk", + "--base-dir", + baseDir, + ]).pipe(Effect.result); + assert.equal(invalid._tag, "Failure"); + assert.isNull((yield* readProjects(baseDir)).projects[0]?.groupingIdentity); + assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index d1c8c8a4959ae..dec38d8c76575 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -3,6 +3,8 @@ import { CommandId, EnvironmentHttpApi, EnvironmentHttpCommonError, + Project, + ProjectGroupingIdentity, type ProjectMutation, type ProjectSnapshot, ProjectId, @@ -463,6 +465,12 @@ const projectAddCommand = Command.make("add", { workspaceRoot: Argument.String("path").pipe( Argument.withDescription("Workspace root to add as a project."), ), + groupingIdentity: Flag.String("grouping-identity").pipe( + Flag.withDescription( + "Shared lowercase UUID v4 for copies of a non-Git project; updates an existing registration.", + ), + Flag.optional, + ), title: Flag.String("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), }).pipe( Command.withDescription("Add a project."), @@ -482,6 +490,17 @@ const projectAddCommand = Command.make("add", { const existingProject = snapshot.projects.find( (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, ); + if (existingProject && Option.isSome(flags.groupingIdentity)) { + if (existingProject.groupingIdentity === flags.groupingIdentity.value) + return `Project ${existingProject.id} already has this grouping identity.`; + yield* dispatch({ + type: "project.update", + commandId: CommandId.make(yield* projectCommandUuid), + projectId: existingProject.id, + groupingIdentity: flags.groupingIdentity.value, + }); + return `Updated grouping identity for project ${existingProject.id}.`; + } if (existingProject) { return yield* new ProjectAlreadyExistsError({ operation: "addProject", @@ -498,6 +517,10 @@ const projectAddCommand = Command.make("add", { projectId, title, workspaceRoot, + ...Option.match(flags.groupingIdentity, { + onNone: () => ({}), + onSome: (groupingIdentity) => ({ groupingIdentity }), + }), }); return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; }), @@ -585,7 +608,79 @@ const projectRenameCommand = Command.make("rename", { ), ); +const encodeProjectList = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Project))); +const encodeGroupingResult = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + projectId: ProjectId, + groupingIdentity: Schema.NullOr(ProjectGroupingIdentity), + }), + ), +); + +const projectListCommand = Command.make("list", { ...projectLocationFlags }).pipe( + Command.withDescription("List registered projects as JSON, including groupingIdentity."), + Command.withHandler((flags) => + runProjectMutation(flags, ({ snapshot }) => encodeProjectList(snapshot.projects)), + ), +); + +const projectGroupingCommand = Command.make("grouping", { + ...projectLocationFlags, + project: Argument.String("project").pipe( + Argument.withDescription("Project id or workspace root."), + ), + identity: Flag.String("identity").pipe( + Flag.withDescription("Shared lowercase UUID v4. Omit to show the current identity."), + Flag.optional, + ), + clear: Flag.Boolean("clear").pipe(Flag.withDefault(false)), +}).pipe( + Command.withDescription("Show, set, or clear a project's explicit grouping identity."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectGroupingMutation")(function* ({ snapshot, dispatch }) { + const project = yield* findActiveProjectTarget({ snapshot, identifier: flags.project }); + if (flags.clear && Option.isSome(flags.identity)) { + return yield* new ProjectGroupingFlagsError({}); + } + if (!flags.clear && Option.isNone(flags.identity)) { + return yield* encodeGroupingResult({ + projectId: project.id, + groupingIdentity: + snapshot.projects.find((entry) => entry.id === project.id)?.groupingIdentity ?? null, + }); + } + const groupingIdentity = flags.clear ? null : Option.getOrThrow(flags.identity); + yield* dispatch({ + type: "project.update", + commandId: CommandId.make(yield* projectCommandUuid), + projectId: project.id, + groupingIdentity, + }); + return yield* encodeGroupingResult({ projectId: project.id, groupingIdentity }); + }), + ), + ), +); + +class ProjectGroupingFlagsError extends Schema.TaggedError()( + "ProjectGroupingFlagsError", + {}, +) { + override get message(): string { + return "Use either --identity or --clear."; + } +} + export const projectCommand = Command.make("project").pipe( Command.withDescription("Manage projects."), - Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), + Command.withSubcommands([ + projectAddCommand, + projectRemoveCommand, + projectRenameCommand, + projectListCommand, + projectGroupingCommand, + ]), ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 59399c7febdea..2e969d050d0dd 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -313,6 +313,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { if (workspaceRoot === undefined) { // Only updates record model defaults; reject defaults that creation would silently drop. if ( + input.groupingIdentity !== undefined || input.scripts !== undefined || input.createWorkspaceRootIfMissing !== undefined || input.defaultModelSelection !== undefined diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bd7c986f4c48a..f2b7b46d6536f 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -87,7 +87,8 @@ const ProjectCreateTool = Tool.make("t3_project_create", { }).annotate(Tool.Destructive, true); const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, - description: "Update one project's settings. Omitted fields are unchanged.", + description: + "Update project settings; omitted fields stay unchanged. groupingIdentity: shared lowercase UUID v4 for non-Git copies, null to clear. Git identity wins; read/list shows the value.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: ProjectId, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 96249ece4c106..fc8b693fcde04 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -17,6 +17,7 @@ const row = (overrides: Partial = {}): ProjectRow => ({ projectId, title: "Scripts", workspaceRoot: "/tmp/scripts", + groupingIdentity: null, defaultModelSelection: null, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index d4c1abb3ceda2..9dc11a71d1816 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -5,6 +5,7 @@ import { type ModelSelection, type ProjectIconOverride, ProjectId, + ProjectGroupingIdentity, type ProjectScript, SCRIPT_RUN_COMMAND_PATTERN, type ThreadEnvMode, @@ -22,6 +23,7 @@ export interface ProjectCreateCommand { readonly projectId: ProjectId; readonly title: string; readonly workspaceRoot: string; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } @@ -36,6 +38,7 @@ export interface ProjectMetaUpdateCommand { readonly autoPull?: boolean; readonly faviconPath?: string | null; readonly projectIcon?: ProjectIconOverride | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; readonly pinned?: boolean; readonly archived?: boolean; @@ -104,6 +107,7 @@ export interface ProjectCommandState { } const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const isProjectGroupingIdentity = Schema.is(ProjectGroupingIdentity); const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); /** Read state and commit the planned event under the project lock. */ @@ -131,6 +135,13 @@ export function planProjectCommand(input: { workspaceRoot, conflictingProjectId: state.workspaceOwner.projectId, }); + if ( + command.type !== "project.delete" && + command.groupingIdentity != null && + !isProjectGroupingIdentity(command.groupingIdentity) + ) { + return invariant("Project grouping identity must be a lowercase UUID v4."); + } const occurredAt = DateTime.formatIso(input.now); const base = { eventId: input.eventId, @@ -159,6 +170,7 @@ export function planProjectCommand(input: { projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, + groupingIdentity: command.groupingIdentity ?? null, defaultModelSelection: null, faviconPath: null, projectIcon: null, @@ -198,6 +210,9 @@ export function planProjectCommand(input: { type: "project.meta-updated", payload: { projectId: command.projectId, + ...(command.groupingIdentity === undefined + ? {} + : { groupingIdentity: command.groupingIdentity }), ...(command.title === undefined ? {} : { title: command.title }), ...(command.workspaceRoot === undefined ? {} : { workspaceRoot: command.workspaceRoot }), ...(command.defaultModelSelection === undefined diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index 2be0a765c784f..d2a2a7004453e 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -33,6 +33,7 @@ export const ProjectRow = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, + groupingIdentity: Schema.NullOr(Schema.String), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, @@ -60,6 +61,7 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + groupingIdentity: row.groupingIdentity, repositoryIdentity: null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, @@ -118,6 +120,7 @@ export const make = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + grouping_identity AS "groupingIdentity", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", @@ -150,6 +153,7 @@ export const make = Effect.gen(function* () { project_id, title, workspace_root, + grouping_identity, default_model_selection_json, default_thread_env_mode, auto_pull, @@ -166,6 +170,7 @@ export const make = Effect.gen(function* () { ${encoded.projectId}, ${encoded.title}, ${encoded.workspaceRoot}, + ${encoded.groupingIdentity}, ${encoded.defaultModelSelection}, ${encoded.defaultThreadEnvMode}, ${encoded.autoPull}, @@ -182,6 +187,7 @@ export const make = Effect.gen(function* () { DO UPDATE SET title = excluded.title, workspace_root = excluded.workspace_root, + grouping_identity = excluded.grouping_identity, default_model_selection_json = excluded.default_model_selection_json, default_thread_env_mode = excluded.default_thread_env_mode, auto_pull = excluded.auto_pull, @@ -232,6 +238,7 @@ export const make = Effect.gen(function* () { projectId: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, + groupingIdentity: payload.groupingIdentity ?? null, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, autoPull: false, @@ -258,6 +265,9 @@ export const make = Effect.gen(function* () { const payload = event.payload; yield* upsertRow({ ...row, + ...(payload.groupingIdentity === undefined + ? {} + : { groupingIdentity: payload.groupingIdentity }), ...(payload.title === undefined ? {} : { title: payload.title }), ...(payload.workspaceRoot === undefined ? {} : { workspaceRoot: payload.workspaceRoot }), ...(payload.defaultModelSelection === undefined diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 21647ef49bf28..7a54f9d47a130 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -94,6 +94,7 @@ const layerWithPolicy = (policy?: ManagedPolicySummary) => projectId, title: "Project", workspaceRoot: "/project-root", + groupingIdentity: null, defaultModelSelection: null, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 06438082acddf..6c5f58454d7c1 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -244,6 +244,7 @@ function makeHarness(options: HarnessOptions = {}) { projectId, title: project.title, workspaceRoot: project.workspaceRoot, + groupingIdentity: null, defaultModelSelection: project.defaultModelSelection, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8f7de819f5718..f4df30430a6d4 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -76,6 +76,7 @@ function makeHarness( projectId, title: "Project", workspaceRoot: "/repo", + groupingIdentity: null, defaultModelSelection: modelSelection, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e13c2b449d9e7..8ef03c30480e7 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -79,6 +79,7 @@ import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; +import Migration0077 from "./Migrations/077_ProjectGroupingIdentity.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -158,6 +159,7 @@ const migrationEntries = [ [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], + [77, "ProjectGroupingIdentity", Migration0077], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/077_ProjectGroupingIdentity.ts b/apps/server/src/persistence/Migrations/077_ProjectGroupingIdentity.ts new file mode 100644 index 0000000000000..c32e41e32d0e8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/077_ProjectGroupingIdentity.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_projects ADD COLUMN grouping_identity TEXT`; +}); diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 550738b24baae..a84e4caa88770 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -23,6 +23,9 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.groupingIdentity === undefined + ? {} + : { groupingIdentity: mutation.groupingIdentity }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); @@ -41,6 +44,9 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu ...(mutation.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.groupingIdentity === undefined + ? {} + : { groupingIdentity: mutation.groupingIdentity }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), ...(mutation.pinned === undefined ? {} : { pinned: mutation.pinned }), ...(mutation.archived === undefined ? {} : { archived: mutation.archived }), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 331332a84bc95..6d1e2ed7b8451 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -738,3 +738,62 @@ it.effect("rejects an update that waited on the lock while its project was delet ); }).pipe(Effect.provide(layerProjectServiceDependencies)), ); + +it.layer( + layerTestFor( + Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => Effect.succeed(null), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + resolvePath: () => Effect.succeed(null), + invalidate: () => Effect.void, + }), + ), + ), +)("non-Git grouping metadata", (it) => { + it.effect( + "persists, changes, and clears explicit grouping without changing physical identity", + () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const projectId = ProjectId.make("explicit-grouping"); + const identity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const created = yield* service.create({ + commandId: CommandId.make("group-create"), + projectId, + title: "sdk", + workspaceRoot: "/groups/sdk", + groupingIdentity: identity, + }); + assert.equal(created.groupingIdentity, identity); + yield* service.update({ + commandId: CommandId.make("group-rename"), + projectId, + title: "Renamed", + }); + assert.equal( + Option.getOrThrow(yield* service.getById(projectId)).groupingIdentity, + identity, + ); + assert.equal((yield* service.listShells())[0]?.groupingIdentity, identity); + const changed = yield* service.update({ + commandId: CommandId.make("group-change"), + projectId, + groupingIdentity: "e703c118-3547-41b1-88c5-9774cf40ae86", + }); + assert.equal(changed.groupingIdentity, "e703c118-3547-41b1-88c5-9774cf40ae86"); + const cleared = yield* service.update({ + commandId: CommandId.make("group-clear"), + projectId, + groupingIdentity: null, + }); + assert.isNull(cleared.groupingIdentity); + assert.equal(cleared.id, created.id); + assert.equal(cleared.workspaceRoot, created.workspaceRoot); + assert.equal(cleared.createdAt, created.createdAt); + assert.isNull((yield* service.snapshot).projects[0]?.groupingIdentity); + assert.isNull(Option.getOrThrow(yield* service.getById(projectId)).repositoryIdentity); + }), + ); +}); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index ee7318fb5d5b7..226e16856a2a0 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -172,6 +172,7 @@ export const make = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + groupingIdentity: row.groupingIdentity, repositoryIdentity: enrichment?.repositoryIdentity ?? null, faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null, defaultModelSelection: row.defaultModelSelection, @@ -351,6 +352,9 @@ export const make = Effect.gen(function* () { projectId: input.projectId, title: input.title, workspaceRoot, + ...(input.groupingIdentity === undefined + ? {} + : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); yield* projectEnrichment.invalidate([workspaceRoot]); @@ -387,6 +391,9 @@ export const make = Effect.gen(function* () { ...(input.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.groupingIdentity === undefined + ? {} + : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.archived === undefined ? {} : { archived: input.archived }), diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index ddecdc40fafc1..0e51278758ad0 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -249,3 +249,19 @@ On mobile, use **Settings → Source control** to change selected environment de T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, or local commits. It skips checkouts on another branch or without an upstream. If a checkout has local work, resolve it yourself before automatic pulls can resume. + +## Group non-Git projects across computers + +Assign the same explicit grouping identity to copies of a non-Git project to show them as one project with a choice of computers. Each computer keeps its own directory, project record, and threads. Names and paths alone never establish this relationship. Git projects continue to group by repository identity; the Separate grouping setting keeps all copies apart. + +Generate a lowercase UUID v4 once (for example, `python -c "import uuid; print(uuid.uuid4())"`) and reuse it only for copies of that project: + +```sh +t3 project add /path/to/sdk --grouping-identity +t3 project grouping /path/to/sdk +t3 project grouping /path/to/sdk --identity +t3 project grouping /path/to/sdk --clear +t3 project list +``` + +With `--grouping-identity`, `project add` also updates an existing registration at that path, preserving its title and threads. Without the flag, adding an existing project still reports a conflict. `project list` returns JSON including the saved identity. Agents can use `t3_project_create` or `t3_project_update` with `groupingIdentity`; omit it to preserve the value or pass `null` to clear it. Read and list tools expose the identity. Update the servers and clients on all computers before assigning identities. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index e3cbecfc2b2c4..f695c5b91de16 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -47,6 +47,7 @@ export interface CreateProjectInput extends CommandMetadata { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } @@ -59,6 +60,7 @@ export interface UpdateProjectInput extends CommandMetadata { readonly projectIcon?: ProjectIconOverride | null; readonly faviconPath?: string | null; readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; readonly pinned?: boolean; readonly archived?: boolean; @@ -318,6 +320,7 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -327,6 +330,7 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -354,6 +358,7 @@ export const createProject = Effect.fn("EnvironmentCommands.createProject")(func ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.groupingIdentity === undefined ? {} : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); }); @@ -376,6 +381,7 @@ export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(func ...(input.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.groupingIdentity === undefined ? {} : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.archived === undefined ? {} : { archived: input.archived }), diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index a5957d06e1b01..6e7dbfdb994f9 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -421,3 +421,82 @@ describe("sortProjectsByGroupOrder", () => { expect(sorted.map((entry) => entry.environmentId)).toEqual([remote, environmentId]); }); }); + +describe("explicit non-Git grouping", () => { + const sdk = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const spacetime = "e703c118-3547-41b1-88c5-9774cf40ae86"; + const copies = ["windows", "linux", "mac"].flatMap((machine) => + [sdk, spacetime].map((groupingIdentity, index) => + makeProject(`${machine}-${index}`, `/${machine}/${index}`, { + environmentId: EnvironmentId.make(machine), + repositoryIdentity: null, + groupingIdentity, + title: index === 0 ? "sdk" : "spacetimedb", + }), + ), + ); + it.each(["repository", "repository_path"] as const)( + "preserves all physical refs across three machines in %s mode", + (mode) => { + const groups = buildProjectGroups({ projects: copies, settings: settings(mode) }); + expect(groups).toHaveLength(2); + for (const group of groups) { + expect(group.members).toHaveLength(3); + expect(group.memberProjectRefs).toHaveLength(3); + expect(new Set(group.members.map(({ project }) => project.environmentId)).size).toBe(3); + } + }, + ); + it("honors separate mode, per-project overrides, and clearing", () => { + expect(buildProjectGroups({ projects: copies, settings: settings("separate") })).toHaveLength( + 6, + ); + expect( + buildProjectGroups({ + projects: copies, + settings: settings("repository", { [derivePhysicalProjectKey(copies[0]!)]: "separate" }), + }), + ).toHaveLength(3); + expect( + buildProjectGroups({ + projects: copies.map((p, i) => (i === 0 ? { ...p, groupingIdentity: null } : p)), + settings: settings("repository"), + }), + ).toHaveLength(3); + }); + it("never groups by name and keeps Git identity authoritative", () => { + const sameNames = copies.map((p) => ({ ...p, groupingIdentity: null, title: "sdk" })); + expect( + buildProjectGroups({ projects: sameNames, settings: settings("repository") }), + ).toHaveLength(6); + const git = copies.map((p) => ({ ...p, repositoryIdentity })); + expect(buildProjectGroups({ projects: git, settings: settings("repository") })).toHaveLength(1); + }); + it("keeps known Git identity authoritative while the newest duplicate is still enriching", () => { + const stale = { ...copies[0]!, repositoryIdentity }; + const fresh = { + ...copies[0]!, + id: ProjectId.make("fresh-git"), + updatedAt: "2026-10-20T00:00:00.000Z", + }; + const remote = { ...copies[2]!, repositoryIdentity }; + expect( + buildProjectGroups({ projects: [stale, fresh, remote], settings: settings("repository") }), + ).toHaveLength(1); + }); + it("does not resurrect a cleared identity from an older duplicate registration", () => { + const first = copies[0]!; + const fresh = { + ...first, + id: ProjectId.make("fresh"), + groupingIdentity: null, + updatedAt: "2026-10-20T00:00:00.000Z", + }; + const groups = buildProjectGroups({ + projects: [first, fresh, copies[2]!], + settings: settings("repository"), + }); + expect(groups).toHaveLength(2); + expect(groups.flatMap((g) => g.memberProjectRefs)).toHaveLength(3); + }); +}); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 46fdc4e3dd3ae..8049d6aaf419d 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -129,7 +129,12 @@ function deriveRepositoryScopedKey( function deriveLogicalProjectKey( project: Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + | "environmentId" + | "id" + | "workspaceRoot" + | "repositoryIdentity" + | "isScratch" + | "groupingIdentity" >, options?: { readonly groupingMode?: SidebarProjectGroupingMode; @@ -144,6 +149,7 @@ function deriveLogicalProjectKey( return ( deriveRepositoryScopedKey(project, groupingMode) ?? + (project.groupingIdentity ? `explicit:${project.groupingIdentity}` : null) ?? derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); @@ -152,7 +158,12 @@ function deriveLogicalProjectKey( export function deriveLogicalProjectKeyFromSettings( project: Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + | "environmentId" + | "id" + | "workspaceRoot" + | "repositoryIdentity" + | "isScratch" + | "groupingIdentity" >, settings: ProjectGroupingSettings, options?: { readonly groupScratchProjects?: boolean }, @@ -168,7 +179,12 @@ export function resolveScratchProjectScopeKey( projects: ReadonlyArray< Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + | "environmentId" + | "id" + | "workspaceRoot" + | "repositoryIdentity" + | "isScratch" + | "groupingIdentity" > >, settings: ProjectGroupingSettings = { @@ -276,13 +292,13 @@ function selectProjectIdentitySource( projects: ReadonlyArray, winner: TProject, ): TProject { - if (winner.repositoryIdentity !== null) { + if (winner.repositoryIdentity != null) { return winner; } let freshestIdentifiedProject: TProject | null = null; for (const project of projects) { - if (project.repositoryIdentity === null) { + if (project.repositoryIdentity == null) { continue; } if ( @@ -319,10 +335,14 @@ export function buildProjectGroups(input: { shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current, ); const identitySource = selectProjectIdentitySource(physicalProjects, winner); - const logicalKey = deriveLogicalProjectKey(identitySource, { - groupingMode: resolveProjectGroupingMode(winner, input.settings), - groupScratchProjects: input.groupScratchProjects ?? false, - }); + // Saved metadata (including a clear) always comes from the newest physical record. + const logicalKey = deriveLogicalProjectKey( + { ...winner, repositoryIdentity: identitySource.repositoryIdentity }, + { + groupingMode: resolveProjectGroupingMode(winner, input.settings), + groupScratchProjects: input.groupScratchProjects ?? false, + }, + ); logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); const member = { physicalProjectKey, project: winner }; const existing = groupedMembers.get(logicalKey); diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 3fbd9b1b8d3af..97374519695d2 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -421,3 +421,23 @@ describe("applyShellStreamEvents", () => { ); }); }); + +describe("explicit project grouping metadata synchronization", () => { + it("applies clearing immediately and does not restore metadata from an enrichment refresh", () => { + const identified = { ...v2Project, groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52" }; + const snapshot = { ...v2ShellSnapshot, projects: [identified] }; + const cleared = applyShellStreamEvent(snapshot, { + kind: "project.updated", + sequence: 1, + project: { ...identified, groupingIdentity: null }, + }); + expect(cleared.projects[0]?.groupingIdentity).toBeNull(); + const enriched = mergeShellSnapshotProjects(cleared, snapshot, { + resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot], + }); + expect(enriched.projects[0]?.groupingIdentity).toBeNull(); + expect(enriched.threads).toBe(snapshot.threads); + const refreshed = mergeShellSnapshotProjects(snapshot, cleared); + expect(refreshed.projects[0]?.groupingIdentity).toBeNull(); + }); +}); diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 30150b9af5247..fc6195c69c9f7 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -14,7 +14,7 @@ import { import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; -import { ProjectScript, StoredProjectIcon } from "./project.ts"; +import { ProjectGroupingIdentity, ProjectScript, StoredProjectIcon } from "./project.ts"; export const OrchestrationClientOrigin = Schema.Struct({ surface: Schema.optional(ClientSurface), @@ -37,6 +37,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), @@ -52,6 +53,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), // Absent = leave unchanged; null = clear the override. diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index d98c50e56e8d1..57ce444607700 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -3,12 +3,13 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; -import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; +import { ProjectGroupingIdentity, ProjectScript, ReceivedProjectIcon } from "./project.ts"; export const OrchestrationProjectShell = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 168886ebe441a..9e05ac2880bc1 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -177,3 +177,20 @@ effectIt.effect("an icon kind from a newer server shows the default icon", () => assert.strictEqual(broken._tag, "Failure"); }), ); + +describe("explicit grouping identity contracts", () => { + it("accepts an explicit UUID, distinguishes clear from omission, and rejects names", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + expect( + decodeProjectCreatePayload({ title: "sdk", workspaceRoot: "/sdk", groupingIdentity }) + .groupingIdentity, + ).toBe(groupingIdentity); + expect(decodeProjectUpdatePayload({ groupingIdentity: null })).toEqual({ + groupingIdentity: null, + }); + expect(decodeProjectUpdatePayload({})).not.toHaveProperty("groupingIdentity"); + for (const value of ["sdk", "group/sdk", "", "39c61a1b-8a64-13a6-9da5-65c15da93d52"]) { + expect(() => decodeProjectUpdatePayload({ groupingIdentity: value })).toThrow(); + } + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 39696c62e4c99..bbe1eb9d37cb5 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -149,10 +149,16 @@ export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "k ), ); +/** Explicit cross-environment identity. Allocate once and share only with copies of this project. */ +export const ProjectGroupingIdentity = TrimmedNonEmptyString.check( + Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), +); + export const Project = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), @@ -176,6 +182,7 @@ export const ProjectSnapshot = Schema.Struct({ export type ProjectSnapshot = typeof ProjectSnapshot.Type; export const ProjectCreatePayload = Schema.Struct({ + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), @@ -185,6 +192,7 @@ export const ProjectCreatePayload = Schema.Struct({ export type ProjectCreatePayload = typeof ProjectCreatePayload.Type; export const ProjectUpdatePayload = Schema.Struct({ + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), From 184479438167d45a8181457befae486522b88a64 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 22 Oct 2026 03:08:54 -0700 Subject: [PATCH 02/21] fix(projects): preserve scratch selections and queued task hosts --- .../threads/new-task-flow-provider.tsx | 57 ++++------------- .../new-task-project-selection.test.ts | 63 +++++++++++++++++++ .../threads/new-task-project-selection.ts | 29 +++++++++ .../src/state/projectGrouping.test.ts | 26 ++++++++ .../src/state/projectGrouping.ts | 3 +- 5 files changed, 130 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index bbee484dc87c5..ceffd3bf4ea1c 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,7 +1,6 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { useNowMinuteMs } from "../../lib/clock"; -import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -93,7 +92,10 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; -import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { + resolveEnvironmentProjectMatch, + resolveNewTaskEnvironments, +} from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveNewThreadRuntimeMode } from "./thread-settings-options"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -800,12 +802,6 @@ export function NewTaskFlowProvider( ) !== null, ); - const selectedRepositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. - const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const selectedProjectTitle = selectedProject?.title ?? null; const environments = (() => { if (isScratchDraft) { return scratchEnvironments.map((environment) => ({ @@ -813,44 +809,13 @@ export function NewTaskFlowProvider( environmentLabel: environment.environmentLabel, })); } - const seen = new Set(); - const result: Array<{ - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - }> = []; - const hostsSelectedRepository = (project: EnvironmentProject) => { - if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { - return true; - } - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - if (selectedRepositoryKey !== null && projectKey !== null) { - return projectKey === selectedRepositoryKey; - } - return ( - project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || - (selectedProjectTitle !== null && project.title === selectedProjectTitle) - ); - }; - for (const project of projects) { - if (!hostsSelectedRepository(project)) { - continue; - } - if (seen.has(project.environmentId)) { - continue; - } - const environment = savedConnectionsById[project.environmentId]; - if (!environment) { - continue; - } - seen.add(project.environmentId); - result.push({ - environmentId: project.environmentId, - environmentLabel: environment.environmentLabel, - }); - } - return result; + return resolveNewTaskEnvironments({ + projects, + selectedProject, + settings: groupingSettings, + savedConnectionsById, + pendingTaskEnvironmentId: editingPendingTask?.environmentId ?? null, + }); })(); const value = { diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index e779aa8fab1ca..4990b13a643f7 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -9,6 +9,7 @@ import { resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, + resolveNewTaskEnvironments, } from "./new-task-project-selection"; function makeProject( @@ -116,6 +117,68 @@ describe("resolveEnvironmentProjectMatch", () => { }); }); +describe("resolveNewTaskEnvironments", () => { + const settings = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, + }; + const savedConnectionsById = { + mac: { environmentLabel: "Mac" }, + server: { environmentLabel: "Server" }, + }; + + it("retains the queued task's machine and label while its project shell is absent", () => { + const missingProject = makeProject("removed", "mac"); + expect( + resolveNewTaskEnvironments({ + projects: [makeProject("unrelated", "mac"), makeProject("other", "server")], + selectedProject: missingProject, + settings, + savedConnectionsById, + pendingTaskEnvironmentId: missingProject.environmentId, + }), + ).toEqual([{ environmentId: "mac", environmentLabel: "Mac" }]); + }); + + it("lists only registered hosts of the explicit group and deduplicates the task's host", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const selectedProject = { ...makeProject("sdk", "mac"), groupingIdentity }; + const input = { + projects: [ + selectedProject, + { ...makeProject("copy", "server"), groupingIdentity }, + { ...makeProject("copy", "unsaved"), groupingIdentity }, + ], + selectedProject, + settings, + savedConnectionsById, + pendingTaskEnvironmentId: selectedProject.environmentId, + }; + expect(resolveNewTaskEnvironments(input)).toEqual([ + { environmentId: "mac", environmentLabel: "Mac" }, + { environmentId: "server", environmentLabel: "Server" }, + ]); + expect( + resolveNewTaskEnvironments({ + ...input, + settings: { ...settings, sidebarProjectGroupingMode: "separate" }, + }), + ).toEqual([{ environmentId: "mac", environmentLabel: "Mac" }]); + }); + + it("does not offer unrelated machines for an ordinary draft with a missing project", () => { + expect( + resolveNewTaskEnvironments({ + projects: [makeProject("other", "server")], + selectedProject: makeProject("missing", "mac"), + settings, + savedConnectionsById, + pendingTaskEnvironmentId: null, + }), + ).toEqual([]); + }); +}); + describe("resolveDraftProjectSelection", () => { const projects = [makeProject("t3code"), makeProject("t3code-2")]; const draft = { incomingShareId: undefined, pickerClosed: false }; diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index faa3e48d2423c..9ff0814fb6e7e 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -97,6 +97,35 @@ export function resolveEnvironmentProjectMatch( ); } +/** Keep a queued task's host available even before its project shell loads. */ +export function resolveNewTaskEnvironments(input: { + readonly projects: ReadonlyArray; + readonly selectedProject: EnvironmentProject | null; + readonly settings: ProjectGroupingSettings; + readonly savedConnectionsById: Readonly< + Record + >; + readonly pendingTaskEnvironmentId: EnvironmentId | null; +}): Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string }> { + const selectedKey = input.selectedProject + ? deriveLogicalProjectKeyFromSettings(input.selectedProject, input.settings) + : null; + const candidates = input.projects + .filter( + (project) => + selectedKey === null || + deriveLogicalProjectKeyFromSettings(project, input.settings) === selectedKey, + ) + .map((project) => project.environmentId); + if (input.pendingTaskEnvironmentId !== null) { + candidates.unshift(input.pendingTaskEnvironmentId); + } + return [...new Set(candidates)].flatMap((environmentId) => { + const environment = input.savedConnectionsById[environmentId]; + return environment ? [{ environmentId, environmentLabel: environment.environmentLabel }] : []; + }); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 6e7dbfdb994f9..1f2f06fcdedf2 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -191,6 +191,32 @@ describe("buildProjectGroups", () => { expect(resolveScratchProjectScopeKey(key, [scratch, ordinary])).toBe(key); }); + it.each(["repository", "repository_path", "separate"] as const)( + "migrates an explicitly grouped scratch selection in %s mode without stealing an ordinary group", + (mode) => { + const project = makeProject("sdk", "/sdk", { + repositoryIdentity: null, + groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52", + }); + const groupingSettings = settings(mode); + const key = deriveLogicalProjectKeyFromSettings(project, groupingSettings); + const scratch = { ...project, isScratch: true as const }; + expect(resolveScratchProjectScopeKey(key, [scratch], groupingSettings)).toBe("\0no-project"); + const ordinary = { ...project, workspaceRoot: "/ordinary" }; + if (mode !== "separate") { + expect(resolveScratchProjectScopeKey(key, [scratch, ordinary], groupingSettings)).toBe(key); + } else { + expect( + resolveScratchProjectScopeKey( + `explicit:${project.groupingIdentity}`, + [scratch], + groupingSettings, + ), + ).toBe(`explicit:${project.groupingIdentity}`); + } + }, + ); + it.each(["repository", "repository_path", "separate"] as const)( "coalesces scratch homes in %s mode without merging ordinary same-title projects", (mode) => { diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 8049d6aaf419d..b3e47508b46b5 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -204,10 +204,9 @@ export function resolveScratchProjectScopeKey( } return projects.some((project) => { if (!project.isScratch) return false; - const mode = resolveProjectGroupingMode(project, settings); return ( derivePhysicalProjectKey(project) === key || - (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key) + deriveLogicalProjectKeyFromSettings(project, settings) === key ); }) ? SCRATCH_PROJECT_SCOPE_KEY From 9f73168987a352a5f9b763436f1668deea06ed6f Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 22 Oct 2026 03:15:02 -0700 Subject: [PATCH 03/21] fix(cli): validate project grouping identities before execution --- apps/server/src/cli/project.test.ts | 38 ++++++ apps/server/src/cli/project.ts | 185 ++++++++++++++++------------ 2 files changed, 146 insertions(+), 77 deletions(-) diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index c162848592247..c1e9d06ef279a 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -517,7 +517,45 @@ it.effect( baseDir, ]).pipe(Effect.result); assert.equal(invalid._tag, "Failure"); + if (invalid._tag === "Failure") { + assert.equal(invalid.failure.message, "Grouping identity must be a lowercase UUID v4."); + } assert.isNull((yield* readProjects(baseDir)).projects[0]?.groupingIdentity); assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("rejects malformed grouping flags before opening project state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-invalid-grouping-cli-" }); + const baseDir = NodePath.join(root, "unopened-state"); + for (const command of [ + ["add", root, "--grouping-identity", "group/sdk"], + ["grouping", root, "--identity", "group/sdk"], + ]) { + const error = yield* runCli(["project", ...command, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(error.message, "Grouping identity must be a lowercase UUID v4."); + assert.isFalse(yield* fs.exists(baseDir)); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("malformed grouping flags cannot create or change project registrations", () => + Effect.gen(function* () { + const { baseDir, workspaceRoot } = yield* makeProjectLookupFixture(); + const before = yield* readProjects(baseDir); + const fs = yield* FileSystem.FileSystem; + const newRoot = `${workspaceRoot}-new`; + yield* fs.makeDirectory(newRoot); + for (const command of [ + ["add", newRoot, "--grouping-identity", "group/sdk"], + ["add", workspaceRoot, "--grouping-identity", "group/sdk"], + ["grouping", workspaceRoot, "--identity", "group/sdk"], + ]) { + const error = yield* runCli(["project", ...command, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(error.message, "Grouping identity must be a lowercase UUID v4."); + assert.deepEqual(yield* readProjects(baseDir), before); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index dec38d8c76575..50ff1dcd6e9c5 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -57,6 +57,26 @@ type ProjectCliDispatchCommand = ProjectMutation; const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); +class ProjectGroupingIdentityInvalidError extends Schema.TaggedError()( + "ProjectGroupingIdentityInvalidError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Grouping identity must be a lowercase UUID v4."; + } +} + +class ProjectGroupingFlagsError extends Schema.TaggedError()( + "ProjectGroupingFlagsError", + {}, +) { + override get message(): string { + return "Use either --identity or --clear."; + } +} + +const decodeGroupingIdentity = Schema.decodeUnknownEffect(ProjectGroupingIdentity); + export class ProjectCommandIdGenerationError extends Schema.TaggedError()( "ProjectCommandIdGenerationError", { @@ -475,56 +495,67 @@ const projectAddCommand = Command.make("add", { }).pipe( Command.withDescription("Add a project."), Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectAddMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: ProjectSnapshot; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); - const existingProject = snapshot.projects.find( - (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, - ); - if (existingProject && Option.isSome(flags.groupingIdentity)) { - if (existingProject.groupingIdentity === flags.groupingIdentity.value) - return `Project ${existingProject.id} already has this grouping identity.`; + Effect.gen(function* () { + const groupingIdentity = Option.isSome(flags.groupingIdentity) + ? yield* decodeGroupingIdentity(flags.groupingIdentity.value).pipe( + Effect.mapError((cause) => new ProjectGroupingIdentityInvalidError({ cause })), + ) + : undefined; + return yield* runProjectMutation( + flags, + Effect.fn("projectAddMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: ProjectSnapshot; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect< + void, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + >; + }) { + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const existingProject = snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, + ); + if (existingProject && groupingIdentity !== undefined) { + if (existingProject.groupingIdentity === groupingIdentity) + return `Project ${existingProject.id} already has this grouping identity.`; + yield* dispatch({ + type: "project.update", + commandId: CommandId.make(yield* projectCommandUuid), + projectId: existingProject.id, + groupingIdentity, + }); + return `Updated grouping identity for project ${existingProject.id}.`; + } + if (existingProject) { + return yield* new ProjectAlreadyExistsError({ + operation: "addProject", + projectId: existingProject.id, + workspaceRoot, + }); + } + + const title = yield* resolveProjectTitle( + workspaceRoot, + Option.getOrUndefined(flags.title), + ); + const projectId = ProjectId.make(yield* projectCommandUuid); yield* dispatch({ - type: "project.update", + type: "project.create", commandId: CommandId.make(yield* projectCommandUuid), - projectId: existingProject.id, - groupingIdentity: flags.groupingIdentity.value, - }); - return `Updated grouping identity for project ${existingProject.id}.`; - } - if (existingProject) { - return yield* new ProjectAlreadyExistsError({ - operation: "addProject", - projectId: existingProject.id, + projectId, + title, workspaceRoot, + ...(groupingIdentity === undefined ? {} : { groupingIdentity }), }); - } - - const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); - const projectId = ProjectId.make(yield* projectCommandUuid); - yield* dispatch({ - type: "project.create", - commandId: CommandId.make(yield* projectCommandUuid), - projectId, - title, - workspaceRoot, - ...Option.match(flags.groupingIdentity, { - onNone: () => ({}), - onSome: (groupingIdentity) => ({ groupingIdentity }), - }), - }); - return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; - }), - ), + return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; + }), + ); + }), ), ); @@ -638,42 +669,42 @@ const projectGroupingCommand = Command.make("grouping", { }).pipe( Command.withDescription("Show, set, or clear a project's explicit grouping identity."), Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectGroupingMutation")(function* ({ snapshot, dispatch }) { - const project = yield* findActiveProjectTarget({ snapshot, identifier: flags.project }); - if (flags.clear && Option.isSome(flags.identity)) { - return yield* new ProjectGroupingFlagsError({}); - } - if (!flags.clear && Option.isNone(flags.identity)) { - return yield* encodeGroupingResult({ + Effect.gen(function* () { + if (flags.clear && Option.isSome(flags.identity)) { + return yield* new ProjectGroupingFlagsError({}); + } + const groupingIdentity = flags.clear + ? null + : Option.isSome(flags.identity) + ? yield* decodeGroupingIdentity(flags.identity.value).pipe( + Effect.mapError((cause) => new ProjectGroupingIdentityInvalidError({ cause })), + ) + : undefined; + return yield* runProjectMutation( + flags, + Effect.fn("projectGroupingMutation")(function* ({ snapshot, dispatch }) { + const project = yield* findActiveProjectTarget({ snapshot, identifier: flags.project }); + if (groupingIdentity === undefined) { + return yield* encodeGroupingResult({ + projectId: project.id, + groupingIdentity: + snapshot.projects.find((entry) => entry.id === project.id)?.groupingIdentity ?? + null, + }); + } + yield* dispatch({ + type: "project.update", + commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, - groupingIdentity: - snapshot.projects.find((entry) => entry.id === project.id)?.groupingIdentity ?? null, + groupingIdentity, }); - } - const groupingIdentity = flags.clear ? null : Option.getOrThrow(flags.identity); - yield* dispatch({ - type: "project.update", - commandId: CommandId.make(yield* projectCommandUuid), - projectId: project.id, - groupingIdentity, - }); - return yield* encodeGroupingResult({ projectId: project.id, groupingIdentity }); - }), - ), + return yield* encodeGroupingResult({ projectId: project.id, groupingIdentity }); + }), + ); + }), ), ); -class ProjectGroupingFlagsError extends Schema.TaggedError()( - "ProjectGroupingFlagsError", - {}, -) { - override get message(): string { - return "Use either --identity or --clear."; - } -} - export const projectCommand = Command.make("project").pipe( Command.withDescription("Manage projects."), Command.withSubcommands([ From 4132949855bfb39e058f82e4bfc848d4b7378ef1 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 22 Oct 2026 03:25:17 -0700 Subject: [PATCH 04/21] fix(mobile): preserve queued project when selecting an unloaded host --- .../threads/new-task-flow-provider.tsx | 23 +++++---- .../new-task-project-selection.test.ts | 49 +++++++++++++++++++ .../threads/new-task-project-selection.ts | 16 ++++++ 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ceffd3bf4ea1c..66230d7ddbe11 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -93,7 +93,7 @@ import { resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; import { - resolveEnvironmentProjectMatch, + resolveNewTaskEnvironmentProject, resolveNewTaskEnvironments, } from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; @@ -588,16 +588,20 @@ export function NewTaskFlowProvider( }; const selectEnvironment = (environmentId: EnvironmentId) => { - const match = resolveEnvironmentProjectMatch( - projects.filter((project) => project.environmentId === environmentId), + const match = resolveNewTaskEnvironmentProject({ + environmentId, + projects, selectedProject, - groupingSettings, - ); + pendingProject: null, + settings: groupingSettings, + }); + if (!match) return false; updateFlow({ - draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, + draftKey: carryDraftContentTo(match), environmentId, - projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, + projectKey: scopedProjectKey(match.environmentId, match.id), }); + return true; }; const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); @@ -612,8 +616,7 @@ export function NewTaskFlowProvider( return true; } if (!isScratchDraft) { - selectEnvironment(environmentId); - return true; + return selectEnvironment(environmentId); } const request = {}; latestSwitchRef.current = request; @@ -814,7 +817,7 @@ export function NewTaskFlowProvider( selectedProject, settings: groupingSettings, savedConnectionsById, - pendingTaskEnvironmentId: editingPendingTask?.environmentId ?? null, + pendingTaskEnvironmentId: null, }); })(); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 4990b13a643f7..d1e63bfbdbdbb 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -9,6 +9,7 @@ import { resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, + resolveNewTaskEnvironmentProject, resolveNewTaskEnvironments, } from "./new-task-project-selection"; @@ -117,6 +118,54 @@ describe("resolveEnvironmentProjectMatch", () => { }); }); +describe("resolveNewTaskEnvironmentProject", () => { + const settings = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, + }; + + it("preserves the queued project's identity when its host has no loaded projects", () => { + const pendingProject = makeProject("queued-project", "offline"); + const unrelated = makeProject("unrelated", "server"); + const input = { + environmentId: pendingProject.environmentId, + projects: [unrelated], + selectedProject: pendingProject, + pendingProject, + settings, + }; + expect(resolveNewTaskEnvironmentProject(input)).toBe(pendingProject); + // Returning to the task's own host must also preserve its stamped project. + expect(resolveNewTaskEnvironmentProject({ ...input, selectedProject: unrelated })).toBe( + pendingProject, + ); + expect( + resolveNewTaskEnvironmentProject({ ...input, environmentId: unrelated.environmentId }), + ).toBeNull(); + }); + + it("switches within the explicit group without reusing the queued project on another host", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const pendingProject = { ...makeProject("sdk", "mac"), groupingIdentity }; + const copy = { ...makeProject("sdk-copy", "server"), groupingIdentity }; + const input = { + environmentId: copy.environmentId, + projects: [pendingProject, copy], + selectedProject: pendingProject, + pendingProject, + settings, + }; + expect(resolveNewTaskEnvironmentProject(input)).toBe(copy); + expect(resolveNewTaskEnvironmentProject({ ...input, projects: [] })).toBeNull(); + expect( + resolveNewTaskEnvironmentProject({ + ...input, + settings: { ...settings, sidebarProjectGroupingMode: "separate" }, + }), + ).toBeNull(); + }); +}); + describe("resolveNewTaskEnvironments", () => { const settings = { sidebarProjectGroupingMode: "repository" as const, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 9ff0814fb6e7e..958c218bee16b 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -97,6 +97,22 @@ export function resolveEnvironmentProjectMatch( ); } +export function resolveNewTaskEnvironmentProject(input: { + readonly environmentId: EnvironmentId; + readonly projects: ReadonlyArray; + readonly selectedProject: EnvironmentProject | null; + readonly pendingProject: EnvironmentProject | null; + readonly settings: ProjectGroupingSettings; +}): EnvironmentProject | null { + return ( + resolveEnvironmentProjectMatch( + input.projects.filter((project) => project.environmentId === input.environmentId), + input.selectedProject, + input.settings, + ) ?? (input.pendingProject?.environmentId === input.environmentId ? input.pendingProject : null) + ); +} + /** Keep a queued task's host available even before its project shell loads. */ export function resolveNewTaskEnvironments(input: { readonly projects: ReadonlyArray; From 8b151a0f8767e408033717b7f91745da5da7d7e0 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 22 Oct 2026 16:43:04 -0700 Subject: [PATCH 05/21] test(server): include grouping in preview migration upgrades --- apps/server/src/persistence/reconcileV2PreviewMigration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 08c5094bf49c8..06baceb2abd33 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -56,6 +56,7 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "ProjectGroupingIdentity"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -155,6 +156,7 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "ProjectGroupingIdentity"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 3551db663251a63feaa1c6970080387902e4de61 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Sat, 24 Oct 2026 04:40:33 -0700 Subject: [PATCH 06/21] fix(projects): route environments through canonical project groups --- .../threads/new-task-flow-provider.tsx | 3 ++ .../new-task-project-selection.test.ts | 35 +++++++++++++++++++ .../threads/new-task-project-selection.ts | 29 ++++++++------- apps/mobile/src/state/thread-outbox-model.ts | 3 ++ apps/mobile/src/state/thread-outbox.test.ts | 17 +++++++++ .../hooks/useLogicalProjectEnvironments.ts | 7 ++-- .../src/state/projectGrouping.test.ts | 11 ++++++ .../src/state/projectGrouping.ts | 16 +++++++++ 8 files changed, 102 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 66230d7ddbe11..6145791ee61d1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -778,6 +778,9 @@ export function NewTaskFlowProvider( projectId: selectedProject.id, projectTitle: selectedProject.title, projectCwd: selectedProject.workspaceRoot, + ...(selectedProject.groupingIdentity !== undefined + ? { groupingIdentity: selectedProject.groupingIdentity } + : {}), workspaceMode: mode, ...(mode === "worktree" && workspaceSelection?.pullRequestReference ? { pullRequestReference: workspaceSelection.pullRequestReference } diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index d1e63bfbdbdbb..d10d5aefd5254 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -124,6 +124,41 @@ describe("resolveNewTaskEnvironmentProject", () => { sidebarProjectGroupingOverrides: {}, }; + it("routes duplicate physical records using the same inherited Git identity as project grouping", () => { + const older = makeProject("older", "mac", { + workspaceRoot: "/sdk", + repositoryKey: "github.com/g/sdk", + }); + const selectedProject = { + ...makeProject("newer", "mac", { workspaceRoot: "/sdk" }), + groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52", + updatedAt: "2026-07-02T00:00:00.000Z", + }; + const remote = makeProject("copy", "server", { repositoryKey: "github.com/g/sdk" }); + const projects = [selectedProject, older, remote]; + expect( + resolveNewTaskEnvironmentProject({ + environmentId: remote.environmentId, + projects, + selectedProject, + pendingProject: null, + settings, + }), + ).toBe(remote); + expect( + resolveNewTaskEnvironments({ + projects, + selectedProject, + settings, + pendingTaskEnvironmentId: null, + savedConnectionsById: { + mac: { environmentLabel: "Mac" }, + server: { environmentLabel: "Server" }, + }, + }).map((environment) => environment.environmentId), + ).toEqual(["mac", "server"]); + }); + it("preserves the queued project's identity when its host has no loaded projects", () => { const pendingProject = makeProject("queued-project", "offline"); const unrelated = makeProject("unrelated", "server"); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 958c218bee16b..95ae73e6ed8fd 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,7 +1,8 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { - deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + resolveProjectGroup, type ProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; @@ -87,13 +88,14 @@ export function resolveEnvironmentProjectMatch( sidebarProjectGroupingMode: "repository", sidebarProjectGroupingOverrides: {}, }, + allProjects: ReadonlyArray = projectsOnTarget, ): EnvironmentProject | null { if (selectedProject === null) return projectsOnTarget[0] ?? null; - const key = deriveLogicalProjectKeyFromSettings(selectedProject, settings); + const targetKeys = new Set(projectsOnTarget.map(derivePhysicalProjectKey)); return ( - projectsOnTarget.find( - (project) => deriveLogicalProjectKeyFromSettings(project, settings) === key, - ) ?? null + resolveProjectGroup(selectedProject, allProjects, settings)?.members.find((member) => + targetKeys.has(member.physicalProjectKey), + )?.project ?? null ); } @@ -109,6 +111,7 @@ export function resolveNewTaskEnvironmentProject(input: { input.projects.filter((project) => project.environmentId === input.environmentId), input.selectedProject, input.settings, + input.projects, ) ?? (input.pendingProject?.environmentId === input.environmentId ? input.pendingProject : null) ); } @@ -123,16 +126,12 @@ export function resolveNewTaskEnvironments(input: { >; readonly pendingTaskEnvironmentId: EnvironmentId | null; }): Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string }> { - const selectedKey = input.selectedProject - ? deriveLogicalProjectKeyFromSettings(input.selectedProject, input.settings) - : null; - const candidates = input.projects - .filter( - (project) => - selectedKey === null || - deriveLogicalProjectKeyFromSettings(project, input.settings) === selectedKey, - ) - .map((project) => project.environmentId); + const matchingProjects = input.selectedProject + ? (resolveProjectGroup(input.selectedProject, input.projects, input.settings)?.members.map( + (member) => member.project, + ) ?? []) + : input.projects; + const candidates = matchingProjects.map((project) => project.environmentId); if (input.pendingTaskEnvironmentId !== null) { candidates.unshift(input.pendingTaskEnvironmentId); } diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 6c5da0db20991..eda85b551a081 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -13,6 +13,7 @@ import { ModelSelection, OrchestrationMessageContext, ProjectId, + ProjectGroupingIdentity, ProviderInteractionMode, RuntimeMode, ThreadId, @@ -38,6 +39,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ projectId: ProjectId, projectTitle: Schema.optional(Schema.String), projectCwd: Schema.optional(Schema.String), + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), workspaceMode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), @@ -69,6 +71,7 @@ export interface QueuedThreadCreation { readonly projectId: ProjectIdType; readonly projectTitle?: string; readonly projectCwd?: string; + readonly groupingIdentity?: string | null; readonly workspaceMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 8aaf94ffc0df6..f7e41a8fe3e75 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1187,6 +1187,23 @@ describe("thread outbox", () => { ).toBe("remove"); }); + it.each([null, "39c61a1b-8a64-43a6-9da5-65c15da93d52"])( + "round-trips queued project grouping identity %s", + (groupingIdentity) => { + const message = { + ...queuedMessage({ messageId: "grouped", createdAt: "2026-06-08T10:00:01.000Z" }), + creation: { + projectId: ProjectId.make("sdk"), + groupingIdentity, + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }, + }; + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); + }, + ); + it("round-trips queued PR checkout sources and gates incomplete creations from sending", () => { const base = queuedMessage({ messageId: "message-1", diff --git a/apps/web/src/hooks/useLogicalProjectEnvironments.ts b/apps/web/src/hooks/useLogicalProjectEnvironments.ts index ebf5fe7a4d2fe..69672a7d538e5 100644 --- a/apps/web/src/hooks/useLogicalProjectEnvironments.ts +++ b/apps/web/src/hooks/useLogicalProjectEnvironments.ts @@ -9,7 +9,7 @@ import { import type { EnvironmentOption } from "../components/BranchToolbar.logic"; import { - deriveLogicalProjectKeyFromSettings, + resolveProjectGroup, selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { useProjects } from "../state/entities"; @@ -58,11 +58,10 @@ export function useLogicalProjectEnvironments( pushEnvironment(environment.environmentId, scratchProject?.id ?? null); } } else { - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const group = resolveProjectGroup(activeProject, allProjects, projectGroupingSettings); const seen = new Set(); - for (const p of allProjects) { + for (const { project: p } of group?.members ?? []) { if (seen.has(p.environmentId)) continue; - if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) continue; seen.add(p.environmentId); pushEnvironment(p.environmentId, p.id); } diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 1f2f06fcdedf2..482add12fc1e8 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -9,6 +9,7 @@ import { pickableProjectGroups, derivePhysicalProjectKey, resolveScratchProjectScopeKey, + resolveProjectGroup, sortProjectsByGroupOrder, type ProjectGroupingSettings, } from "./projectGrouping.ts"; @@ -509,6 +510,11 @@ describe("explicit non-Git grouping", () => { expect( buildProjectGroups({ projects: [stale, fresh, remote], settings: settings("repository") }), ).toHaveLength(1); + expect( + resolveProjectGroup(fresh, [stale, fresh, remote], settings("repository"))?.members.map( + (member) => member.project, + ), + ).toEqual([fresh, remote]); }); it("does not resurrect a cleared identity from an older duplicate registration", () => { const first = copies[0]!; @@ -524,5 +530,10 @@ describe("explicit non-Git grouping", () => { }); expect(groups).toHaveLength(2); expect(groups.flatMap((g) => g.memberProjectRefs)).toHaveLength(3); + expect( + resolveProjectGroup(first, [first, fresh, copies[2]!], settings("repository"))?.members.map( + (member) => member.project, + ), + ).toEqual([fresh]); }); }); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index b3e47508b46b5..18ac74a649a8b 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -399,6 +399,22 @@ export function buildProjectGroups(input: { }); } +/** Physical duplicates must resolve through the group's winning saved metadata. */ +export function resolveProjectGroup( + project: EnvironmentProject, + projects: ReadonlyArray, + settings: ProjectGroupingSettings, +): ProjectGroup | undefined { + const groups = buildProjectGroups({ projects, settings }); + const physicalKey = derivePhysicalProjectKey(project); + return ( + groups.find((group) => + group.members.some((member) => member.physicalProjectKey === physicalKey), + ) ?? + groups.find((group) => group.key === deriveLogicalProjectKeyFromSettings(project, settings)) + ); +} + export function sortProjectsByGroupOrder< TProject extends { readonly id: string; readonly environmentId: string }, >( From 4f2f51693ac752cca5b45d805523af36ce700cf0 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Mon, 26 Oct 2026 17:21:09 -0700 Subject: [PATCH 07/21] fix(mobile): align project grouping with the current draft flow --- .../threads/new-task-flow-provider.tsx | 17 ++--- .../new-task-project-selection.test.ts | 74 ++----------------- .../threads/new-task-project-selection.ts | 23 ------ apps/mobile/src/state/thread-outbox-model.ts | 3 - apps/mobile/src/state/thread-outbox.test.ts | 17 ----- 5 files changed, 11 insertions(+), 123 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6145791ee61d1..78b82a706955c 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -93,7 +93,7 @@ import { resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; import { - resolveNewTaskEnvironmentProject, + resolveEnvironmentProjectMatch, resolveNewTaskEnvironments, } from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; @@ -588,13 +588,12 @@ export function NewTaskFlowProvider( }; const selectEnvironment = (environmentId: EnvironmentId) => { - const match = resolveNewTaskEnvironmentProject({ - environmentId, - projects, + const match = resolveEnvironmentProjectMatch( + projects.filter((project) => project.environmentId === environmentId), selectedProject, - pendingProject: null, - settings: groupingSettings, - }); + groupingSettings, + projects, + ); if (!match) return false; updateFlow({ draftKey: carryDraftContentTo(match), @@ -778,9 +777,6 @@ export function NewTaskFlowProvider( projectId: selectedProject.id, projectTitle: selectedProject.title, projectCwd: selectedProject.workspaceRoot, - ...(selectedProject.groupingIdentity !== undefined - ? { groupingIdentity: selectedProject.groupingIdentity } - : {}), workspaceMode: mode, ...(mode === "worktree" && workspaceSelection?.pullRequestReference ? { pullRequestReference: workspaceSelection.pullRequestReference } @@ -820,7 +816,6 @@ export function NewTaskFlowProvider( selectedProject, settings: groupingSettings, savedConnectionsById, - pendingTaskEnvironmentId: null, }); })(); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index d10d5aefd5254..205413cb73a11 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -9,7 +9,6 @@ import { resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, - resolveNewTaskEnvironmentProject, resolveNewTaskEnvironments, } from "./new-task-project-selection"; @@ -118,7 +117,7 @@ describe("resolveEnvironmentProjectMatch", () => { }); }); -describe("resolveNewTaskEnvironmentProject", () => { +describe("collection-aware environment routing", () => { const settings = { sidebarProjectGroupingMode: "repository" as const, sidebarProjectGroupingOverrides: {}, @@ -136,21 +135,14 @@ describe("resolveNewTaskEnvironmentProject", () => { }; const remote = makeProject("copy", "server", { repositoryKey: "github.com/g/sdk" }); const projects = [selectedProject, older, remote]; - expect( - resolveNewTaskEnvironmentProject({ - environmentId: remote.environmentId, - projects, - selectedProject, - pendingProject: null, - settings, - }), - ).toBe(remote); + expect(resolveEnvironmentProjectMatch([remote], selectedProject, settings, projects)).toBe( + remote, + ); expect( resolveNewTaskEnvironments({ projects, selectedProject, settings, - pendingTaskEnvironmentId: null, savedConnectionsById: { mac: { environmentLabel: "Mac" }, server: { environmentLabel: "Server" }, @@ -158,47 +150,6 @@ describe("resolveNewTaskEnvironmentProject", () => { }).map((environment) => environment.environmentId), ).toEqual(["mac", "server"]); }); - - it("preserves the queued project's identity when its host has no loaded projects", () => { - const pendingProject = makeProject("queued-project", "offline"); - const unrelated = makeProject("unrelated", "server"); - const input = { - environmentId: pendingProject.environmentId, - projects: [unrelated], - selectedProject: pendingProject, - pendingProject, - settings, - }; - expect(resolveNewTaskEnvironmentProject(input)).toBe(pendingProject); - // Returning to the task's own host must also preserve its stamped project. - expect(resolveNewTaskEnvironmentProject({ ...input, selectedProject: unrelated })).toBe( - pendingProject, - ); - expect( - resolveNewTaskEnvironmentProject({ ...input, environmentId: unrelated.environmentId }), - ).toBeNull(); - }); - - it("switches within the explicit group without reusing the queued project on another host", () => { - const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; - const pendingProject = { ...makeProject("sdk", "mac"), groupingIdentity }; - const copy = { ...makeProject("sdk-copy", "server"), groupingIdentity }; - const input = { - environmentId: copy.environmentId, - projects: [pendingProject, copy], - selectedProject: pendingProject, - pendingProject, - settings, - }; - expect(resolveNewTaskEnvironmentProject(input)).toBe(copy); - expect(resolveNewTaskEnvironmentProject({ ...input, projects: [] })).toBeNull(); - expect( - resolveNewTaskEnvironmentProject({ - ...input, - settings: { ...settings, sidebarProjectGroupingMode: "separate" }, - }), - ).toBeNull(); - }); }); describe("resolveNewTaskEnvironments", () => { @@ -211,20 +162,7 @@ describe("resolveNewTaskEnvironments", () => { server: { environmentLabel: "Server" }, }; - it("retains the queued task's machine and label while its project shell is absent", () => { - const missingProject = makeProject("removed", "mac"); - expect( - resolveNewTaskEnvironments({ - projects: [makeProject("unrelated", "mac"), makeProject("other", "server")], - selectedProject: missingProject, - settings, - savedConnectionsById, - pendingTaskEnvironmentId: missingProject.environmentId, - }), - ).toEqual([{ environmentId: "mac", environmentLabel: "Mac" }]); - }); - - it("lists only registered hosts of the explicit group and deduplicates the task's host", () => { + it("lists only registered hosts of the explicit group", () => { const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; const selectedProject = { ...makeProject("sdk", "mac"), groupingIdentity }; const input = { @@ -236,7 +174,6 @@ describe("resolveNewTaskEnvironments", () => { selectedProject, settings, savedConnectionsById, - pendingTaskEnvironmentId: selectedProject.environmentId, }; expect(resolveNewTaskEnvironments(input)).toEqual([ { environmentId: "mac", environmentLabel: "Mac" }, @@ -257,7 +194,6 @@ describe("resolveNewTaskEnvironments", () => { selectedProject: makeProject("missing", "mac"), settings, savedConnectionsById, - pendingTaskEnvironmentId: null, }), ).toEqual([]); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 95ae73e6ed8fd..eb80cddd1751c 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -80,7 +80,6 @@ export function resolveNewTaskScratchOption(input: { return environment ? { environmentId: environment.environmentId, ready: ready.length > 0 } : null; } - export function resolveEnvironmentProjectMatch( projectsOnTarget: ReadonlyArray, selectedProject: EnvironmentProject | null, @@ -99,24 +98,6 @@ export function resolveEnvironmentProjectMatch( ); } -export function resolveNewTaskEnvironmentProject(input: { - readonly environmentId: EnvironmentId; - readonly projects: ReadonlyArray; - readonly selectedProject: EnvironmentProject | null; - readonly pendingProject: EnvironmentProject | null; - readonly settings: ProjectGroupingSettings; -}): EnvironmentProject | null { - return ( - resolveEnvironmentProjectMatch( - input.projects.filter((project) => project.environmentId === input.environmentId), - input.selectedProject, - input.settings, - input.projects, - ) ?? (input.pendingProject?.environmentId === input.environmentId ? input.pendingProject : null) - ); -} - -/** Keep a queued task's host available even before its project shell loads. */ export function resolveNewTaskEnvironments(input: { readonly projects: ReadonlyArray; readonly selectedProject: EnvironmentProject | null; @@ -124,7 +105,6 @@ export function resolveNewTaskEnvironments(input: { readonly savedConnectionsById: Readonly< Record >; - readonly pendingTaskEnvironmentId: EnvironmentId | null; }): Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string }> { const matchingProjects = input.selectedProject ? (resolveProjectGroup(input.selectedProject, input.projects, input.settings)?.members.map( @@ -132,9 +112,6 @@ export function resolveNewTaskEnvironments(input: { ) ?? []) : input.projects; const candidates = matchingProjects.map((project) => project.environmentId); - if (input.pendingTaskEnvironmentId !== null) { - candidates.unshift(input.pendingTaskEnvironmentId); - } return [...new Set(candidates)].flatMap((environmentId) => { const environment = input.savedConnectionsById[environmentId]; return environment ? [{ environmentId, environmentLabel: environment.environmentLabel }] : []; diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eda85b551a081..6c5da0db20991 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -13,7 +13,6 @@ import { ModelSelection, OrchestrationMessageContext, ProjectId, - ProjectGroupingIdentity, ProviderInteractionMode, RuntimeMode, ThreadId, @@ -39,7 +38,6 @@ const QueuedThreadCreationSchema = Schema.Struct({ projectId: ProjectId, projectTitle: Schema.optional(Schema.String), projectCwd: Schema.optional(Schema.String), - groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), workspaceMode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), @@ -71,7 +69,6 @@ export interface QueuedThreadCreation { readonly projectId: ProjectIdType; readonly projectTitle?: string; readonly projectCwd?: string; - readonly groupingIdentity?: string | null; readonly workspaceMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index f7e41a8fe3e75..8aaf94ffc0df6 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1187,23 +1187,6 @@ describe("thread outbox", () => { ).toBe("remove"); }); - it.each([null, "39c61a1b-8a64-43a6-9da5-65c15da93d52"])( - "round-trips queued project grouping identity %s", - (groupingIdentity) => { - const message = { - ...queuedMessage({ messageId: "grouped", createdAt: "2026-06-08T10:00:01.000Z" }), - creation: { - projectId: ProjectId.make("sdk"), - groupingIdentity, - workspaceMode: "local" as const, - branch: null, - worktreePath: null, - }, - }; - expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); - }, - ); - it("round-trips queued PR checkout sources and gates incomplete creations from sending", () => { const base = queuedMessage({ messageId: "message-1", From 808329e038adf2100daff69a2fa3c569be921437 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Tue, 27 Oct 2026 14:20:43 -0700 Subject: [PATCH 08/21] fix(server): keep project grouping compatible with linked environments --- apps/server/src/mcp/toolkits/project/tools.ts | 8 ++-- .../server/src/persistence/Migrations.test.ts | 37 +++++++++++++++++++ .../src/provider/agentContextBudget.json | 2 +- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index f2b7b46d6536f..22b83753263e1 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -65,7 +65,7 @@ const ProjectListTool = Tool.make("t3_project_list", { .annotate(Tool.Destructive, false); const ProjectReadTool = Tool.make("t3_project_read", { ...shared, - description: "Read one project: workspace and saved scripts.", + description: "Read one project.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: ProjectId, @@ -76,7 +76,7 @@ const ProjectReadTool = Tool.make("t3_project_read", { const ProjectCreateTool = Tool.make("t3_project_create", { ...shared, description: - "Register a project folder. `createWorkspaceRootIfMissing` creates it; a registered folder is rejected. No `workspaceRoot`: makes a git repo from `title` with README, icon, first commit (`commitError` on failure; project still exists). Clone first with `t3_project_clone` if needed.", + "Register a folder; createWorkspaceRootIfMissing if absent. Existing registrations fail. Omit workspaceRoot for a Git repo from title; commitError leaves it created. Clone first with t3_project_clone.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, ...ProjectCreatePayload.fields, @@ -88,7 +88,7 @@ const ProjectCreateTool = Tool.make("t3_project_create", { const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, description: - "Update project settings; omitted fields stay unchanged. groupingIdentity: shared lowercase UUID v4 for non-Git copies, null to clear. Git identity wins; read/list shows the value.", + "Update settings; omitted fields stay. groupingIdentity: shared lowercase UUID v4 for non-Git copies; null clears. Git wins.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: ProjectId, @@ -108,7 +108,7 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, description: - "Clone a repository (`destinationPath` new or empty). Register after with `t3_project_create`, with the same `environmentId`.", + "Clone into new/empty destinationPath; register via t3_project_create on the same environmentId.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, ...SourceControlCloneRepositoryInput.fields, diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 54e4a711aa8a1..bedf8dd167f4f 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,6 +29,43 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { + it.effect("adds explicit grouping after linked-environment migrations", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 72 }); + const before = yield* history; + yield* sql` + INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-27') + `; + + assert.deepStrictEqual(yield* runMigrations(), [[73, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [73, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual( + yield* sql`SELECT project_id, title, workspace_root, grouping_identity FROM projection_projects`, + [ + { + project_id: "sdk-project", + title: "SDK", + workspace_root: "/work/sdk", + grouping_identity: null, + }, + ], + ); + yield* sql` + UPDATE projection_projects + SET grouping_identity = '39c61a1b-8a64-43a6-9da5-65c15da93d52' + WHERE project_id = 'sdk-project' + `; + assert.deepStrictEqual( + yield* sql`SELECT grouping_identity FROM projection_projects WHERE project_id = 'sdk-project'`, + [{ grouping_identity: "39c61a1b-8a64-43a6-9da5-65c15da93d52" }], + ); + assert.deepStrictEqual(yield* runMigrations(), []); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + it.effect("refuses to start when another migration took a V2 id", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1719d3ca6bd19..b617de3a34bf2 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47538 + "mcpToolListJsonBytes": 47535 } From 02a2f1fed66e9b64a86defb9f25eada72b4a20ce Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 29 Oct 2026 10:32:28 -0700 Subject: [PATCH 09/21] fix(mcp): preserve project creation guidance and add grouping evals --- apps/server/src/mcp/toolkits/project/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- evals/mcp-tool-use/cases/projects.ndjson | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 22b83753263e1..9034f005fc2a5 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -76,7 +76,7 @@ const ProjectReadTool = Tool.make("t3_project_read", { const ProjectCreateTool = Tool.make("t3_project_create", { ...shared, description: - "Register a folder; createWorkspaceRootIfMissing if absent. Existing registrations fail. Omit workspaceRoot for a Git repo from title; commitError leaves it created. Clone first with t3_project_clone.", + "Register workspaceRoot; createWorkspaceRootIfMissing if absent. Duplicates fail. No root: title makes Git, README.md, assets/icon.svg, tries Initial commit; commitError keeps project. Clone if needed.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, ...ProjectCreatePayload.fields, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index b617de3a34bf2..1d19191f9a0f4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47535 + "mcpToolListJsonBytes": 47536 } diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index b52ee4eff30bc..a7b9999a12ec0 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -1 +1,5 @@ {"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} +{"id": "non-git-copies-reuse-grouping-identity", "area": "projects", "kind": "guard", "pattern": "reuse the existing explicit identity on every non-Git copy", "experience": "Grouping guidance must keep one lowercase UUID v4 across copies rather than generating one identity per machine.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The current project grouping mode is repository. Both environments are connected and writable. The copies contain the same non-Git workspace, with Git repositories only in child directories. The following inventory is current.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Group these two non-Git SDK copies as one logical project. The Mac copy already has the shared identity; keep it and all other settings unchanged. Update the other physical project through MCP.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:linux", "projectId": "project:sdk-linux", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac"}}], "maxCalls": {"t3_project_update": 1}, "callsMustNot": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac"}}], "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"], "shellMustNot": ["\\bgit\\s+init\\b"]}, "maxSteps": 8} +{"id": "git-identity-precedes-explicit-grouping", "area": "projects", "kind": "guard", "pattern": "explicit grouping must not override Git repository identity", "experience": "The explicit grouping key applies to non-Git roots; agents must not claim it merges unrelated Git repositories.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Both environments are connected and writable, in repository grouping mode. These are the current project records.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:app", "title": "SDK", "workspaceRoot": "/home/user/app", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/app", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/app.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:other", "title": "SDK", "workspaceRoot": "/home/user/other", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/other", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/other.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"ok": true}}}, "prompt": "Make these two Git projects one logical project by setting the same explicit grouping key, without changing their Git remotes. If this feature cannot do that, explain instead of modifying the projects.", "expect": {"notCalls": ["t3_project_update", "t3_project_create", "t3_project_delete", "t3_project_clone"], "textMust": ["\\b([Gg]it|[Rr]epositor(y|ies))\\b", "([Pp]recedence|[Pp]riority|[Ww]ins|[Oo]verrid|[Cc]annot|[Cc]an.t|[Ww]on.t|[Ww]ill not)"], "shellMustNot": ["\\bgit\\s+remote\\s+(set-url|add|remove)\\b", "\\bgit\\s+init\\b", "\\bt3\\s+project\\s+grouping\\b[^\\n]*(--identity|--clear)", "\\bt3\\s+project\\s+add\\b[^\\n]*--grouping-identity"]}, "maxSteps": 8} +{"id": "clear-non-git-grouping-identity", "area": "projects", "kind": "control", "pattern": "clear the optional key without deleting the project", "experience": "Clearing grouping is a normal metadata update and must retain the physical workspace registration.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The Mac environment is writable. SDK is a non-Git project with an explicit shared grouping identity.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Remove the explicit grouping identity from project:sdk-mac on environment:mac. Keep its folder, title, scripts and existing threads.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac", "projectId": "project:sdk-mac", "groupingIdentity": null}}], "maxCalls": {"t3_project_update": 1}, "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"]}, "maxSteps": 8} +{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["README\\.md", "assets/icon\\.svg", "([Ii]nitial commit|[Cc]ommit attempt|[Aa]ttempt.{0,50}commit|[Tt]ries?.{0,50}commit)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"]}, "maxSteps": 8} From 23f297e32f77d8e4a4d71a4c664dc28165c63e0c Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 29 Oct 2026 10:54:22 -0700 Subject: [PATCH 10/21] fix(evals): enforce project mutation and disclosure checks --- evals/mcp-tool-use/cases/projects.ndjson | 6 +- evals/mcp-tool-use/run.test.ts | 80 +++++++++++++++++++++++- evals/mcp-tool-use/run.ts | 13 +++- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index a7b9999a12ec0..710308f9d02e8 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -1,5 +1,5 @@ {"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} -{"id": "non-git-copies-reuse-grouping-identity", "area": "projects", "kind": "guard", "pattern": "reuse the existing explicit identity on every non-Git copy", "experience": "Grouping guidance must keep one lowercase UUID v4 across copies rather than generating one identity per machine.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The current project grouping mode is repository. Both environments are connected and writable. The copies contain the same non-Git workspace, with Git repositories only in child directories. The following inventory is current.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Group these two non-Git SDK copies as one logical project. The Mac copy already has the shared identity; keep it and all other settings unchanged. Update the other physical project through MCP.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:linux", "projectId": "project:sdk-linux", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac"}}], "maxCalls": {"t3_project_update": 1}, "callsMustNot": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac"}}], "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"], "shellMustNot": ["\\bgit\\s+init\\b"]}, "maxSteps": 8} +{"id": "non-git-copies-reuse-grouping-identity", "area": "projects", "kind": "guard", "pattern": "reuse the existing explicit identity on every non-Git copy", "experience": "Grouping guidance must keep one lowercase UUID v4 across copies rather than generating one identity per machine.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The current project grouping mode is repository. Both environments are connected and writable. The copies contain the same non-Git workspace, with Git repositories only in child directories. The following inventory is current.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Group these two non-Git SDK copies as one logical project. The Mac copy already has the shared identity; keep it and all other settings unchanged. Update the other physical project through MCP.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:linux", "projectId": "project:sdk-linux", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac"}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "callsMustNot": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac"}}], "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"], "shellMustNot": ["\\bgit\\s+init\\b"]}, "maxSteps": 8} {"id": "git-identity-precedes-explicit-grouping", "area": "projects", "kind": "guard", "pattern": "explicit grouping must not override Git repository identity", "experience": "The explicit grouping key applies to non-Git roots; agents must not claim it merges unrelated Git repositories.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Both environments are connected and writable, in repository grouping mode. These are the current project records.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:app", "title": "SDK", "workspaceRoot": "/home/user/app", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/app", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/app.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:other", "title": "SDK", "workspaceRoot": "/home/user/other", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/other", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/other.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"ok": true}}}, "prompt": "Make these two Git projects one logical project by setting the same explicit grouping key, without changing their Git remotes. If this feature cannot do that, explain instead of modifying the projects.", "expect": {"notCalls": ["t3_project_update", "t3_project_create", "t3_project_delete", "t3_project_clone"], "textMust": ["\\b([Gg]it|[Rr]epositor(y|ies))\\b", "([Pp]recedence|[Pp]riority|[Ww]ins|[Oo]verrid|[Cc]annot|[Cc]an.t|[Ww]on.t|[Ww]ill not)"], "shellMustNot": ["\\bgit\\s+remote\\s+(set-url|add|remove)\\b", "\\bgit\\s+init\\b", "\\bt3\\s+project\\s+grouping\\b[^\\n]*(--identity|--clear)", "\\bt3\\s+project\\s+add\\b[^\\n]*--grouping-identity"]}, "maxSteps": 8} -{"id": "clear-non-git-grouping-identity", "area": "projects", "kind": "control", "pattern": "clear the optional key without deleting the project", "experience": "Clearing grouping is a normal metadata update and must retain the physical workspace registration.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The Mac environment is writable. SDK is a non-Git project with an explicit shared grouping identity.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Remove the explicit grouping identity from project:sdk-mac on environment:mac. Keep its folder, title, scripts and existing threads.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac", "projectId": "project:sdk-mac", "groupingIdentity": null}}], "maxCalls": {"t3_project_update": 1}, "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"]}, "maxSteps": 8} -{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["README\\.md", "assets/icon\\.svg", "([Ii]nitial commit|[Cc]ommit attempt|[Aa]ttempt.{0,50}commit|[Tt]ries?.{0,50}commit)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"]}, "maxSteps": 8} +{"id": "clear-non-git-grouping-identity", "area": "projects", "kind": "control", "pattern": "clear the optional key without deleting the project", "experience": "Clearing grouping is a normal metadata update and must retain the physical workspace registration.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The Mac environment is writable. SDK is a non-Git project with an explicit shared grouping identity.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Remove the explicit grouping identity from project:sdk-mac on environment:mac. Keep its folder, title, scripts and existing threads.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac", "projectId": "project:sdk-mac", "groupingIdentity": null}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"]}, "maxSteps": 8} +{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}README\\.md|README\\.md[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}assets/icon\\.svg|assets/icon\\.svg[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|[Cc]reat\\w*|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:[Ii]nitial|[Ff]irst)\\s+(?:Git\\s+)?commit|[Ii]nitial commit[^\\n]{0,40}(?:attempt|tried|made|created|performed|run)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"], "shellMustNot": ["\\bgit\\s+init\\b"], "textMustNot": ["(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)", "(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)[^\\n]{0,80}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)"]}, "maxSteps": 8} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index 69de4f0c6c4b2..2338519885cbe 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -30,6 +30,8 @@ import { import { briefRoute, gradeDevice, shellInvocations } from "./device-grading.ts"; import { taskPrompt } from "../../apps/server/src/mcp/delegatedTaskModes.ts"; +const decodeCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); + it("grades Codex shell and subagent calls with the shared rules", () => { const calls = [ { name: "exec_command", input: { cmd: "sleep 60" } }, @@ -161,6 +163,59 @@ it("passes a good run and names every rule a bad run breaks", () => { "linked ~/pull/124", "linkedNumbers", ]); + + const projectCases = NodeFS.readFileSync( + new URL("./cases/projects.ndjson", import.meta.url), + "utf8", + ) + .trim() + .split("\n") + .map((line) => decodeCase(line)); + const preview = projectCases.find( + (entry) => entry.id === "title-only-project-creation-side-effects", + )!; + const disclosure = "It creates README.md and assets/icon.svg, then attempts an Initial commit."; + for (const text of [ + disclosure, + "README.md and assets/icon.svg are created; an Initial commit is attempted.", + "Writes `README.md`.\nGenerates `assets/icon.svg`.\nAttempts an initial commit.", + ]) { + assert.deepEqual(failed(preview.expect, { calls: [], text, stopReason: "end_turn" }), [], text); + } + for (const run of [ + { + calls: [{ step: 0, tool: "Bash", input: { command: "git init /home/user/docs" } }], + text: disclosure, + stopReason: "end_turn", + }, + { + calls: [], + text: "It does not create README.md or assets/icon.svg and does not attempt an initial commit.", + stopReason: "end_turn", + }, + { + calls: [], + text: "README.md and assets/icon.svg are not created; an initial commit is not attempted.", + stopReason: "end_turn", + }, + { + calls: [], + text: "Creates README.md and assets/icon.svg, but does not attempt an initial commit.", + stopReason: "end_turn", + }, + { + calls: [], + text: "It does not create README.md, but writes assets/icon.svg and attempts an Initial commit.", + stopReason: "end_turn", + }, + { + calls: [], + text: "Creates README.md but never generates assets/icon.svg; attempts an Initial commit.", + stopReason: "end_turn", + }, + ]) { + assert.isNotEmpty(failed(preview.expect, run), JSON.stringify(run)); + } }); it("bounds numbers and reads target.options in either schema form", () => { @@ -182,6 +237,28 @@ it("bounds numbers and reads target.options in either schema form", () => { assert.deepEqual(failed(expect, run(5, { effort: "max" }, 90_000)), []); assert.lengthOf(failed(expect, run(21, [{ id: "effort", value: "high" }], 59_999)), 2); assert.lengthOf(failed(expect, run("10", {}, 60_000)), 2); + + const args = { + environmentId: "environment:linux", + projectId: "project:sdk", + groupingIdentity: "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", + }; + const update = { tool: "t3_project_update", args }; + const restricted = { calls: [{ ...update, allowedArgs: Object.keys(args) }] }; + const updated = (extra: Record): SampleRun => ({ + calls: [{ step: 0, tool: update.tool, input: { ...args, ...extra } }], + text: "", + stopReason: "end_turn", + }); + assert.deepEqual(failed(restricted, updated({})), []); + for (const extra of [ + { title: "Other" }, + { workspaceRoot: "/home/user/other" }, + { scripts: [] }, + ]) { + assert.deepEqual(failed({ calls: [update] }, updated(extra)), []); + assert.lengthOf(failed(restricted, updated(extra)), 1); + } }); const template = { @@ -426,12 +503,11 @@ it("stops a sample only once a must-not check has failed", () => { assert.isTrue(failedEarly({ textMustNot: ["paste"] }, run("Bash", "paste it here"))); }); -const decodeDeviceCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); const deviceCases = () => NodeFS.readFileSync(new URL("./cases/device-manager.ndjson", import.meta.url), "utf8") .trim() .split("\n") - .map((line) => decodeDeviceCase(line)); + .map((line) => decodeCase(line)); it.effect("builds device cases from product-backed worlds and synthetic prior turns", () => Effect.gen(function* () { diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index b407b147b9be2..4305c23e667da 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -148,6 +148,8 @@ const PriorToolCall = Schema.Struct({ const McpToolUseCall = Schema.Struct({ tool: Schema.String, args: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + /** Top-level allowlist; required values still come from args. */ + allowedArgs: Schema.optional(Schema.Array(Schema.String)), }); type McpToolUseCall = typeof McpToolUseCall.Type; @@ -1457,8 +1459,15 @@ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun) => const calls = run.calls.map((call) => ({ ...call, input: withOptionsObject(call.input) })); const count = (tool: string) => calls.filter((call) => call.tool === tool).length; // `tool` may list alternatives, as in "link_pull_request|watch_pull_request". - const found = ({ tool, args = {} }: McpToolUseCall) => - calls.some((call) => tool.split("|").includes(call.tool) && matches(args, call.input)); + const found = ({ tool, args = {}, allowedArgs }: McpToolUseCall) => + calls.some( + (call) => + tool.split("|").includes(call.tool) && + matches(args, call.input) && + (allowedArgs === undefined || + (isRecord(call.input) && + Object.keys(call.input).every((key) => allowedArgs.includes(key)))), + ); const label = ({ tool, args }: McpToolUseCall) => (args ? `${tool} ${valueText(args)}` : tool); for (const call of expect.calls ?? []) add(`calls ${label(call)}`, found(call)); for (const call of expect.callsMustNot ?? []) add(`callsMustNot ${label(call)}`, !found(call)); From d02a3adf76cb73ceecbb0e25853f9b8b50f3a12e Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 29 Oct 2026 11:15:43 -0700 Subject: [PATCH 11/21] fix(evals): keep preview negations within one sentence --- evals/mcp-tool-use/cases/projects.ndjson | 2 +- evals/mcp-tool-use/run.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index 710308f9d02e8..a327afa4bb07e 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -2,4 +2,4 @@ {"id": "non-git-copies-reuse-grouping-identity", "area": "projects", "kind": "guard", "pattern": "reuse the existing explicit identity on every non-Git copy", "experience": "Grouping guidance must keep one lowercase UUID v4 across copies rather than generating one identity per machine.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The current project grouping mode is repository. Both environments are connected and writable. The copies contain the same non-Git workspace, with Git repositories only in child directories. The following inventory is current.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Group these two non-Git SDK copies as one logical project. The Mac copy already has the shared identity; keep it and all other settings unchanged. Update the other physical project through MCP.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:linux", "projectId": "project:sdk-linux", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac"}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "callsMustNot": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac"}}], "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"], "shellMustNot": ["\\bgit\\s+init\\b"]}, "maxSteps": 8} {"id": "git-identity-precedes-explicit-grouping", "area": "projects", "kind": "guard", "pattern": "explicit grouping must not override Git repository identity", "experience": "The explicit grouping key applies to non-Git roots; agents must not claim it merges unrelated Git repositories.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Both environments are connected and writable, in repository grouping mode. These are the current project records.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:app", "title": "SDK", "workspaceRoot": "/home/user/app", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/app", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/app.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:other", "title": "SDK", "workspaceRoot": "/home/user/other", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/other", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/other.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"ok": true}}}, "prompt": "Make these two Git projects one logical project by setting the same explicit grouping key, without changing their Git remotes. If this feature cannot do that, explain instead of modifying the projects.", "expect": {"notCalls": ["t3_project_update", "t3_project_create", "t3_project_delete", "t3_project_clone"], "textMust": ["\\b([Gg]it|[Rr]epositor(y|ies))\\b", "([Pp]recedence|[Pp]riority|[Ww]ins|[Oo]verrid|[Cc]annot|[Cc]an.t|[Ww]on.t|[Ww]ill not)"], "shellMustNot": ["\\bgit\\s+remote\\s+(set-url|add|remove)\\b", "\\bgit\\s+init\\b", "\\bt3\\s+project\\s+grouping\\b[^\\n]*(--identity|--clear)", "\\bt3\\s+project\\s+add\\b[^\\n]*--grouping-identity"]}, "maxSteps": 8} {"id": "clear-non-git-grouping-identity", "area": "projects", "kind": "control", "pattern": "clear the optional key without deleting the project", "experience": "Clearing grouping is a normal metadata update and must retain the physical workspace registration.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The Mac environment is writable. SDK is a non-Git project with an explicit shared grouping identity.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Remove the explicit grouping identity from project:sdk-mac on environment:mac. Keep its folder, title, scripts and existing threads.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac", "projectId": "project:sdk-mac", "groupingIdentity": null}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"]}, "maxSteps": 8} -{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}README\\.md|README\\.md[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}assets/icon\\.svg|assets/icon\\.svg[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|[Cc]reat\\w*|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:[Ii]nitial|[Ff]irst)\\s+(?:Git\\s+)?commit|[Ii]nitial commit[^\\n]{0,40}(?:attempt|tried|made|created|performed|run)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"], "shellMustNot": ["\\bgit\\s+init\\b"], "textMustNot": ["(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)", "(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)[^\\n]{0,80}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)"]}, "maxSteps": 8} +{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}README\\.md|README\\.md[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}assets/icon\\.svg|assets/icon\\.svg[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|[Cc]reat\\w*|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:[Ii]nitial|[Ff]irst)\\s+(?:Git\\s+)?commit|[Ii]nitial commit[^\\n]{0,40}(?:attempt|tried|made|created|performed|run)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"], "shellMustNot": ["\\bgit\\s+init\\b"], "textMustNot": ["(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)[^\\n.!?]{0,80}(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)", "(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)[^\\n.!?]{0,80}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)"]}, "maxSteps": 8} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index 2338519885cbe..57603d9ac7947 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -179,6 +179,8 @@ it("passes a good run and names every rule a bad run breaks", () => { disclosure, "README.md and assets/icon.svg are created; an Initial commit is attempted.", "Writes `README.md`.\nGenerates `assets/icon.svg`.\nAttempts an initial commit.", + "I won't create anything yet. After approval, the tool creates README.md and assets/icon.svg, then attempts an Initial commit.", + "After approval, README.md and assets/icon.svg are created and an Initial commit is attempted. I won't create anything yet.", ]) { assert.deepEqual(failed(preview.expect, { calls: [], text, stopReason: "end_turn" }), [], text); } From bbf472bd8d3ce99102fc07b7246d8a09cf6f37de Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Thu, 29 Oct 2026 13:26:39 -0700 Subject: [PATCH 12/21] test(server): upgrade grouping after active-turn indexes --- apps/server/src/persistence/Migrations.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index bedf8dd167f4f..70ea3689ed952 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,10 +29,10 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { - it.effect("adds explicit grouping after linked-environment migrations", () => + it.effect("adds explicit grouping after active-turn indexes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 72 }); + yield* runMigrations({ toMigrationInclusive: 73 }); const before = yield* history; yield* sql` INSERT INTO projection_projects @@ -40,8 +40,8 @@ describe("runMigrations", () => { VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-27') `; - assert.deepStrictEqual(yield* runMigrations(), [[73, "ProjectGroupingIdentity"]]); - assert.deepStrictEqual(yield* history, [...before, [73, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* runMigrations(), [[74, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [74, "ProjectGroupingIdentity"]]); assert.deepStrictEqual( yield* sql`SELECT project_id, title, workspace_root, grouping_identity FROM projection_projects`, [ From fc8a5eafb9b66a645754a05b36248c1b2d3425de Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Fri, 30 Oct 2026 10:30:49 -0700 Subject: [PATCH 13/21] test(server): upgrade grouping after attachment search cleanup --- apps/server/src/persistence/Migrations.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 70ea3689ed952..4b2c159f96bf5 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,10 +29,10 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { - it.effect("adds explicit grouping after active-turn indexes", () => + it.effect("adds explicit grouping after attachment search cleanup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 73 }); + yield* runMigrations({ toMigrationInclusive: 74 }); const before = yield* history; yield* sql` INSERT INTO projection_projects @@ -40,8 +40,8 @@ describe("runMigrations", () => { VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-27') `; - assert.deepStrictEqual(yield* runMigrations(), [[74, "ProjectGroupingIdentity"]]); - assert.deepStrictEqual(yield* history, [...before, [74, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* runMigrations(), [[75, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [75, "ProjectGroupingIdentity"]]); assert.deepStrictEqual( yield* sql`SELECT project_id, title, workspace_root, grouping_identity FROM projection_projects`, [ From 632600c3f75b3c2be47724b81801b66ad39c7d20 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Fri, 30 Oct 2026 12:27:39 -0700 Subject: [PATCH 14/21] test(server): upgrade grouping after subagent delivery state --- apps/server/src/persistence/Migrations.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 4b2c159f96bf5..869f531b63237 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,10 +29,10 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { - it.effect("adds explicit grouping after attachment search cleanup", () => + it.effect("adds explicit grouping after subagent delivery state", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 74 }); + yield* runMigrations({ toMigrationInclusive: 75 }); const before = yield* history; yield* sql` INSERT INTO projection_projects @@ -40,8 +40,8 @@ describe("runMigrations", () => { VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-27') `; - assert.deepStrictEqual(yield* runMigrations(), [[75, "ProjectGroupingIdentity"]]); - assert.deepStrictEqual(yield* history, [...before, [75, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* runMigrations(), [[76, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [76, "ProjectGroupingIdentity"]]); assert.deepStrictEqual( yield* sql`SELECT project_id, title, workspace_root, grouping_identity FROM projection_projects`, [ From a8d6396d8844883958bbc9a3d6465d9d3ada091d Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Fri, 30 Oct 2026 13:54:24 -0700 Subject: [PATCH 15/21] test(mobile): verify grouped host environments stay cached --- apps/mobile/src/reactCompilerCoverage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 192b70edba47e..a5afefab6e59b 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -187,7 +187,7 @@ describe("React Compiler coverage", () => { it("caches the new-task model options and host environments", () => { const file = "src/features/threads/new-task-flow-provider.tsx"; expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]); - expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]); + expect(compilerCachedCalls(file, "resolveNewTaskEnvironments")).toEqual([true]); }); // An uncached registration would re-register the workspace inspector on every render. From 51a627c5caeaecb1f74b9fb9492ba496c5fecd27 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Fri, 30 Oct 2026 19:11:24 -0700 Subject: [PATCH 16/21] fix(mcp): name grouping identity in project creation errors --- apps/server/src/mcp/toolkits/project/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 2e969d050d0dd..642e6db94dcc9 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -321,7 +321,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { return yield* new OrchestratorMcpFailure({ code: "invalid_request", message: - "A project started from its title takes only a title; set scripts or defaultModelSelection afterwards with t3_project_update.", + "A project started from its title takes only a title; set groupingIdentity, scripts or defaultModelSelection afterwards with t3_project_update.", }); const folders = yield* ManagedProjectFolders.ManagedProjectFolders; const created = yield* folders.createNamedProject({ name: input.title }).pipe( From 63bb83cb91b26b3cdbd9b130520c696abf3e1567 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Sat, 31 Oct 2026 10:08:46 -0700 Subject: [PATCH 17/21] test(server): upgrade grouping after project pin and archive metadata --- apps/server/src/persistence/Migrations.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 869f531b63237..0cd32b3d4f0c9 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,26 +29,28 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { - it.effect("adds explicit grouping after subagent delivery state", () => + it.effect("adds explicit grouping after project pinning and archiving", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 75 }); + yield* runMigrations({ toMigrationInclusive: 76 }); const before = yield* history; yield* sql` INSERT INTO projection_projects - (project_id, title, workspace_root, scripts_json, created_at, updated_at) - VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-27') + (project_id, title, workspace_root, scripts_json, pinned_at, archived_at, created_at, updated_at) + VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-28', '2026-10-27', '2026-10-28') `; - assert.deepStrictEqual(yield* runMigrations(), [[76, "ProjectGroupingIdentity"]]); - assert.deepStrictEqual(yield* history, [...before, [76, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* runMigrations(), [[77, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [77, "ProjectGroupingIdentity"]]); assert.deepStrictEqual( - yield* sql`SELECT project_id, title, workspace_root, grouping_identity FROM projection_projects`, + yield* sql`SELECT project_id, title, workspace_root, pinned_at, archived_at, grouping_identity FROM projection_projects`, [ { project_id: "sdk-project", title: "SDK", workspace_root: "/work/sdk", + pinned_at: "2026-10-27", + archived_at: "2026-10-28", grouping_identity: null, }, ], From fd62f5ca1706514cebd68f03e7202d7603276762 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Sat, 31 Oct 2026 10:29:41 -0700 Subject: [PATCH 18/21] test(evals): require correct project explanations --- evals/mcp-tool-use/cases/projects.ndjson | 4 +-- evals/mcp-tool-use/run.test.ts | 46 +++++++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index a327afa4bb07e..d677ad7290149 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -1,5 +1,5 @@ {"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} {"id": "non-git-copies-reuse-grouping-identity", "area": "projects", "kind": "guard", "pattern": "reuse the existing explicit identity on every non-Git copy", "experience": "Grouping guidance must keep one lowercase UUID v4 across copies rather than generating one identity per machine.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The current project grouping mode is repository. Both environments are connected and writable. The copies contain the same non-Git workspace, with Git repositories only in child directories. The following inventory is current.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Group these two non-Git SDK copies as one logical project. The Mac copy already has the shared identity; keep it and all other settings unchanged. Update the other physical project through MCP.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:linux", "projectId": "project:sdk-linux", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac"}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "callsMustNot": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac"}}], "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"], "shellMustNot": ["\\bgit\\s+init\\b"]}, "maxSteps": 8} -{"id": "git-identity-precedes-explicit-grouping", "area": "projects", "kind": "guard", "pattern": "explicit grouping must not override Git repository identity", "experience": "The explicit grouping key applies to non-Git roots; agents must not claim it merges unrelated Git repositories.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Both environments are connected and writable, in repository grouping mode. These are the current project records.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:app", "title": "SDK", "workspaceRoot": "/home/user/app", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/app", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/app.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:other", "title": "SDK", "workspaceRoot": "/home/user/other", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/other", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/other.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"ok": true}}}, "prompt": "Make these two Git projects one logical project by setting the same explicit grouping key, without changing their Git remotes. If this feature cannot do that, explain instead of modifying the projects.", "expect": {"notCalls": ["t3_project_update", "t3_project_create", "t3_project_delete", "t3_project_clone"], "textMust": ["\\b([Gg]it|[Rr]epositor(y|ies))\\b", "([Pp]recedence|[Pp]riority|[Ww]ins|[Oo]verrid|[Cc]annot|[Cc]an.t|[Ww]on.t|[Ww]ill not)"], "shellMustNot": ["\\bgit\\s+remote\\s+(set-url|add|remove)\\b", "\\bgit\\s+init\\b", "\\bt3\\s+project\\s+grouping\\b[^\\n]*(--identity|--clear)", "\\bt3\\s+project\\s+add\\b[^\\n]*--grouping-identity"]}, "maxSteps": 8} +{"id": "git-identity-precedes-explicit-grouping", "area": "projects", "kind": "guard", "pattern": "explicit grouping must not override Git repository identity", "experience": "The explicit grouping key applies to non-Git roots; agents must not claim it merges unrelated Git repositories.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Both environments are connected and writable, in repository grouping mode. These are the current project records.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:app", "title": "SDK", "workspaceRoot": "/home/user/app", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/app", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/app.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:other", "title": "SDK", "workspaceRoot": "/home/user/other", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/other", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/other.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"ok": true}}}, "prompt": "Make these two Git projects one logical project by setting the same explicit grouping key, without changing their Git remotes. If this feature cannot do that, explain instead of modifying the projects.", "expect": {"notCalls": ["t3_project_update", "t3_project_create", "t3_project_delete", "t3_project_clone"], "textMust": ["(?:(?:[Gg]it(?:\\s+repository)?\\s+identity|[Rr]epository\\s+identity)\\s+(?:takes?\\s+(?:precedence|priority)\\s+over|wins?\\s+over|overrides?)\\s+(?:the\\s+)?(?:[Ee]xplicit\\s+grouping|groupingIdentity|[Gg]rouping\\s+(?:keys?|identity))|(?:[Ee]xplicit\\s+grouping|groupingIdentity|[Gg]rouping\\s+(?:keys?|identity))\\s+(?:cannot|can[\\x27\\u2019]t|does\\s+not|doesn[\\x27\\u2019]t)\\s+override\\s+(?:the\\s+)?(?:[Gg]it(?:\\s+repository)?\\s+identity|[Rr]epository\\s+identity))", "(?:[Cc]annot|[Cc]an[\\x27\\u2019]t|[Ww]on[\\x27\\u2019]t|[Ww]ill\\s+not)[^\\n.!?]{0,60}(?:merg\\w*|group\\w*|combin\\w*)"], "shellMustNot": ["\\bgit\\s+remote\\s+(set-url|add|remove)\\b", "\\bgit\\s+init\\b", "\\bt3\\s+project\\s+grouping\\b[^\\n]*(--identity|--clear)", "\\bt3\\s+project\\s+add\\b[^\\n]*--grouping-identity"]}, "maxSteps": 8} {"id": "clear-non-git-grouping-identity", "area": "projects", "kind": "control", "pattern": "clear the optional key without deleting the project", "experience": "Clearing grouping is a normal metadata update and must retain the physical workspace registration.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The Mac environment is writable. SDK is a non-Git project with an explicit shared grouping identity.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Remove the explicit grouping identity from project:sdk-mac on environment:mac. Keep its folder, title, scripts and existing threads.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac", "projectId": "project:sdk-mac", "groupingIdentity": null}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"]}, "maxSteps": 8} -{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}README\\.md|README\\.md[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}assets/icon\\.svg|assets/icon\\.svg[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|[Cc]reat\\w*|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:[Ii]nitial|[Ff]irst)\\s+(?:Git\\s+)?commit|[Ii]nitial commit[^\\n]{0,40}(?:attempt|tried|made|created|performed|run)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"], "shellMustNot": ["\\bgit\\s+init\\b"], "textMustNot": ["(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)[^\\n.!?]{0,80}(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)", "(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)[^\\n.!?]{0,80}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)"]}, "maxSteps": 8} +{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}README\\.md|README\\.md[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}assets/icon\\.svg|assets/icon\\.svg[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|[Cc]reat\\w*|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:[Ii]nitial|[Ff]irst)\\s+(?:Git\\s+)?commit|[Ii]nitial commit[^\\n]{0,40}(?:attempt|tried|made|created|performed|run)", "(?:[Ii]nitiali[sz](?:e|es|ing)\\s+(?:(?:a|the)\\s+)?(?:new\\s+)?[Gg]it(?:\\s+repo(?:sitory)?)?|[Rr]uns?\\s+`?git\\s+init\\b|[Gg]it(?:\\s+repo(?:sitory)?)?\\s+(?:is|gets?)\\s+initiali[sz]ed)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"], "shellMustNot": ["\\bgit\\s+init\\b"], "textMustNot": ["(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)[^\\n.!?]{0,80}(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)", "(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)[^\\n.!?]{0,80}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)", "(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Ii]nitiali[sz]\\w*|[Rr]un\\w*)[^\\n.!?]{0,40}\\b[Gg]it\\b", "\\b[Gg]it(?:\\s+repo(?:sitory)?)?[^\\n.!?]{0,35}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Ii]nitiali[sz]\\w*|[Rr]un\\w*)"]}, "maxSteps": 8} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index 57603d9ac7947..d9ecd768a9d5b 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -174,16 +174,52 @@ it("passes a good run and names every rule a bad run breaks", () => { const preview = projectCases.find( (entry) => entry.id === "title-only-project-creation-side-effects", )!; - const disclosure = "It creates README.md and assets/icon.svg, then attempts an Initial commit."; + const disclosure = + "It initializes a Git repository, creates README.md and assets/icon.svg, then attempts an Initial commit."; for (const text of [ disclosure, - "README.md and assets/icon.svg are created; an Initial commit is attempted.", - "Writes `README.md`.\nGenerates `assets/icon.svg`.\nAttempts an initial commit.", - "I won't create anything yet. After approval, the tool creates README.md and assets/icon.svg, then attempts an Initial commit.", - "After approval, README.md and assets/icon.svg are created and an Initial commit is attempted. I won't create anything yet.", + "A Git repository is initialized; README.md and assets/icon.svg are created; an Initial commit is attempted.", + "Runs `git init`.\nWrites `README.md`.\nGenerates `assets/icon.svg`.\nAttempts an initial commit.", + "I won't create anything yet. After approval, the tool initializes Git, creates README.md and assets/icon.svg, then attempts an Initial commit.", + "After approval, Git is initialized, README.md and assets/icon.svg are created and an Initial commit is attempted. I won't create anything yet.", ]) { assert.deepEqual(failed(preview.expect, { calls: [], text, stopReason: "end_turn" }), [], text); } + const precedence = projectCases.find( + (entry) => entry.id === "git-identity-precedes-explicit-grouping", + )!; + for (const text of [ + "Git identity takes precedence over explicit grouping, so these projects cannot be merged this way.", + "Repository identity wins over the grouping key; I can't combine these projects using it.", + "Explicit grouping cannot override Git repository identity. I cannot merge them with this key.", + ]) { + assert.deepEqual( + failed(precedence.expect, { calls: [], text, stopReason: "end_turn" }), + [], + text, + ); + } + const acceptedBadExplanations: Array = []; + for (const text of [ + "Git identity can be overridden by explicit grouping, but I cannot make the change now.", + "Explicit grouping overrides Git identity, but I cannot merge them now.", + "Git identity does not take precedence over explicit grouping. I cannot merge them now.", + ]) { + if (failed(precedence.expect, { calls: [], text, stopReason: "end_turn" }).length === 0) { + acceptedBadExplanations.push(text); + } + } + for (const text of [ + "It creates README.md and assets/icon.svg, then attempts an Initial commit.", + "It creates README.md and assets/icon.svg, then attempts an Initial commit, but does not initialize Git.", + "Git is not initialized. It creates README.md and assets/icon.svg, then attempts an Initial commit.", + "It initializes Git and creates README.md and assets/icon.svg, then attempts an Initial commit. The Git repository will not be initialized.", + ]) { + if (failed(preview.expect, { calls: [], text, stopReason: "end_turn" }).length === 0) { + acceptedBadExplanations.push(text); + } + } + assert.deepEqual(acceptedBadExplanations, []); for (const run of [ { calls: [{ step: 0, tool: "Bash", input: { command: "git init /home/user/docs" } }], From 8a5ddad3a10f5a92e3736cbe258e694c18e6a8e2 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Sat, 31 Oct 2026 19:47:20 -0700 Subject: [PATCH 19/21] test(mobile): give draft host copies an explicit grouping identity --- .../features/threads/new-task-flow-provider.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index 0b7ecf7900c2b..1852489add33d 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -63,7 +63,10 @@ vi.mock("../../state/threads", () => ({ olderSettledThreads: {}, threadFamilies: vi.mock("../../state/pull-requests", () => ({ composerPullRequests: {} })); vi.mock("../../state/olderSettledShelf", () => ({ useOlderSettledCount: () => 0 })); vi.mock("../../state/project-grouping", () => ({ - useMobileProjectGroupingSettings: () => ({ sidebarProjectGroupingMode: "none" }), + useMobileProjectGroupingSettings: () => ({ + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, + }), })); vi.mock("./use-legacy-plan-mode-enabled", () => ({ useLegacyPlanModeState: () => ({ enabled: false, loaded: true }), @@ -132,7 +135,12 @@ function setup() { vi.clearAllMocks(); const registry = AtomRegistry.make(); mocks.registry.mockReturnValue(registry); - const projects = [project("one", "a"), project("one", "b"), project("two", "b")]; + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const projects = [ + project("one", "a"), + { ...project("one", "b"), groupingIdentity }, + { ...project("two", "b"), groupingIdentity }, + ]; mocks.projects.mockReturnValue(projects); mocks.presentation.mockReturnValue({ isReady: true, From 7af4c88bcbeb8449ce050f4a123caf47f32915b6 Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Sat, 31 Oct 2026 20:10:34 -0700 Subject: [PATCH 20/21] test(mobile): distinguish the target copy during draft handoff --- .../src/features/threads/new-task-flow-provider.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index 1852489add33d..1323ce2bb8d74 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -139,7 +139,7 @@ function setup() { const projects = [ project("one", "a"), { ...project("one", "b"), groupingIdentity }, - { ...project("two", "b"), groupingIdentity }, + { ...project("two", "copy"), groupingIdentity }, ]; mocks.projects.mockReturnValue(projects); mocks.presentation.mockReturnValue({ @@ -296,9 +296,9 @@ it("owns a status stream only while the draft is active, retaining draft choices expect(screen.current.selectedProject).toEqual(screen.projects[2]); expect(screen.snapshot(draftKey).project).toEqual({ environmentId: "two", - projectId: "b", + projectId: "copy", }); - expect(screen.events).toEqual(["start:/a", "start:/b", "start:/b"]); + expect(screen.events).toEqual(["start:/a", "start:/b", "start:/copy"]); // A late old-project result cannot replace the current environment's checkout. await screen.emit("/a", "stale-branch"); expect(screen.current.currentCheckoutBranchName).toBe("main"); From ea0333b8fc9622fc8088a5c3598d22b5da8c9f4e Mon Sep 17 00:00:00 2001 From: Dj Gilcrease Date: Sun, 1 Nov 2026 12:42:06 -0800 Subject: [PATCH 21/21] test(cli): verify grouping inspection JSON output --- apps/server/src/cli/project.test.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index c1e9d06ef279a..d0d8abea0a2d7 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -26,6 +26,7 @@ import * as Stream from "effect/Stream"; import { Command } from "effect/cli"; import { FetchHttpClient } from "effect/http"; import * as TestClock from "effect/testing/TestClock"; +import * as TestConsole from "effect/testing/TestConsole"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cli } from "../binCli.ts"; @@ -490,8 +491,26 @@ it.effect( assert.equal(assigned[0]?.id, project.id); assert.equal(assigned[0]?.title, project.title); assert.equal(assigned[0]?.groupingIdentity, identity); - yield* runCli(["project", "grouping", project.id, "--base-dir", baseDir]); - yield* runCli(["project", "list", "--base-dir", baseDir]); + const groupingOutput = yield* runCli([ + "project", + "grouping", + project.id, + "--base-dir", + baseDir, + ]).pipe(Effect.andThen(TestConsole.logLines), Effect.provide(Layer.fresh(TestConsole.layer))); + assert.deepEqual( + groupingOutput.map((line) => JSON.parse(String(line))), + [{ projectId: project.id, groupingIdentity: identity }], + ); + const listOutput = yield* runCli(["project", "list", "--base-dir", baseDir]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.provide(Layer.fresh(TestConsole.layer)), + ); + assert.lengthOf(listOutput, 1); + const listed = JSON.parse(String(listOutput[0])); + assert.lengthOf(listed, 1); + assert.nestedPropertyVal(listed, "[0].id", project.id); + assert.nestedPropertyVal(listed, "[0].groupingIdentity", identity); yield* runCli([ "project", "grouping",