diff --git a/package.json b/package.json index 4d2e27d67..1b7426b98 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:node-pty-permissions": "node --import tsx --test src/node-pty-postinstall.test.ts", "test:physical-host-registry": "tsx src/physical-host-registry.test.ts", "start": "node dist/cli.js serve", - "test": "node scripts/check-test-chain.mjs && tsx src/local-agent-path.test.ts && npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/host-storage-retention.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/local-agent-process-tree.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-activation.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/herdr-socket-path.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts && tsx src/host-operation-external.test.ts && node --import tsx --test src/issue-413-wave1.test.ts && node --import tsx --test src/work-resume-integration.test.ts && tsx src/batch-autonomy-grant.test.ts && node --import tsx --test src/db/migrations.test.ts && node --import tsx --test src/host-capability-snapshot.test.ts && node --import tsx --test src/pi-tools.test.ts && node --import tsx --test src/work-resume.test.ts", + "test": "node scripts/check-test-chain.mjs && tsx src/local-agent-path.test.ts && npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/tool-projection-recovery.test.ts && tsx src/host-storage-retention.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/local-agent-process-tree.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-activation.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/herdr-socket-path.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts && tsx src/host-operation-external.test.ts && node --import tsx --test src/issue-413-wave1.test.ts && node --import tsx --test src/work-resume-integration.test.ts && tsx src/batch-autonomy-grant.test.ts && node --import tsx --test src/db/migrations.test.ts && node --import tsx --test src/host-capability-snapshot.test.ts && node --import tsx --test src/pi-tools.test.ts && node --import tsx --test src/work-resume.test.ts", "verify:nexus-core": "node scripts/nexus-core-verify.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test:carrier-binding": "tsx src/carrier-binding.test.ts", diff --git a/src/tool-projection-recovery.test.ts b/src/tool-projection-recovery.test.ts new file mode 100644 index 000000000..4f74a583b --- /dev/null +++ b/src/tool-projection-recovery.test.ts @@ -0,0 +1,106 @@ +// Issue #401: open_workspace(mode=worktree) emits GIT_BASE_REF_NOT_LOCAL and tells the +// caller to use git_fetch_ref. That tool must be callable on every tool surface that can +// emit the message. src/server.test.ts holds the same contract but is excluded from +// `npm test` (DS-1-F6), so this file runs it in CI without codex CLI or OAuth secrets. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { loadConfig, type ServerConfig, type ToolMode } from "./config.js"; +import { DurableOperationManager } from "./durable-operations.js"; +import { ProcessSessionManager } from "./process-sessions.js"; +import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { createMcpServer } from "./server.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceRegistry } from "./workspaces.js"; + +// Record makes the compiler reject this table if a ToolMode is added or removed. +const ALL_TOOL_MODES: Record = { minimal: true, full: true, codex: true, dispatch: true }; + +async function projectSurface(toolMode: ToolMode): Promise<{ names: string[]; instructions: string }> { + const root = await mkdtemp(join(tmpdir(), "devspace-projection-recovery-")); + const agentDir = join(root, "agent"); + const stateDir = join(root, ".state"); + await mkdir(join(root, "project"), { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + const loaded = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_WIDGETS: "full", + DEVSPACE_TOOL_MODE: toolMode, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_GIT_CANDIDATES: "false", + DEVSPACE_CHAT_SWARM: "0", + }); + const config: ServerConfig = { + ...loaded, + toolMode, + subagents: { ...loaded.subagents, enabled: false }, + }; + const store = new SqliteWorkspaceStore(stateDir); + const workspaces = new WorkspaceRegistry(config, store); + const durableOperations = new DurableOperationManager(config); + const processSessions = new ProcessSessionManager(); + const server = createMcpServer( + config, + workspaces, + createReviewCheckpointManager(), + processSessions, + () => [], + [], + undefined, + undefined, + undefined, + durableOperations, + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "devspace-projection-recovery-client", version: "1.0.0" }); + try { + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + const names = (await client.listTools()).tools.map((tool) => tool.name); + return { names, instructions: client.getInstructions() ?? "" }; + } finally { + await client.close(); + await server.close(); + durableOperations.close(); + processSessions.shutdown(); + store.close(); + await rm(root, { recursive: true, force: true }); + } +} + +test("GIT_BASE_REF_NOT_LOCAL error text names the git_fetch_ref recovery tool", () => { + const source = readFileSync(new URL("./git-worktrees.ts", import.meta.url), "utf8"); + const match = source.match(/"GIT_BASE_REF_NOT_LOCAL",\s*"\[GIT_BASE_REF_NOT_LOCAL\][^;]*?\);/); + assert.ok(match, "GIT_BASE_REF_NOT_LOCAL throw site not found in src/git-worktrees.ts"); + assert.match(match[0], /git_fetch_ref/); +}); + +for (const toolMode of Object.keys(ALL_TOOL_MODES) as ToolMode[]) { + test(`git_fetch_ref recovery is projected in ${toolMode} tool mode`, async () => { + const { names, instructions } = await projectSurface(toolMode); + const canOpenWorkspace = names.includes("open_workspace"); + if (canOpenWorkspace) { + assert.ok( + names.includes("git_fetch_ref"), + `${toolMode} lists open_workspace (can emit GIT_BASE_REF_NOT_LOCAL) but not git_fetch_ref`, + ); + } + if (instructions.includes("GIT_BASE_REF_NOT_LOCAL")) { + assert.match(instructions, /git_fetch_ref/); + assert.ok(names.includes("git_fetch_ref"), `${toolMode} instructions cite git_fetch_ref but it is not callable`); + } + // Every current mode exposes open_workspace and the recovery text; guard against a vacuous pass. + assert.ok(canOpenWorkspace, `${toolMode} no longer lists open_workspace; update this contract deliberately`); + assert.match(instructions, /GIT_BASE_REF_NOT_LOCAL/); + }); +}