From 44bb54fb69d634ba6e5946e401ef0f15945cf777 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:00:42 +0530 Subject: [PATCH 1/8] refactor(workspace): scope model context --- src/local-agent-catalog.test.ts | 3 + src/local-agent-manager.test.ts | 1 + src/local-agent-profiles.test.ts | 1 + src/local-agent-profiles.ts | 10 +- src/local-agent-targets.test.ts | 2 + src/server.test.ts | 100 +++++++++---- src/server.ts | 237 ++++++++++++++++++++----------- src/skills.test.ts | 8 ++ src/skills.ts | 3 +- src/workspaces.ts | 27 ++-- 10 files changed, 269 insertions(+), 123 deletions(-) diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts index 51a564921..fc499f8b5 100644 --- a/src/local-agent-catalog.test.ts +++ b/src/local-agent-catalog.test.ts @@ -29,6 +29,7 @@ const profiles: LocalAgentProfile[] = [ name: "reviewer", description: "Review changes.", provider: "codex", + scope: "project", filePath: "/project/reviewer.md", body: "Review only.", disabled: false, @@ -37,6 +38,7 @@ const profiles: LocalAgentProfile[] = [ name: "custom", description: "Use a custom model.", provider: "codex", + scope: "project", model: "gpt-custom", filePath: "/project/custom.md", body: "Inspect.", @@ -46,6 +48,7 @@ const profiles: LocalAgentProfile[] = [ name: "claude-reviewer", description: "Unavailable profile.", provider: "claude", + scope: "project", filePath: "/project/claude.md", body: "Review.", disabled: false, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 4ca5ed28d..6e5a4e2df 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -28,6 +28,7 @@ const profile: LocalAgentProfile = { name: "reviewer", description: "Test reviewer", provider: "codex", + scope: "project", filePath: join(root, "reviewer.md"), body: "Review only.", disabled: false, diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index d802c17f3..95beeedd8 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -68,6 +68,7 @@ try { assert.equal(profiles[0]?.name, "reviewer"); assert.equal(profiles[0]?.description, "Project reviewer #1."); assert.equal(profiles[0]?.provider, "claude"); + assert.equal(profiles[0]?.scope, "project"); assert.equal(profiles[0]?.model, "sonnet"); assert.equal(profiles[0]?.effort, "high"); assert.equal(profiles[0]?.body, "Project body."); diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index ad99a225c..9ed4a8997 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -20,6 +20,7 @@ export interface LocalAgentProfile { name: string; description: string; provider: LocalAgentProvider; + scope: "global" | "project"; model?: string; effort?: string; filePath: string; @@ -51,14 +52,14 @@ export async function loadLocalAgentProfiles( if (!config.subagents.enabled) return []; const profileDirs = [ - config.devspaceAgentsDir, - join(workspaceRoot, ".devspace", "agents"), + { path: config.devspaceAgentsDir, scope: "global" as const }, + { path: join(workspaceRoot, ".devspace", "agents"), scope: "project" as const }, ]; const profilesByName = new Map(); for (const directory of profileDirs) { - for (const profile of await loadProfilesFromDirectory(directory)) { - profilesByName.set(profile.name, profile); + for (const profile of await loadProfilesFromDirectory(directory.path)) { + profilesByName.set(profile.name, { ...profile, scope: directory.scope }); } } @@ -146,6 +147,7 @@ function profileFromFrontmatter( name, description, provider, + scope: "global", model: readString(frontmatter, "model"), effort: readString(frontmatter, "effort"), filePath, diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index fde0c000e..377f990aa 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -10,6 +10,7 @@ const profiles: LocalAgentProfile[] = [ name: "reviewer", description: "Review changes.", provider: "codex", + scope: "project", model: "gpt-5-codex", effort: "high", filePath: "/workspace/.devspace/agents/reviewer.md", @@ -20,6 +21,7 @@ const profiles: LocalAgentProfile[] = [ name: "claude", description: "A profile that shadows the raw provider.", provider: "opencode", + scope: "project", model: "qwen/custom", filePath: "/workspace/.devspace/agents/claude.md", body: "Use OpenCode.", diff --git a/src/server.test.ts b/src/server.test.ts index 79c21a66c..ae07e46d9 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -63,15 +63,19 @@ test("UI metadata is limited to workspace and aggregate review", async (t) => { } }); -test("open_workspace reports aggregate review availability", async (t) => { +test("open_workspace keeps aggregate review availability in card metadata", async (t) => { const plain = await fixture(t); const gitWorkspace = await fixture(t, { git: true }); - const plainReview = structuredContent(await callOpen(plain.client, plain.project, "plain")).review; - const gitReview = structuredContent(await callOpen(gitWorkspace.client, gitWorkspace.project, "git")).review; + const plainResult = await callOpen(plain.client, plain.project, "plain"); + const gitResult = await callOpen(gitWorkspace.client, gitWorkspace.project, "git"); + const plainReview = responseCard(plainResult).review; + const gitReview = responseCard(gitResult).review; assert.equal((plainReview as { available: boolean }).available, false); assert.deepEqual(gitReview, { available: true }); + assert.equal(structuredContent(plainResult).review, undefined); + assert.equal(structuredContent(gitResult).review, undefined); }); test("show_changes keeps model output compact and preserves the rich review card", async (t) => { @@ -165,7 +169,7 @@ test("show_changes can reopen a historical review without advancing the checkpoi ); }); -test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { +test("open_workspace returns scoped model context without internal workspace state", async (t) => { const providerNote = "available"; const context = await fixture(t, { localAgentProviders: [{ name: "codex", available: true, note: providerNote }], @@ -180,37 +184,53 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com const outputProperties = (openTool?.outputSchema as { properties?: Record } | undefined)?.properties; assert.equal(outputProperties && "workspaceReused" in outputProperties, false); assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); - const providerSchema = outputProperties?.agentProviders as { + assert.equal(outputProperties && "skillDiagnostics" in outputProperties, false); + assert.equal(outputProperties && "review" in outputProperties, false); + assert.equal(outputProperties && "instruction" in outputProperties, false); + const agentsSchema = outputProperties?.agents as { + properties?: Record; + } | undefined; + const providerSchema = agentsSchema?.properties?.providers as { items?: { properties?: Record }; } | undefined; assert.ok(providerSchema?.items?.properties?.note); const firstStructured = structuredContent(first); assert.equal(firstStructured.workspaceId, structuredContent(repeated).workspaceId); - assert.ok(Array.isArray(firstStructured.agentsFiles)); - assert.ok(Array.isArray(firstStructured.availableAgentsFiles)); - assert.ok(Array.isArray(firstStructured.skills)); - assert.ok(Array.isArray(firstStructured.agentProviders)); + const instructions = firstStructured.instructions as Record; + assert.ok(Array.isArray(instructions.global)); + const projectInstructions = instructions.project as Record; + assert.ok(Array.isArray(projectInstructions.loaded)); + assert.ok(Array.isArray(projectInstructions.available)); + assert.equal( + ((projectInstructions.loaded as Array>)[0]?.path), + "AGENTS.md", + ); + const agents = firstStructured.agents as Record; + assert.ok(Array.isArray(agents.providers)); assert.equal( - (firstStructured.agentProviders as Array>)[0]?.id, + (agents.providers as Array>)[0]?.id, "codex", ); assert.equal( - (firstStructured.agentProviders as Array>)[0]?.note, + (agents.providers as Array>)[0]?.note, providerNote, ); - assert.ok(Array.isArray(firstStructured.agents)); - assert.ok(Array.isArray(firstStructured.skillDiagnostics)); + const profiles = agents.profiles as Record; + assert.ok(Array.isArray(profiles.project)); + assert.equal(firstStructured.skillDiagnostics, undefined); + assert.equal(firstStructured.review, undefined); + assert.equal(firstStructured.instruction, undefined); assert.equal("workspaceReused" in firstStructured, false); assert.equal("includeBootstrapContext" in firstStructured, false); const repeatedStructured = structuredContent(repeated); - assert.equal(repeatedStructured.agentsFiles, undefined); - assert.equal(repeatedStructured.availableAgentsFiles, undefined); + assert.equal(repeatedStructured.instructions, undefined); assert.equal(repeatedStructured.skills, undefined); - assert.equal(repeatedStructured.agentProviders, undefined); assert.equal(repeatedStructured.agents, undefined); assert.equal(repeatedStructured.skillDiagnostics, undefined); + assert.equal(repeatedStructured.review, undefined); + assert.equal(repeatedStructured.instruction, undefined); assert.equal("workspaceReused" in repeatedStructured, false); assert.equal("includeBootstrapContext" in repeatedStructured, false); @@ -228,6 +248,36 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.agents)); }); +test("open_workspace uses workspace-relative paths for project context", async (t) => { + const context = await fixture(t); + const skillDir = join(context.project, ".agents", "skills", "project-skill"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + [ + "---", + "name: project-skill", + "description: Project-only workflow.", + "---", + "", + "# Project Skill", + ].join("\n"), + ); + + const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const instructions = opened.instructions as Record; + const projectInstructions = instructions.project as Record; + assert.equal( + (projectInstructions.loaded as Array>)[0]?.path, + "AGENTS.md", + ); + const skills = opened.skills as Record; + assert.equal( + (skills.project as Array>).find((skill) => skill.name === "project-skill")?.path, + ".agents/skills/project-skill/SKILL.md", + ); +}); + test("open_workspace refreshes provider availability for each catalog", async (t) => { let available = false; const context = await fixture(t, { @@ -235,17 +285,18 @@ test("open_workspace refreshes provider availability for each catalog", async (t }); const unavailable = structuredContent(await callOpen(context.client, context.project, "chat-1")); - assert.deepEqual(unavailable.agentProviders, []); - assert.deepEqual(unavailable.agents, []); + assert.deepEqual(unavailable.agents, {}); available = true; const usable = structuredContent(await callOpen(context.client, context.project, "chat-2")); + const usableAgents = usable.agents as Record; assert.equal( - (usable.agentProviders as Array>)[0]?.id, + (usableAgents.providers as Array>)[0]?.id, "codex", ); + const usableProfiles = usableAgents.profiles as Record; assert.equal( - (usable.agents as Array>)[0]?.name, + (usableProfiles.project as Array>)[0]?.name, "reviewer", ); }); @@ -266,8 +317,9 @@ test("open_workspace omits providers disabled by configuration", async (t) => { }); const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const agents = opened.agents as Record; assert.deepEqual( - (opened.agentProviders as Array>).map((provider) => provider.id), + (agents.providers as Array>).map((provider) => provider.id), ["codex"], ); }); @@ -280,11 +332,11 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t const unscoped = await callOpen(context.client, context.project); assert.equal(structuredContent(repeated).workspaceId, structuredContent(first).workspaceId); - assert.equal(structuredContent(repeated).agentsFiles, undefined); + assert.equal(structuredContent(repeated).instructions, undefined); assert.notEqual(structuredContent(otherSession).workspaceId, structuredContent(first).workspaceId); assert.notEqual(structuredContent(unscoped).workspaceId, structuredContent(first).workspaceId); - assert.ok(Array.isArray(structuredContent(otherSession).agentsFiles)); - assert.ok(Array.isArray(structuredContent(unscoped).agentsFiles)); + assert.ok(structuredContent(otherSession).instructions); + assert.ok(structuredContent(unscoped).instructions); }); interface ServerFixture { diff --git a/src/server.ts b/src/server.ts index 9e7ded7fd..f77712350 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,6 +43,7 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { openAiConversationScopeId } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; +import { isPathInsideRoot } from "./roots.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { @@ -112,31 +113,6 @@ function serverInstructions( return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } -function formatVisibleAgent(agent: { - name: string; - provider: string; - model?: string; - effort?: string; -}): string { - const model = agent.model ? `, model ${agent.model}` : ""; - const effort = agent.effort ? `, effort ${agent.effort}` : ""; - return `${agent.name} (${agent.provider}${model}${effort})`; -} - -function formatAvailableAgentProvider(provider: { - id: string; - model?: string; - effort?: string; - note?: string; -}): string { - const details = [ - provider.model ? `model ${provider.model}` : undefined, - provider.effort ? `effort ${provider.effort}` : undefined, - provider.note, - ].filter(Boolean).join(", "); - return `${provider.id}${details ? ` (${details})` : ""}`; -} - const workspaceSkillOutputSchema = z.object({ name: z.string(), description: z.string(), @@ -167,6 +143,135 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const workspaceInstructionsOutputSchema = z.object({ + global: z.array(workspaceAgentsFileOutputSchema).optional(), + project: z.object({ + loaded: z.array(workspaceAgentsFileOutputSchema), + available: z.array(workspaceAvailableAgentsFileOutputSchema), + }).optional(), +}); + +const workspaceSkillsOutputSchema = z.object({ + global: z.array(workspaceSkillOutputSchema).optional(), + project: z.array(workspaceSkillOutputSchema).optional(), +}); + +const workspaceAgentsOutputSchema = z.object({ + profiles: z.object({ + global: z.array(workspaceLocalAgentOutputSchema).optional(), + project: z.array(workspaceLocalAgentOutputSchema).optional(), + }).optional(), + providers: z.array(workspaceLocalAgentProviderOutputSchema).optional(), +}); + +function formatWorkspaceResourcePath(path: string, workspaceRoot: string): string { + return isPathInsideRoot(path, workspaceRoot) + ? formatAgentsPath(path, workspaceRoot) + : formatPathForPrompt(path); +} + +function scopedInstructions( + loaded: Array<{ path: string; content: string }>, + available: Array<{ path: string }>, + workspaceRoot: string, +): { + global?: Array<{ path: string; content: string }>; + project?: { + loaded: Array<{ path: string; content: string }>; + available: Array<{ path: string }>; + }; +} { + const global = loaded + .filter((file) => !isPathInsideRoot(file.path, workspaceRoot)) + .map((file) => ({ + path: formatWorkspaceResourcePath(file.path, workspaceRoot), + content: file.content, + })); + const projectLoaded = loaded + .filter((file) => isPathInsideRoot(file.path, workspaceRoot)) + .map((file) => ({ + path: formatWorkspaceResourcePath(file.path, workspaceRoot), + content: file.content, + })); + const projectAvailable = available.map((file) => ({ + path: formatWorkspaceResourcePath(file.path, workspaceRoot), + })); + + return { + ...(global.length > 0 ? { global } : {}), + ...(projectLoaded.length > 0 || projectAvailable.length > 0 + ? { + project: { + loaded: projectLoaded, + available: projectAvailable, + }, + } + : {}), + }; +} + +function scopedSkills( + skills: Array<{ + name: string; + description: string; + path: string; + scope: "global" | "project"; + }>, +): { + global?: Array<{ name: string; description: string; path: string }>; + project?: Array<{ name: string; description: string; path: string }>; +} { + const summarize = (scope: "global" | "project") => skills + .filter((skill) => skill.scope === scope) + .map(({ scope: _scope, ...skill }) => skill); + const global = summarize("global"); + const project = summarize("project"); + return { + ...(global.length > 0 ? { global } : {}), + ...(project.length > 0 ? { project } : {}), + }; +} + +function scopedAgentCatalog( + profiles: Array<{ + name: string; + description: string; + provider: string; + scope: "global" | "project"; + model?: string; + effort?: string; + }>, + providers: Array<{ + id: string; + model?: string; + effort?: string; + note?: string; + }>, +): { + profiles?: { + global?: Array<{ name: string; description: string; provider: string; model?: string; effort?: string }>; + project?: Array<{ name: string; description: string; provider: string; model?: string; effort?: string }>; + }; + providers?: Array<{ id: string; model?: string; effort?: string; note?: string }>; +} { + const summarize = (scope: "global" | "project") => profiles + .filter((profile) => profile.scope === scope) + .map(({ scope: _scope, ...profile }) => profile); + const global = summarize("global"); + const project = summarize("project"); + return { + ...(global.length > 0 || project.length > 0 + ? { + profiles: { + ...(global.length > 0 ? { global } : {}), + ...(project.length > 0 ? { project } : {}), + }, + } + : {}), + ...(providers.length > 0 ? { providers } : {}), + }; +} + function sendJsonRpcError( res: Response, status: number, @@ -370,20 +475,9 @@ export function createMcpServer( managed: z.boolean(), }) .optional(), - agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), - availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), - skills: z.array(workspaceSkillOutputSchema).optional(), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), - agents: z.array(workspaceLocalAgentOutputSchema).optional(), - skillDiagnostics: z.array(z.unknown()).optional(), - review: z.discriminatedUnion("available", [ - z.object({ available: z.literal(true) }), - z.object({ - available: z.literal(false), - reason: z.string(), - }), - ]), - instruction: z.string(), + instructions: workspaceInstructionsOutputSchema.optional(), + skills: workspaceSkillsOutputSchema.optional(), + agents: workspaceAgentsOutputSchema.optional(), }, ...workspaceAppDescriptorMeta(config), annotations: { readOnlyHint: true }, @@ -404,13 +498,15 @@ export function createMcpServer( workspaceId: workspace.id, root: workspace.root, }); - const cardSkills = workspace.skills + const scopedSkillCatalog = workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ name: skill.name, description: skill.description, - path: formatPathForPrompt(skill.filePath), + path: formatWorkspaceResourcePath(skill.filePath, workspace.root), + scope: isPathInsideRoot(skill.filePath, workspace.root) ? "project" as const : "global" as const, })); + const cardSkills = scopedSkillCatalog.map(({ scope: _scope, ...skill }) => skill); const agentCatalog = buildLocalAgentCatalog( config.subagents, workspace.agentProfiles, @@ -424,31 +520,22 @@ export function createMcpServer( effort: provider.effort, note: provider.note, })); - const cardAgents = agentCatalog.profiles; + const profileScopes = new Map(workspace.agentProfiles.map((profile) => [profile.name, profile.scope])); + const scopedAgents = agentCatalog.profiles.map((profile) => ({ + ...profile, + scope: profileScopes.get(profile.name) ?? "global" as const, + })); + const cardAgents = scopedAgents.map(({ scope: _scope, ...profile }) => profile); const cardAgentsFiles = agentsFiles.map((file) => ({ - path: formatAgentsPath(file.path, workspace.root), + path: formatWorkspaceResourcePath(file.path, workspace.root), content: file.content, })); const cardAvailableAgentsFiles = availableAgentsFiles.map((file) => ({ - path: formatAgentsPath(file.path, workspace.root), + path: formatWorkspaceResourcePath(file.path, workspace.root), })); - const visibleSkills = includeBootstrapContext ? cardSkills : []; - const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : []; - const visibleAgents = includeBootstrapContext ? cardAgents : []; - const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; - const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; const cardInstruction = config.skillsEnabled ? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; - const instruction = workspaceReused - ? [ - `Workspace already open as ${workspace.id}.`, - "Continue with this workspaceId.", - "Keep following the project instructions, nested instruction files, skills, agent profiles, and diagnostics already provided for this workspace.", - ].join("\n\n") - : workspace.mode === "worktree" - ? "Use this workspaceId for subsequent work in this isolated worktree. Keep reusing it while working in this worktree. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for it." - : cardInstruction; const resultContent: ToolContent[] = [ { type: "text" as const, @@ -460,22 +547,9 @@ export function createMcpServer( : `Opened workspace ${workspace.id}.`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, - loadedAgentsFiles.length > 0 - ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` - : undefined, - availableAgentsFileOutputs.length > 0 - ? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` - : undefined, - visibleSkills.length > 0 - ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` - : undefined, - visibleAgentProviders.length > 0 - ? `Available subagent providers: ${visibleAgentProviders.map(formatAvailableAgentProvider).join(", ")}` - : undefined, - visibleAgents.length > 0 - ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` - : undefined, - instruction, + workspaceReused + ? "Continue using this workspaceId for work in this workspace." + : "Use this workspaceId for work in this workspace.", ].filter(Boolean).join("\n"), }, ]; @@ -522,18 +596,17 @@ export function createMcpServer( mode: workspace.mode, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, - review, ...(includeBootstrapContext ? { - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, + instructions: scopedInstructions( + agentsFiles, + availableAgentsFiles, + workspace.root, + ), + skills: scopedSkills(scopedSkillCatalog), + agents: scopedAgentCatalog(scopedAgents, cardAgentProviders), } : {}), - instruction, }, }; }, diff --git a/src/skills.test.ts b/src/skills.test.ts index 41556b3ad..91aacc605 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -235,6 +235,14 @@ try { const skillFileRead = resolveSkillReadPath(loaded.skills, new Set(), projectSkill.filePath); assert.equal(skillFileRead?.isSkillFile, true); assert.equal(skillFileRead?.absolutePath, projectSkill.filePath); + const relativeSkillFileRead = resolveSkillReadPath( + loaded.skills, + new Set(), + ".agents/skills/agent-project-skill/SKILL.md", + projectRoot, + ); + assert.equal(relativeSkillFileRead?.isSkillFile, true); + assert.equal(relativeSkillFileRead?.absolutePath, projectSkill.filePath); const resourcePath = join(projectSkill.baseDir, "references.md"); await writeFile(resourcePath, "reference\n"); diff --git a/src/skills.ts b/src/skills.ts index cf4fa3322..f838e1a80 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -86,8 +86,9 @@ export function resolveSkillReadPath( skills: Skill[], activatedSkillDirs: Set, inputPath: string, + cwd?: string, ): SkillReadResolution | undefined { - const absolutePath = resolve(expandHomePath(inputPath)); + const absolutePath = resolve(cwd ?? process.cwd(), expandHomePath(inputPath)); for (const skill of skills) { const skillFilePath = resolve(skill.filePath); diff --git a/src/workspaces.ts b/src/workspaces.ts index 307626489..f97ff02f5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -293,24 +293,27 @@ export class WorkspaceRegistry { } resolveReadPath(workspace: Workspace, inputPath: string): WorkspaceReadPath { + const skillRead = resolveSkillReadPath( + workspace.skills, + workspace.activatedSkillDirs, + inputPath, + workspace.root, + ); + if (skillRead) { + return { + absolutePath: skillRead.absolutePath, + readRoots: [workspace.root, skillRead.skill.baseDir], + skillRead, + }; + } + try { return { absolutePath: this.resolvePath(workspace, inputPath), readRoots: [workspace.root], }; } catch (workspaceError) { - const skillRead = resolveSkillReadPath( - workspace.skills, - workspace.activatedSkillDirs, - inputPath, - ); - if (!skillRead) throw workspaceError; - - return { - absolutePath: skillRead.absolutePath, - readRoots: [workspace.root, skillRead.skill.baseDir], - skillRead, - }; + throw workspaceError; } } From e598c4cc93c7f1bc53ccc996251034f0c57454a9 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:06:21 +0530 Subject: [PATCH 2/8] feat(workspace): persist context fingerprints --- src/db/migrations.ts | 18 +++++++++++++ src/db/schema.ts | 16 ++++++++++++ src/oauth-store.test.ts | 1 + src/workspace-store.ts | 58 +++++++++++++++++++++++++++++++++++++++++ src/workspaces.ts | 26 ++++++++++++++++++ 5 files changed, 119 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index df192caa0..55075c741 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -37,6 +37,11 @@ const migrations: Migration[] = [ name: "local-agent-effort-rename", up: migrateLocalAgentEffortRename, }, + { + version: 7, + name: "workspace-conversation-contexts", + up: migrateWorkspaceConversationContexts, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -208,6 +213,19 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { `); } +function migrateWorkspaceConversationContexts(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_contexts ( + conversation_scope_id text not null, + context_key text not null, + fingerprint text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_id, context_key) + ); + `); +} + function migrateLocalAgentStructuredErrors(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "local_agent_sessions", "error_code", "text"); addColumnIfMissing(sqlite, "local_agent_sessions", "error_retryable", "text"); diff --git a/src/db/schema.ts b/src/db/schema.ts index c16da8925..549791f5d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,6 +55,20 @@ export const workspaceConversationBindings = sqliteTable( ], ); +export const workspaceConversationContexts = sqliteTable( + "workspace_conversation_contexts", + { + conversationScopeId: text("conversation_scope_id").notNull(), + contextKey: text("context_key").notNull(), + fingerprint: text("fingerprint").notNull(), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeId, table.contextKey] }), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -122,5 +136,7 @@ export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; +export type WorkspaceConversationContextRow = typeof workspaceConversationContexts.$inferSelect; +export type NewWorkspaceConversationContextRow = typeof workspaceConversationContexts.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 225f9fdf5..f6db5ee83 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -47,6 +47,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 4, name: "workspace-conversation-bindings" }, { version: 5, name: "local-agent-structured-errors" }, { version: 6, name: "local-agent-effort-rename" }, + { version: 7, name: "workspace-conversation-contexts" }, ]); } finally { database.close(); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 88a70e2e5..0c1c1ff5a 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceConversationBindings, + workspaceConversationContexts, workspaceSessions, type WorkspaceConversationBindingRow, type WorkspaceSessionRow, @@ -53,6 +54,10 @@ export interface WorkspaceStore { }): WorkspaceConversationBinding; touchConversationBinding(conversationScopeId: string, targetKey: string): void; deleteConversationBinding(conversationScopeId: string, targetKey: string): void; + claimConversationContexts( + conversationScopeId: string, + contexts: Array<{ contextKey: string; fingerprint: string }>, + ): string[]; close?(): void; } @@ -201,6 +206,59 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + claimConversationContexts( + conversationScopeId: string, + contexts: Array<{ contextKey: string; fingerprint: string }>, + ): string[] { + const claim = this.database.sqlite.transaction(() => { + const select = this.database.sqlite.prepare(` + select fingerprint + from workspace_conversation_contexts + where conversation_scope_id = ? and context_key = ? + `); + const changed: string[] = []; + + for (const { contextKey, fingerprint } of contexts) { + const existing = select.get(conversationScopeId, contextKey) as { fingerprint: string } | undefined; + const now = new Date().toISOString(); + + if (!existing) { + this.database.db + .insert(workspaceConversationContexts) + .values({ + conversationScopeId, + contextKey, + fingerprint, + createdAt: now, + lastUsedAt: now, + }) + .run(); + changed.push(contextKey); + continue; + } + + this.database.db + .update(workspaceConversationContexts) + .set({ + ...(existing.fingerprint === fingerprint ? {} : { fingerprint }), + lastUsedAt: now, + }) + .where( + and( + eq(workspaceConversationContexts.conversationScopeId, conversationScopeId), + eq(workspaceConversationContexts.contextKey, contextKey), + ), + ) + .run(); + if (existing.fingerprint !== fingerprint) changed.push(contextKey); + } + + return changed; + }); + + return claim.immediate(); + } + close(): void { this.database.close(); } diff --git a/src/workspaces.ts b/src/workspaces.ts index f97ff02f5..362c3d906 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -64,6 +64,8 @@ export interface WorkspaceContext { availableAgentsFiles: AvailableAgentsFile[]; workspaceReused: boolean; includeBootstrapContext: boolean; + conversationScopeId?: string; + projectKey?: string; } export interface WorkspaceReadPath { @@ -113,6 +115,8 @@ export class WorkspaceRegistry { const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); return { ...context, + conversationScopeId, + projectKey, // A new worktree always has its own workspace-specific context. includeBootstrapContext: true, }; @@ -134,6 +138,7 @@ export class WorkspaceRegistry { workspaceInput, conversationScopeId, targetKey, + projectKey, ); this.pendingCheckoutOpens.set(operationKey, open); @@ -160,6 +165,7 @@ export class WorkspaceRegistry { input: OpenWorkspaceInput, conversationScopeId: string, targetKey: string, + projectKey: string, ): Promise { const binding = this.store?.getConversationBinding(conversationScopeId, targetKey); if (binding) { @@ -170,6 +176,8 @@ export class WorkspaceRegistry { this.store?.touchConversationBinding(conversationScopeId, targetKey); return { ...context, + conversationScopeId, + projectKey, includeBootstrapContext: false, }; } @@ -186,10 +194,25 @@ export class WorkspaceRegistry { }); return { ...context, + conversationScopeId, + projectKey, includeBootstrapContext: true, }; } + claimConversationContexts( + context: WorkspaceContext, + contexts: Array<{ contextKey: string; fingerprint: string }>, + ): Set { + if (!context.conversationScopeId || !this.store) { + return new Set(contexts.map(({ contextKey }) => contextKey)); + } + return new Set(this.store.claimConversationContexts( + context.conversationScopeId, + contexts, + )); + } + private async findReusableCheckoutWorkspace( binding: WorkspaceConversationBinding, ): Promise { @@ -229,6 +252,9 @@ export class WorkspaceRegistry { } private async reusedWorkspaceContext(workspace: Workspace): Promise { + const loadedSkills = this.loadSkillsForWorkspace(workspace.root); + workspace.skills = loadedSkills.skills; + workspace.skillDiagnostics = loadedSkills.skillDiagnostics; workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); From ce867a43fc66885672b1b4ad52dc408ffd115cdd Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:06:34 +0530 Subject: [PATCH 3/8] feat(workspace): dedupe scoped context --- src/server.test.ts | 130 ++++++++++++++++++++++++++++++++- src/server.ts | 177 +++++++++++++++++++++++++++++++++------------ 2 files changed, 257 insertions(+), 50 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index ae07e46d9..27c74fe69 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -278,6 +278,90 @@ test("open_workspace uses workspace-relative paths for project context", async ( ); }); +test("open_workspace re-emits changed project instructions without repeating global context", async (t) => { + const context = await fixture(t); + await callOpen(context.client, context.project, "chat-1"); + await writeFile(join(context.project, "AGENTS.md"), "updated project instructions\n"); + + const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const instructions = reopened.instructions as Record; + assert.equal(instructions.global, undefined); + const project = instructions.project as Record; + assert.equal( + (project.loaded as Array>)[0]?.content, + "updated project instructions\n", + ); + assert.equal(reopened.skills, undefined); + assert.equal(reopened.agents, undefined); +}); + +test("open_workspace re-emits changed global instructions without repeating project context", async (t) => { + const context = await fixture(t); + await callOpen(context.client, context.project, "chat-1"); + await writeFile(join(context.config.agentDir, "AGENTS.md"), "updated global instructions\n"); + + const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const instructions = reopened.instructions as Record; + assert.equal(instructions.project, undefined); + assert.equal( + (instructions.global as Array>)[0]?.content, + "updated global instructions\n", + ); + assert.equal(reopened.skills, undefined); + assert.equal(reopened.agents, undefined); +}); + +test("open_workspace refreshes project skills before selective delivery", async (t) => { + const context = await fixture(t); + await callOpen(context.client, context.project, "chat-1"); + const skillDir = join(context.project, ".agents", "skills", "new-skill"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + [ + "---", + "name: new-skill", + "description: Added after the workspace was opened.", + "---", + "", + "# New Skill", + ].join("\n"), + ); + + const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const skills = reopened.skills as Record; + assert.equal(skills.global, undefined); + assert.equal( + (skills.project as Array>)[0]?.path, + ".agents/skills/new-skill/SKILL.md", + ); + assert.equal(reopened.instructions, undefined); + assert.equal(reopened.agents, undefined); +}); + +test("a conversation reuses global context when opening another project", async (t) => { + const context = await fixture(t); + const otherProject = join(context.project, "..", "other-project"); + await mkdir(otherProject, { recursive: true }); + await writeFile(join(otherProject, "AGENTS.md"), "other project instructions\n"); + + await callOpen(context.client, context.project, "chat-1"); + const opened = structuredContent(await callOpen(context.client, otherProject, "chat-1")); + const instructions = opened.instructions as Record; + assert.equal(instructions.global, undefined); + const project = instructions.project as Record; + assert.equal( + (project.loaded as Array>)[0]?.content, + "other project instructions\n", + ); + const skills = opened.skills as Record; + assert.equal(skills.global, undefined); + const agents = opened.agents as Record; + assert.equal(agents.providers, undefined); + const profiles = agents.profiles as Record; + assert.equal(profiles.global, undefined); +}); + test("open_workspace refreshes provider availability for each catalog", async (t) => { let available = false; const context = await fixture(t, { @@ -285,11 +369,16 @@ test("open_workspace refreshes provider availability for each catalog", async (t }); const unavailable = structuredContent(await callOpen(context.client, context.project, "chat-1")); - assert.deepEqual(unavailable.agents, {}); + const unavailableAgents = unavailable.agents as Record; + assert.deepEqual(unavailableAgents.providers, []); + const unavailableProfiles = unavailableAgents.profiles as Record; + assert.deepEqual(unavailableProfiles.project, []); available = true; - const usable = structuredContent(await callOpen(context.client, context.project, "chat-2")); + const usable = structuredContent(await callOpen(context.client, context.project, "chat-1")); const usableAgents = usable.agents as Record; + assert.equal(usable.instructions, undefined); + assert.equal(usable.skills, undefined); assert.equal( (usableAgents.providers as Array>)[0]?.id, "codex", @@ -339,9 +428,38 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t assert.ok(structuredContent(unscoped).instructions); }); +test("open_workspace reuses unchanged context when switching to worktree mode", async (t) => { + const context = await fixture(t, { git: true }); + const checkout = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const worktree = structuredContent( + await callOpen(context.client, context.project, "chat-1", "worktree"), + ); + + assert.notEqual(worktree.workspaceId, checkout.workspaceId); + assert.equal(worktree.mode, "worktree"); + assert.equal(worktree.instructions, undefined); + assert.equal(worktree.skills, undefined); + assert.equal(worktree.agents, undefined); +}); + +test("concurrent checkout opens deliver context once", async (t) => { + const context = await fixture(t); + const [first, second] = await Promise.all([ + callOpen(context.client, context.project, "chat-1"), + callOpen(context.client, context.project, "chat-1"), + ]); + + assert.equal(structuredContent(first).workspaceId, structuredContent(second).workspaceId); + assert.equal( + [first, second].filter((result) => structuredContent(result).instructions !== undefined).length, + 1, + ); +}); + interface ServerFixture { client: Client; project: string; + config: ServerConfig; } async function fixture( @@ -446,7 +564,7 @@ async function fixture( await rm(root, { recursive: true, force: true }); }); - return { client, project }; + return { client, project, config }; } async function git(cwd: string, args: string[]): Promise { @@ -457,10 +575,14 @@ async function callOpen( client: Client, path: string, conversationScopeId?: string, + mode?: "checkout" | "worktree", ): Promise>> { const params = { name: "open_workspace", - arguments: { path }, + arguments: { + path, + ...(mode ? { mode } : {}), + }, ...(conversationScopeId ? { _meta: { "openai/session": conversationScopeId } } : {}), diff --git a/src/server.ts b/src/server.ts index f77712350..c907c1a6b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { access, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; @@ -45,7 +45,11 @@ import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { isPathInsideRoot } from "./roots.js"; import { createWorkspaceStore } from "./workspace-store.js"; -import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { + formatAgentsPath, + WorkspaceRegistry, + type WorkspaceContext, +} from "./workspaces.js"; import { getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; @@ -175,8 +179,8 @@ function scopedInstructions( available: Array<{ path: string }>, workspaceRoot: string, ): { - global?: Array<{ path: string; content: string }>; - project?: { + global: Array<{ path: string; content: string }>; + project: { loaded: Array<{ path: string; content: string }>; available: Array<{ path: string }>; }; @@ -198,15 +202,11 @@ function scopedInstructions( })); return { - ...(global.length > 0 ? { global } : {}), - ...(projectLoaded.length > 0 || projectAvailable.length > 0 - ? { - project: { - loaded: projectLoaded, - available: projectAvailable, - }, - } - : {}), + global, + project: { + loaded: projectLoaded, + available: projectAvailable, + }, }; } @@ -218,18 +218,15 @@ function scopedSkills( scope: "global" | "project"; }>, ): { - global?: Array<{ name: string; description: string; path: string }>; - project?: Array<{ name: string; description: string; path: string }>; + global: Array<{ name: string; description: string; path: string }>; + project: Array<{ name: string; description: string; path: string }>; } { const summarize = (scope: "global" | "project") => skills .filter((skill) => skill.scope === scope) .map(({ scope: _scope, ...skill }) => skill); const global = summarize("global"); const project = summarize("project"); - return { - ...(global.length > 0 ? { global } : {}), - ...(project.length > 0 ? { project } : {}), - }; + return { global, project }; } function scopedAgentCatalog( @@ -248,11 +245,11 @@ function scopedAgentCatalog( note?: string; }>, ): { - profiles?: { - global?: Array<{ name: string; description: string; provider: string; model?: string; effort?: string }>; - project?: Array<{ name: string; description: string; provider: string; model?: string; effort?: string }>; + profiles: { + global: Array<{ name: string; description: string; provider: string; model?: string; effort?: string }>; + project: Array<{ name: string; description: string; provider: string; model?: string; effort?: string }>; }; - providers?: Array<{ id: string; model?: string; effort?: string; note?: string }>; + providers: Array<{ id: string; model?: string; effort?: string; note?: string }>; } { const summarize = (scope: "global" | "project") => profiles .filter((profile) => profile.scope === scope) @@ -260,16 +257,100 @@ function scopedAgentCatalog( const global = summarize("global"); const project = summarize("project"); return { - ...(global.length > 0 || project.length > 0 - ? { - profiles: { - ...(global.length > 0 ? { global } : {}), - ...(project.length > 0 ? { project } : {}), - }, - } + profiles: { global, project }, + providers, + }; +} + +interface WorkspaceContextSnapshots { + instructions: ReturnType; + skills: ReturnType; + agents: ReturnType; +} + +function conversationContextOutput( + workspaces: WorkspaceRegistry, + context: WorkspaceContext, + snapshots: WorkspaceContextSnapshots, +): { + instructions?: { + global?: WorkspaceContextSnapshots["instructions"]["global"]; + project?: WorkspaceContextSnapshots["instructions"]["project"]; + }; + skills?: { + global?: WorkspaceContextSnapshots["skills"]["global"]; + project?: WorkspaceContextSnapshots["skills"]["project"]; + }; + agents?: { + profiles?: { + global?: WorkspaceContextSnapshots["agents"]["profiles"]["global"]; + project?: WorkspaceContextSnapshots["agents"]["profiles"]["project"]; + }; + providers?: WorkspaceContextSnapshots["agents"]["providers"]; + }; +} { + const projectKey = context.projectKey + ?? context.workspace.sourceRoot + ?? context.workspace.root; + const keys = { + globalInstructions: JSON.stringify(["global", "instructions"]), + projectInstructions: JSON.stringify(["project", projectKey, "instructions"]), + globalSkills: JSON.stringify(["global", "skills"]), + projectSkills: JSON.stringify(["project", projectKey, "skills"]), + globalProfiles: JSON.stringify(["global", "agent-profiles"]), + projectProfiles: JSON.stringify(["project", projectKey, "agent-profiles"]), + providers: JSON.stringify(["global", "agent-providers"]), + }; + const values = new Map([ + [keys.globalInstructions, snapshots.instructions.global], + [keys.projectInstructions, snapshots.instructions.project], + [keys.globalSkills, snapshots.skills.global], + [keys.projectSkills, snapshots.skills.project], + [keys.globalProfiles, snapshots.agents.profiles.global], + [keys.projectProfiles, snapshots.agents.profiles.project], + [keys.providers, snapshots.agents.providers], + ]); + const changed = workspaces.claimConversationContexts( + context, + Array.from(values, ([contextKey, value]) => ({ + contextKey, + fingerprint: contextFingerprint(value), + })), + ); + const instructions = { + ...(changed.has(keys.globalInstructions) + ? { global: snapshots.instructions.global } : {}), - ...(providers.length > 0 ? { providers } : {}), + ...(changed.has(keys.projectInstructions) + ? { project: snapshots.instructions.project } + : {}), + }; + const skills = { + ...(changed.has(keys.globalSkills) ? { global: snapshots.skills.global } : {}), + ...(changed.has(keys.projectSkills) ? { project: snapshots.skills.project } : {}), + }; + const profiles = { + ...(changed.has(keys.globalProfiles) + ? { global: snapshots.agents.profiles.global } + : {}), + ...(changed.has(keys.projectProfiles) + ? { project: snapshots.agents.profiles.project } + : {}), + }; + const agents = { + ...(Object.keys(profiles).length > 0 ? { profiles } : {}), + ...(changed.has(keys.providers) ? { providers: snapshots.agents.providers } : {}), }; + + return { + ...(Object.keys(instructions).length > 0 ? { instructions } : {}), + ...(Object.keys(skills).length > 0 ? { skills } : {}), + ...(Object.keys(agents).length > 0 ? { agents } : {}), + }; +} + +function contextFingerprint(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } function sendJsonRpcError( @@ -484,16 +565,17 @@ export function createMcpServer( }, async ({ path, mode, baseRef }, { _meta }) => { const startedAt = performance.now(); + const workspaceContext = await workspaces.openWorkspace( + { path, mode, baseRef }, + { conversationScopeId: openAiConversationScopeId(_meta) }, + ); const { workspace, agentsFiles, availableAgentsFiles, workspaceReused, includeBootstrapContext, - } = await workspaces.openWorkspace( - { path, mode, baseRef }, - { conversationScopeId: openAiConversationScopeId(_meta) }, - ); + } = workspaceContext; const review = await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, @@ -536,6 +618,19 @@ export function createMcpServer( const cardInstruction = config.skillsEnabled ? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const modelContext = conversationContextOutput( + workspaces, + workspaceContext, + { + instructions: scopedInstructions( + agentsFiles, + availableAgentsFiles, + workspace.root, + ), + skills: scopedSkills(scopedSkillCatalog), + agents: scopedAgentCatalog(scopedAgents, cardAgentProviders), + }, + ); const resultContent: ToolContent[] = [ { type: "text" as const, @@ -596,17 +691,7 @@ export function createMcpServer( mode: workspace.mode, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, - ...(includeBootstrapContext - ? { - instructions: scopedInstructions( - agentsFiles, - availableAgentsFiles, - workspace.root, - ), - skills: scopedSkills(scopedSkillCatalog), - agents: scopedAgentCatalog(scopedAgents, cardAgentProviders), - } - : {}), + ...modelContext, }, }; }, From ea5f62908f171eb2781f2c8b88878d9c34b9dbd1 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:09:26 +0530 Subject: [PATCH 4/8] fix(ui): decode scoped workspace context --- src/ui/tool-result.test.ts | 28 ++++++++++++++++++++------ src/ui/tool-result.ts | 40 +++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/ui/tool-result.test.ts b/src/ui/tool-result.test.ts index b7c5315dd..fd5116861 100644 --- a/src/ui/tool-result.test.ts +++ b/src/ui/tool-result.test.ts @@ -13,10 +13,23 @@ test("workspace cards can be rebuilt from structured content without result meta workspaceId: "ws_1", root: "/tmp/project", mode: "checkout", - skills: [{ name: "tdd", description: "Tests first", path: "/tmp/tdd/SKILL.md" }], - agentsFiles: [{ path: "AGENTS.md", content: "instructions" }], - review: { available: true }, - instruction: "Reuse this workspace.", + instructions: { + global: [{ path: "~/.codex/AGENTS.md", content: "global instructions" }], + project: { + loaded: [{ path: "AGENTS.md", content: "project instructions" }], + available: [{ path: "src/AGENTS.md" }], + }, + }, + skills: { + global: [{ name: "tdd", description: "Tests first", path: "~/.agents/skills/tdd/SKILL.md" }], + project: [{ name: "release", description: "Release flow", path: ".agents/skills/release/SKILL.md" }], + }, + agents: { + profiles: { + project: [{ name: "reviewer", description: "Review", provider: "codex" }], + }, + providers: [{ id: "codex" }], + }, }, }); @@ -24,8 +37,11 @@ test("workspace cards can be rebuilt from structured content without result meta if (decoded.kind !== "card") return; assert.equal(decoded.card.tool, "open_workspace"); assert.equal(decoded.card.workspaceId, "ws_1"); - assert.equal(decoded.card.summary?.skills, 1); - assert.equal(decoded.card.summary?.agentsFiles, 1); + assert.equal(decoded.card.summary?.skills, 2); + assert.equal(decoded.card.summary?.agentsFiles, 2); + assert.equal(decoded.card.summary?.availableAgentsFiles, 1); + assert.equal(decoded.card.summary?.agentProviders, 1); + assert.equal(decoded.card.summary?.agents, 1); }); test("review results use rich metadata when the host provides it", () => { diff --git a/src/ui/tool-result.ts b/src/ui/tool-result.ts index efd1bdb4e..a486cb0b9 100644 --- a/src/ui/tool-result.ts +++ b/src/ui/tool-result.ts @@ -130,25 +130,55 @@ function mcpToolResult(value: unknown): CallToolResult | undefined { function cardFields(record: Record | undefined): Partial | undefined { if (!record) return undefined; - const agentsFiles = arrayRecords(record.agentsFiles)?.map((item) => ({ + const instructionScopes = asRecord(record.instructions); + const projectInstructions = asRecord(instructionScopes?.project); + const scopedAgentsFiles = instructionScopes + ? [ + ...(arrayRecords(instructionScopes.global) ?? []), + ...(arrayRecords(projectInstructions?.loaded) ?? []), + ] + : undefined; + const agentsFileRecords = arrayRecords(record.agentsFiles) ?? scopedAgentsFiles; + const agentsFiles = agentsFileRecords?.map((item) => ({ path: stringField(item.path), content: stringField(item.content), })); - const availableAgentsFiles = arrayRecords(record.availableAgentsFiles)?.map((item) => ({ + const availableAgentFileRecords = arrayRecords(record.availableAgentsFiles) + ?? arrayRecords(projectInstructions?.available); + const availableAgentsFiles = availableAgentFileRecords?.map((item) => ({ path: stringField(item.path), })); - const skills = arrayRecords(record.skills)?.map((item) => ({ + const skillScopes = asRecord(record.skills); + const scopedSkillRecords = skillScopes + ? [ + ...(arrayRecords(skillScopes.global) ?? []), + ...(arrayRecords(skillScopes.project) ?? []), + ] + : undefined; + const skillRecords = arrayRecords(record.skills) ?? scopedSkillRecords; + const skills = skillRecords?.map((item) => ({ name: stringField(item.name), description: stringField(item.description), path: stringField(item.path), })); - const agentProviders = arrayRecords(record.agentProviders)?.map((item) => ({ + const agentScopes = asRecord(record.agents); + const profileScopes = asRecord(agentScopes?.profiles); + const agentProviderRecords = arrayRecords(record.agentProviders) + ?? arrayRecords(agentScopes?.providers); + const agentProviders = agentProviderRecords?.map((item) => ({ id: stringField(item.id), model: stringField(item.model), effort: stringField(item.effort), note: stringField(item.note), })); - const agents = arrayRecords(record.agents)?.map((item) => ({ + const scopedAgentRecords = profileScopes + ? [ + ...(arrayRecords(profileScopes.global) ?? []), + ...(arrayRecords(profileScopes.project) ?? []), + ] + : undefined; + const agentRecords = arrayRecords(record.agents) ?? scopedAgentRecords; + const agents = agentRecords?.map((item) => ({ name: stringField(item.name), description: stringField(item.description), provider: stringField(item.provider), From a8c75304864fdb33ca88bdec26b03c2b83f6d029 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:10:24 +0530 Subject: [PATCH 5/8] docs(workspace): retain scoped context --- docs/chatgpt-coding-workflow.md | 45 ++++++++++++++++++++------------- docs/gotchas.md | 27 +++++++++++++------- src/server.ts | 6 ++--- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index f6826617c..f4ef3add6 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -21,25 +21,27 @@ ChatGPT may support automatic checkout recovery through optional host conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP conversation field. When that optional context is available, opening the same checkout project again in the same conversation can continue in the existing -workspace, and the context already provided for that reused checkout is not -repeated. The portable workflow remains the same: keep using the `workspaceId` -returned by `open_workspace` for later operations. Hosts without supported -conversation context receive a normal new workspace and continue with that -explicit `workspaceId` workflow. +workspace. DevSpace also remembers which global and project context snapshots +have already been delivered in that conversation, so unchanged instructions, +skills, agent profiles, and provider information are not repeated across +checkout/worktree switches or project changes. The portable workflow remains +the same: keep using the `workspaceId` returned by `open_workspace` for later +operations. Hosts without supported conversation context receive a normal new +workspace and complete context on each open. The model receives actionable workspace instructions; automatic-reuse bookkeeping is not a model-facing choice. -Worktree mode is deliberately different: every call creates a new managed -worktree and a new workspace session with complete context, even for the same -path and base ref. +Worktree mode still creates a new managed worktree and workspace session on +every call. Model context is independent from that workspace lifecycle: if a +global or project scope is unchanged from an earlier open in the conversation, +the new worktree result can omit it. If a branch or filesystem change alters a +scope, `open_workspace` returns the complete replacement snapshot for that scope. -The first successful open of a checkout provides complete instructions and -coding context. A repeated open that reuses the same checkout workspace does -not repeat the model-visible context, but the workspace UI continues to show the -complete details. Every new worktree establishes and returns its own complete -context, even when the same project was already opened in checkout or another -worktree. Opening checkout after a worktree therefore provides the checkout's -own context. +Treat returned `instructions`, `skills`, and `agents` scopes as durable +operating context. Preserve them when summarizing or compacting conversation +state. A later `open_workspace` result that omits a scope means the previously +returned snapshot is still current; a scope that appears again replaces the +retained snapshot for that scope. Do not call `open_workspace` again for the same checkout folder unless: @@ -98,8 +100,12 @@ When a workspace opens, DevSpace loads root-level instruction files: - `CLAUDE.md` - `CLAUDE.MD` -Nested instruction files are returned as `availableAgentsFiles`. The model -should read the relevant nested file before working under that directory. +`open_workspace` groups instructions by scope. Global loaded instructions are +returned under `instructions.global`; project root instructions are under +`instructions.project.loaded`; nested project instruction files are advertised +under `instructions.project.available`. Project instruction paths are relative +to the current workspace root. The model should read the relevant nested file +before working under that directory. This keeps instructions explicit and inspectable instead of silently injecting new context during later tool calls. @@ -135,7 +141,10 @@ Legacy project paths such as `.pi/skills` can be added to `skills.paths` when ne When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. -Skill paths may be outside the workspace. DevSpace only permits reading: +Project skill paths are workspace-relative, so the same retained project skill +catalog remains valid when moving between a checkout and managed worktree. +Global skill paths use home-shortened or absolute paths and may be outside the +workspace. DevSpace only permits reading: - advertised `SKILL.md` files - files under a skill directory after that skill's `SKILL.md` has been read diff --git a/docs/gotchas.md b/docs/gotchas.md index 5f6288678..814dde0e5 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -133,11 +133,15 @@ project. Workspace session metadata is persisted. ChatGPT may provide optional conversation metadata that lets DevSpace resume the same checkout workspace for the same project in that conversation; repeated opens reuse the `workspaceId` -and do not repeat context already provided for that reused checkout. Worktree -mode always creates a new isolated workspace with its own complete context. -Hosts without supported conversation metadata receive a normal new workspace. -In all cases, continue passing the `workspaceId` returned by `open_workspace` to -later tools. Other MCP hosts use this explicit workspace workflow as well. +and DevSpace avoids repeating unchanged global/project context already delivered +in that conversation. Worktree mode still creates a new isolated workspace, but +unchanged instructions, skills, agent profiles, and provider state can be +omitted from its model-visible result. A scope that appears again is the complete +replacement snapshot for that scope. Hosts without supported conversation +metadata receive a normal new workspace and complete context on each open. In +all cases, continue passing the `workspaceId` returned by `open_workspace` to +later tools and retain previously supplied context scopes when later results omit +them. To review work, call `show_changes` once after the final related file change. It shows the combined changes and advances the review point automatically. @@ -145,8 +149,9 @@ shows the combined changes and advances the review point automatically. ## Data Retention DevSpace does not currently prune workspace sessions, conversation bindings, -or review refs. A future product retention policy will define safe cleanup for -these records; no automatic deletion is performed today. +conversation context fingerprints, or review refs. A future product retention +policy will define safe cleanup for these records; no automatic deletion is +performed today. ## MCP Workspace Path Rejected @@ -241,6 +246,8 @@ Legacy project paths such as `.pi/skills` can be added to `skills.paths` when ne If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. +Project skill paths are relative to the active workspace root; global skill paths +may be home-shortened or absolute. ## Review Card Does Not Appear @@ -254,5 +261,7 @@ in `~/.devspace/config.jsonc` and reconnect the MCP server. Historical `show_changes` cards use the `reviewRef` in their structured result to recover the exact Git-backed review when a host reloads the app without its -original result metadata. `open_workspace` can rebuild its card directly from -its structured result. +original result metadata. `open_workspace` can rebuild its card from the scoped +workspace context present in its structured result; hidden result metadata is +used when available to preserve the complete card for selectively suppressed +scopes. diff --git a/src/server.ts b/src/server.ts index c907c1a6b..9c98b692c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -109,10 +109,10 @@ function serverInstructions( const showChangesInstruction = " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change."; const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` + ? `When ${toolNames.openWorkspace} returns skills and a task matches one, use ${toolNames.read} to read that skill's path before proceeding. Project skill paths are relative to the current workspace root. Global skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` : ""; - const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected.`; + const agents = `Follow loaded instructions returned under instructions.global and instructions.project.loaded. Before working under a path listed in instructions.project.available, use ${toolNames.read} to inspect that instruction file and follow it. Project instruction paths are relative to the current workspace root. `; + const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Treat instructions, skills, agent profiles, and provider information returned by ${toolNames.openWorkspace} as durable operating context and preserve them when summarizing or compacting conversation state. Later ${toolNames.openWorkspace} results may omit unchanged global or project scopes; an omitted scope remains unchanged and must continue to be followed. When a scope is returned again, replace the retained snapshot for that scope with the new value.`; return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } From 430e3cc0ab09011d28f288c72f78b3b58616aa5f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:44:26 +0530 Subject: [PATCH 6/8] fix(workspace): invalidate changed context files --- docs/chatgpt-coding-workflow.md | 8 +++- src/server.test.ts | 75 +++++++++++++++++++++++------ src/server.ts | 85 ++++++++++++++++++++++++++++----- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index f4ef3add6..49c6d6d2c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -37,11 +37,15 @@ global or project scope is unchanged from an earlier open in the conversation, the new worktree result can omit it. If a branch or filesystem change alters a scope, `open_workspace` returns the complete replacement snapshot for that scope. -Treat returned `instructions`, `skills`, and `agents` scopes as durable +Treat returned `instructions`, `skills`, and `agents` scopes, plus instructions +later read from nested instruction files or `SKILL.md` files, as durable operating context. Preserve them when summarizing or compacting conversation state. A later `open_workspace` result that omits a scope means the previously returned snapshot is still current; a scope that appears again replaces the -retained snapshot for that scope. +retained snapshot for that scope. When project instructions reappear, reread +relevant nested instruction files before relying on an older read. When a +skills scope reappears, reread a matching `SKILL.md` before relying on its +previously read instructions. Do not call `open_workspace` again for the same checkout folder unless: diff --git a/src/server.test.ts b/src/server.test.ts index 27c74fe69..ff860f444 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -272,24 +272,31 @@ test("open_workspace uses workspace-relative paths for project context", async ( "AGENTS.md", ); const skills = opened.skills as Record; + const projectSkill = (skills.project as Array>) + .find((skill) => skill.name === "project-skill"); assert.equal( - (skills.project as Array>).find((skill) => skill.name === "project-skill")?.path, + projectSkill?.path, ".agents/skills/project-skill/SKILL.md", ); + assert.equal(projectSkill?.filePath, undefined); }); test("open_workspace re-emits changed project instructions without repeating global context", async (t) => { const context = await fixture(t); + const nestedDir = join(context.project, "src"); + await mkdir(nestedDir, { recursive: true }); + const nestedInstructions = join(nestedDir, "AGENTS.md"); + await writeFile(nestedInstructions, "nested instructions v1\n"); await callOpen(context.client, context.project, "chat-1"); - await writeFile(join(context.project, "AGENTS.md"), "updated project instructions\n"); + await writeFile(nestedInstructions, "nested instructions v2\n"); const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); const instructions = reopened.instructions as Record; assert.equal(instructions.global, undefined); const project = instructions.project as Record; assert.equal( - (project.loaded as Array>)[0]?.content, - "updated project instructions\n", + (project.available as Array>)[0]?.path, + "src/AGENTS.md", ); assert.equal(reopened.skills, undefined); assert.equal(reopened.agents, undefined); @@ -311,20 +318,25 @@ test("open_workspace re-emits changed global instructions without repeating proj assert.equal(reopened.agents, undefined); }); -test("open_workspace refreshes project skills before selective delivery", async (t) => { +test("open_workspace re-emits a project skill when only its instructions change", async (t) => { const context = await fixture(t); - await callOpen(context.client, context.project, "chat-1"); const skillDir = join(context.project, ".agents", "skills", "new-skill"); await mkdir(skillDir, { recursive: true }); + const skillFile = join(skillDir, "SKILL.md"); + const skillHeader = [ + "---", + "name: new-skill", + "description: Project workflow.", + "---", + "", + ]; + await writeFile(skillFile, [...skillHeader, "# Version one"].join("\n")); + await callOpen(context.client, context.project, "chat-1"); await writeFile( - join(skillDir, "SKILL.md"), + skillFile, [ - "---", - "name: new-skill", - "description: Added after the workspace was opened.", - "---", - "", - "# New Skill", + ...skillHeader, + "# Version two", ].join("\n"), ); @@ -456,10 +468,45 @@ test("concurrent checkout opens deliver context once", async (t) => { ); }); +test("conversation context fingerprints survive a server restart", async (t) => { + const context = await fixture(t); + const first = structuredContent(await callOpen(context.client, context.project, "chat-1")); + await context.close(); + + const restoredStore = new SqliteWorkspaceStore(context.stateDir); + const restoredServer = createMcpServer( + context.config, + new WorkspaceRegistry(context.config, restoredStore), + createReviewCheckpointManager(), + new ProcessSessionManager(), + () => [], + [], + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const restoredClient = new Client({ name: "devspace-restored-test-client", version: "1.0.0" }); + await Promise.all([ + restoredClient.connect(clientTransport), + restoredServer.connect(serverTransport), + ]); + t.after(async () => { + await restoredClient.close(); + await restoredServer.close(); + restoredStore.close(); + }); + + const restored = structuredContent(await callOpen(restoredClient, context.project, "chat-1")); + assert.equal(restored.workspaceId, first.workspaceId); + assert.equal(restored.instructions, undefined); + assert.equal(restored.skills, undefined); + assert.equal(restored.agents, undefined); +}); + interface ServerFixture { client: Client; project: string; config: ServerConfig; + stateDir: string; + close(): Promise; } async function fixture( @@ -564,7 +611,7 @@ async function fixture( await rm(root, { recursive: true, force: true }); }); - return { client, project, config }; + return { client, project, config, stateDir, close }; } async function git(cwd: string, args: string[]): Promise { diff --git a/src/server.ts b/src/server.ts index 9c98b692c..ce6bbeb68 100644 --- a/src/server.ts +++ b/src/server.ts @@ -112,7 +112,7 @@ function serverInstructions( ? `When ${toolNames.openWorkspace} returns skills and a task matches one, use ${toolNames.read} to read that skill's path before proceeding. Project skill paths are relative to the current workspace root. Global skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` : ""; const agents = `Follow loaded instructions returned under instructions.global and instructions.project.loaded. Before working under a path listed in instructions.project.available, use ${toolNames.read} to inspect that instruction file and follow it. Project instruction paths are relative to the current workspace root. `; - const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Treat instructions, skills, agent profiles, and provider information returned by ${toolNames.openWorkspace} as durable operating context and preserve them when summarizing or compacting conversation state. Later ${toolNames.openWorkspace} results may omit unchanged global or project scopes; an omitted scope remains unchanged and must continue to be followed. When a scope is returned again, replace the retained snapshot for that scope with the new value.`; + const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Treat instructions, skills, agent profiles, and provider information returned by ${toolNames.openWorkspace}, plus instructions later read from advertised nested instruction files or SKILL.md files, as durable operating context and preserve them when summarizing or compacting conversation state. Later ${toolNames.openWorkspace} results may omit unchanged global or project scopes; an omitted scope remains unchanged and must continue to be followed. When a scope is returned again, replace the retained snapshot for that scope with the new value. If project instructions are returned again, treat previously read nested instruction files from that scope as stale and reread them when relevant. If a skills scope is returned again, reread a matching SKILL.md before relying on instructions previously read from that skill.`; return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } @@ -215,6 +215,7 @@ function scopedSkills( name: string; description: string; path: string; + filePath?: string; scope: "global" | "project"; }>, ): { @@ -223,7 +224,7 @@ function scopedSkills( } { const summarize = (scope: "global" | "project") => skills .filter((skill) => skill.scope === scope) - .map(({ scope: _scope, ...skill }) => skill); + .map(({ scope: _scope, filePath: _filePath, ...skill }) => skill); const global = summarize("global"); const project = summarize("project"); return { global, project }; @@ -268,10 +269,17 @@ interface WorkspaceContextSnapshots { agents: ReturnType; } +interface WorkspaceContextFingerprintInputs { + projectInstructions: unknown; + globalSkills: unknown; + projectSkills: unknown; +} + function conversationContextOutput( workspaces: WorkspaceRegistry, context: WorkspaceContext, snapshots: WorkspaceContextSnapshots, + fingerprintInputs: WorkspaceContextFingerprintInputs, ): { instructions?: { global?: WorkspaceContextSnapshots["instructions"]["global"]; @@ -303,9 +311,9 @@ function conversationContextOutput( }; const values = new Map([ [keys.globalInstructions, snapshots.instructions.global], - [keys.projectInstructions, snapshots.instructions.project], - [keys.globalSkills, snapshots.skills.global], - [keys.projectSkills, snapshots.skills.project], + [keys.projectInstructions, fingerprintInputs.projectInstructions], + [keys.globalSkills, fingerprintInputs.globalSkills], + [keys.projectSkills, fingerprintInputs.projectSkills], [keys.globalProfiles, snapshots.agents.profiles.global], [keys.projectProfiles, snapshots.agents.profiles.project], [keys.providers, snapshots.agents.providers], @@ -353,6 +361,46 @@ function contextFingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +function fileContentFingerprint(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function projectInstructionFingerprintInput( + instructions: WorkspaceContextSnapshots["instructions"]["project"], + available: Array<{ path: string }>, + workspaceRoot: string, +): unknown { + return { + loaded: instructions.loaded, + available: available.map((file) => ({ + path: formatWorkspaceResourcePath(file.path, workspaceRoot), + contentFingerprint: fileContentFingerprint(file.path), + })), + }; +} + +function skillFingerprintInputs( + skills: Array<{ + name: string; + description: string; + path: string; + filePath: string; + scope: "global" | "project"; + }>, +): { global: unknown; project: unknown } { + const summarize = (scope: "global" | "project") => skills + .filter((skill) => skill.scope === scope) + .map(({ scope: _scope, filePath, ...skill }) => ({ + ...skill, + contentFingerprint: fileContentFingerprint(filePath), + })); + + return { + global: summarize("global"), + project: summarize("project"), + }; +} + function sendJsonRpcError( res: Response, status: number, @@ -586,9 +634,10 @@ export function createMcpServer( name: skill.name, description: skill.description, path: formatWorkspaceResourcePath(skill.filePath, workspace.root), + filePath: skill.filePath, scope: isPathInsideRoot(skill.filePath, workspace.root) ? "project" as const : "global" as const, })); - const cardSkills = scopedSkillCatalog.map(({ scope: _scope, ...skill }) => skill); + const cardSkills = scopedSkillCatalog.map(({ scope: _scope, filePath: _filePath, ...skill }) => skill); const agentCatalog = buildLocalAgentCatalog( config.subagents, workspace.agentProfiles, @@ -616,19 +665,31 @@ export function createMcpServer( path: formatWorkspaceResourcePath(file.path, workspace.root), })); const cardInstruction = config.skillsEnabled - ? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + ? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded project instructions. Before working under a path with an available nested instruction file, read that file. When a task matches an available skill, read its SKILL.md before proceeding." + : "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded project instructions. Before working under a path with an available nested instruction file, read that file."; + const instructionSnapshots = scopedInstructions( + agentsFiles, + availableAgentsFiles, + workspace.root, + ); + const skillSnapshots = scopedSkills(scopedSkillCatalog); + const skillFingerprints = skillFingerprintInputs(scopedSkillCatalog); const modelContext = conversationContextOutput( workspaces, workspaceContext, { - instructions: scopedInstructions( - agentsFiles, + instructions: instructionSnapshots, + skills: skillSnapshots, + agents: scopedAgentCatalog(scopedAgents, cardAgentProviders), + }, + { + projectInstructions: projectInstructionFingerprintInput( + instructionSnapshots.project, availableAgentsFiles, workspace.root, ), - skills: scopedSkills(scopedSkillCatalog), - agents: scopedAgentCatalog(scopedAgents, cardAgentProviders), + globalSkills: skillFingerprints.global, + projectSkills: skillFingerprints.project, }, ); const resultContent: ToolContent[] = [ From ae48f8af1a3d7c66dd2f7ebd3bf2623eb9d6e4ef Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:51:45 +0530 Subject: [PATCH 7/8] test: trim workspace context coverage --- src/server.test.ts | 226 +++++++++------------------------------------ 1 file changed, 46 insertions(+), 180 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index ff860f444..0962acc56 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -63,21 +63,6 @@ test("UI metadata is limited to workspace and aggregate review", async (t) => { } }); -test("open_workspace keeps aggregate review availability in card metadata", async (t) => { - const plain = await fixture(t); - const gitWorkspace = await fixture(t, { git: true }); - - const plainResult = await callOpen(plain.client, plain.project, "plain"); - const gitResult = await callOpen(gitWorkspace.client, gitWorkspace.project, "git"); - const plainReview = responseCard(plainResult).review; - const gitReview = responseCard(gitResult).review; - - assert.equal((plainReview as { available: boolean }).available, false); - assert.deepEqual(gitReview, { available: true }); - assert.equal(structuredContent(plainResult).review, undefined); - assert.equal(structuredContent(gitResult).review, undefined); -}); - test("show_changes keeps model output compact and preserves the rich review card", async (t) => { const context = await fixture(t, { git: true, uiEnabled: false }); const opened = structuredContent( @@ -169,15 +154,27 @@ test("show_changes can reopen a historical review without advancing the checkpoi ); }); -test("open_workspace returns scoped model context without internal workspace state", async (t) => { +test("open_workspace returns scoped model context and keeps UI state in card metadata", async (t) => { const providerNote = "available"; const context = await fixture(t, { localAgentProviders: [{ name: "codex", available: true, note: providerNote }], }); + const nestedDir = join(context.project, "src"); + const skillDir = join(context.project, ".agents", "skills", "project-skill"); + await mkdir(nestedDir, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(nestedDir, "AGENTS.md"), "nested instructions\n"); + await writeFile(join(skillDir, "SKILL.md"), [ + "---", + "name: project-skill", + "description: Project-only workflow.", + "---", + "", + "# Project Skill", + ].join("\n")); + const first = await callOpen(context.client, context.project, "chat-1"); const repeated = await callOpen(context.client, context.project, "chat-1"); - assert.equal((first._meta as Record | undefined)?.tool, undefined); - assert.equal((repeated._meta as Record | undefined)?.tool, undefined); const tools = await context.client.listTools(); const openTool = tools.tools.find((tool) => tool.name === "open_workspace"); @@ -200,14 +197,20 @@ test("open_workspace returns scoped model context without internal workspace sta const instructions = firstStructured.instructions as Record; assert.ok(Array.isArray(instructions.global)); const projectInstructions = instructions.project as Record; - assert.ok(Array.isArray(projectInstructions.loaded)); - assert.ok(Array.isArray(projectInstructions.available)); assert.equal( - ((projectInstructions.loaded as Array>)[0]?.path), + (projectInstructions.loaded as Array>)[0]?.path, "AGENTS.md", ); + assert.equal( + (projectInstructions.available as Array>)[0]?.path, + "src/AGENTS.md", + ); + const skills = firstStructured.skills as Record; + const projectSkill = (skills.project as Array>) + .find((skill) => skill.name === "project-skill"); + assert.equal(projectSkill?.path, ".agents/skills/project-skill/SKILL.md"); + assert.equal(projectSkill?.filePath, undefined); const agents = firstStructured.agents as Record; - assert.ok(Array.isArray(agents.providers)); assert.equal( (agents.providers as Array>)[0]?.id, "codex", @@ -216,138 +219,59 @@ test("open_workspace returns scoped model context without internal workspace sta (agents.providers as Array>)[0]?.note, providerNote, ); - const profiles = agents.profiles as Record; - assert.ok(Array.isArray(profiles.project)); assert.equal(firstStructured.skillDiagnostics, undefined); assert.equal(firstStructured.review, undefined); assert.equal(firstStructured.instruction, undefined); - assert.equal("workspaceReused" in firstStructured, false); - assert.equal("includeBootstrapContext" in firstStructured, false); const repeatedStructured = structuredContent(repeated); assert.equal(repeatedStructured.instructions, undefined); assert.equal(repeatedStructured.skills, undefined); assert.equal(repeatedStructured.agents, undefined); - assert.equal(repeatedStructured.skillDiagnostics, undefined); - assert.equal(repeatedStructured.review, undefined); - assert.equal(repeatedStructured.instruction, undefined); - assert.equal("workspaceReused" in repeatedStructured, false); - assert.equal("includeBootstrapContext" in repeatedStructured, false); const card = responseCard(repeated); assert.equal(card.workspaceReused, true); - assert.equal(card.includeBootstrapContext, false); - assert.ok(Array.isArray(card.agentsFiles)); - assert.ok(Array.isArray(card.availableAgentsFiles)); + assert.equal((card.review as { available: boolean }).available, false); assert.ok(Array.isArray(card.skills)); - assert.ok(Array.isArray(card.agentProviders)); - assert.equal( - (card.agentProviders as Array>)[0]?.note, - providerNote, - ); - assert.ok(Array.isArray(card.agents)); }); -test("open_workspace uses workspace-relative paths for project context", async (t) => { - const context = await fixture(t); - const skillDir = join(context.project, ".agents", "skills", "project-skill"); - await mkdir(skillDir, { recursive: true }); - await writeFile( - join(skillDir, "SKILL.md"), - [ - "---", - "name: project-skill", - "description: Project-only workflow.", - "---", - "", - "# Project Skill", - ].join("\n"), - ); - - const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); - const instructions = opened.instructions as Record; - const projectInstructions = instructions.project as Record; - assert.equal( - (projectInstructions.loaded as Array>)[0]?.path, - "AGENTS.md", - ); - const skills = opened.skills as Record; - const projectSkill = (skills.project as Array>) - .find((skill) => skill.name === "project-skill"); - assert.equal( - projectSkill?.path, - ".agents/skills/project-skill/SKILL.md", - ); - assert.equal(projectSkill?.filePath, undefined); -}); - -test("open_workspace re-emits changed project instructions without repeating global context", async (t) => { +test("open_workspace re-emits changed project resources without repeating global context", async (t) => { const context = await fixture(t); const nestedDir = join(context.project, "src"); + const skillDir = join(context.project, ".agents", "skills", "project-skill"); await mkdir(nestedDir, { recursive: true }); - const nestedInstructions = join(nestedDir, "AGENTS.md"); - await writeFile(nestedInstructions, "nested instructions v1\n"); - await callOpen(context.client, context.project, "chat-1"); - await writeFile(nestedInstructions, "nested instructions v2\n"); - - const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); - const instructions = reopened.instructions as Record; - assert.equal(instructions.global, undefined); - const project = instructions.project as Record; - assert.equal( - (project.available as Array>)[0]?.path, - "src/AGENTS.md", - ); - assert.equal(reopened.skills, undefined); - assert.equal(reopened.agents, undefined); -}); - -test("open_workspace re-emits changed global instructions without repeating project context", async (t) => { - const context = await fixture(t); - await callOpen(context.client, context.project, "chat-1"); - await writeFile(join(context.config.agentDir, "AGENTS.md"), "updated global instructions\n"); - - const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); - const instructions = reopened.instructions as Record; - assert.equal(instructions.project, undefined); - assert.equal( - (instructions.global as Array>)[0]?.content, - "updated global instructions\n", - ); - assert.equal(reopened.skills, undefined); - assert.equal(reopened.agents, undefined); -}); - -test("open_workspace re-emits a project skill when only its instructions change", async (t) => { - const context = await fixture(t); - const skillDir = join(context.project, ".agents", "skills", "new-skill"); await mkdir(skillDir, { recursive: true }); + const nestedFile = join(nestedDir, "AGENTS.md"); const skillFile = join(skillDir, "SKILL.md"); const skillHeader = [ "---", - "name: new-skill", - "description: Project workflow.", + "name: project-skill", + "description: Project-only workflow.", "---", "", ]; - await writeFile(skillFile, [...skillHeader, "# Version one"].join("\n")); - await callOpen(context.client, context.project, "chat-1"); + await writeFile(nestedFile, "nested instructions v1\n"); await writeFile( skillFile, - [ - ...skillHeader, - "# Version two", - ].join("\n"), + [...skillHeader, "# Version one"].join("\n"), ); + await callOpen(context.client, context.project, "chat-1"); + await writeFile(nestedFile, "nested instructions v2\n"); + await writeFile(skillFile, [...skillHeader, "# Version two"].join("\n")); const reopened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const instructions = reopened.instructions as Record; + assert.equal(instructions.global, undefined); + const project = instructions.project as Record; + assert.equal( + (project.available as Array>)[0]?.path, + "src/AGENTS.md", + ); const skills = reopened.skills as Record; assert.equal(skills.global, undefined); assert.equal( (skills.project as Array>)[0]?.path, - ".agents/skills/new-skill/SKILL.md", + ".agents/skills/project-skill/SKILL.md", ); - assert.equal(reopened.instructions, undefined); assert.equal(reopened.agents, undefined); }); @@ -361,17 +285,9 @@ test("a conversation reuses global context when opening another project", async const opened = structuredContent(await callOpen(context.client, otherProject, "chat-1")); const instructions = opened.instructions as Record; assert.equal(instructions.global, undefined); - const project = instructions.project as Record; - assert.equal( - (project.loaded as Array>)[0]?.content, - "other project instructions\n", - ); - const skills = opened.skills as Record; - assert.equal(skills.global, undefined); + assert.ok(instructions.project); const agents = opened.agents as Record; assert.equal(agents.providers, undefined); - const profiles = agents.profiles as Record; - assert.equal(profiles.global, undefined); }); test("open_workspace refreshes provider availability for each catalog", async (t) => { @@ -454,59 +370,9 @@ test("open_workspace reuses unchanged context when switching to worktree mode", assert.equal(worktree.agents, undefined); }); -test("concurrent checkout opens deliver context once", async (t) => { - const context = await fixture(t); - const [first, second] = await Promise.all([ - callOpen(context.client, context.project, "chat-1"), - callOpen(context.client, context.project, "chat-1"), - ]); - - assert.equal(structuredContent(first).workspaceId, structuredContent(second).workspaceId); - assert.equal( - [first, second].filter((result) => structuredContent(result).instructions !== undefined).length, - 1, - ); -}); - -test("conversation context fingerprints survive a server restart", async (t) => { - const context = await fixture(t); - const first = structuredContent(await callOpen(context.client, context.project, "chat-1")); - await context.close(); - - const restoredStore = new SqliteWorkspaceStore(context.stateDir); - const restoredServer = createMcpServer( - context.config, - new WorkspaceRegistry(context.config, restoredStore), - createReviewCheckpointManager(), - new ProcessSessionManager(), - () => [], - [], - ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const restoredClient = new Client({ name: "devspace-restored-test-client", version: "1.0.0" }); - await Promise.all([ - restoredClient.connect(clientTransport), - restoredServer.connect(serverTransport), - ]); - t.after(async () => { - await restoredClient.close(); - await restoredServer.close(); - restoredStore.close(); - }); - - const restored = structuredContent(await callOpen(restoredClient, context.project, "chat-1")); - assert.equal(restored.workspaceId, first.workspaceId); - assert.equal(restored.instructions, undefined); - assert.equal(restored.skills, undefined); - assert.equal(restored.agents, undefined); -}); - interface ServerFixture { client: Client; project: string; - config: ServerConfig; - stateDir: string; - close(): Promise; } async function fixture( @@ -611,7 +477,7 @@ async function fixture( await rm(root, { recursive: true, force: true }); }); - return { client, project, config, stateDir, close }; + return { client, project }; } async function git(cwd: string, args: string[]): Promise { From acdef75bcca846208f0a49781950bf005a1c5bae Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:09:29 +0530 Subject: [PATCH 8/8] fix(workspace): stabilize context fingerprints --- docs/gotchas.md | 2 +- src/server.test.ts | 1 + src/server.ts | 70 +++++++++++++++++++++++++++++----------------- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/docs/gotchas.md b/docs/gotchas.md index 814dde0e5..3c6b53814 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -134,7 +134,7 @@ Workspace session metadata is persisted. ChatGPT may provide optional conversation metadata that lets DevSpace resume the same checkout workspace for the same project in that conversation; repeated opens reuse the `workspaceId` and DevSpace avoids repeating unchanged global/project context already delivered -in that conversation. Worktree mode still creates a new isolated workspace, but +in that conversation. Worktree mode creates a new managed worktree and workspace session, but unchanged instructions, skills, agent profiles, and provider state can be omitted from its model-visible result. A scope that appears again is the complete replacement snapshot for that scope. Hosts without supported conversation diff --git a/src/server.test.ts b/src/server.test.ts index 0962acc56..6aed56f20 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -358,6 +358,7 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t test("open_workspace reuses unchanged context when switching to worktree mode", async (t) => { const context = await fixture(t, { git: true }); + await git(context.project, ["config", "core.autocrlf", "true"]); const checkout = structuredContent(await callOpen(context.client, context.project, "chat-1")); const worktree = structuredContent( await callOpen(context.client, context.project, "chat-1", "worktree"), diff --git a/src/server.ts b/src/server.ts index ce6bbeb68..cd0f56165 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { access, realpath } from "node:fs/promises"; +import { access, readFile, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; @@ -361,25 +361,40 @@ function contextFingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } -function fileContentFingerprint(path: string): string { - return createHash("sha256").update(readFileSync(path)).digest("hex"); +function normalizedContextText(content: string): string { + return content.replace(/\r\n?/g, "\n"); } -function projectInstructionFingerprintInput( +async function fileContentFingerprint(path: string): Promise { + try { + const content = await readFile(path, "utf8"); + return contextFingerprint(["readable", normalizedContextText(content)]); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String(error.code) + : "unknown"; + return contextFingerprint(["unreadable", code]); + } +} + +async function projectInstructionFingerprintInput( instructions: WorkspaceContextSnapshots["instructions"]["project"], available: Array<{ path: string }>, workspaceRoot: string, -): unknown { +): Promise { return { - loaded: instructions.loaded, - available: available.map((file) => ({ - path: formatWorkspaceResourcePath(file.path, workspaceRoot), - contentFingerprint: fileContentFingerprint(file.path), + loaded: instructions.loaded.map((file) => ({ + ...file, + content: normalizedContextText(file.content), })), + available: await Promise.all(available.map(async (file) => ({ + path: formatWorkspaceResourcePath(file.path, workspaceRoot), + contentFingerprint: await fileContentFingerprint(file.path), + }))), }; } -function skillFingerprintInputs( +async function skillFingerprintInputs( skills: Array<{ name: string; description: string; @@ -387,17 +402,19 @@ function skillFingerprintInputs( filePath: string; scope: "global" | "project"; }>, -): { global: unknown; project: unknown } { - const summarize = (scope: "global" | "project") => skills - .filter((skill) => skill.scope === scope) - .map(({ scope: _scope, filePath, ...skill }) => ({ - ...skill, - contentFingerprint: fileContentFingerprint(filePath), - })); +): Promise<{ global: unknown; project: unknown }> { + const summarize = async (scope: "global" | "project") => Promise.all( + skills + .filter((skill) => skill.scope === scope) + .map(async ({ scope: _scope, filePath, ...skill }) => ({ + ...skill, + contentFingerprint: await fileContentFingerprint(filePath), + })), + ); return { - global: summarize("global"), - project: summarize("project"), + global: await summarize("global"), + project: await summarize("project"), }; } @@ -673,7 +690,14 @@ export function createMcpServer( workspace.root, ); const skillSnapshots = scopedSkills(scopedSkillCatalog); - const skillFingerprints = skillFingerprintInputs(scopedSkillCatalog); + const [projectInstructionFingerprint, skillFingerprints] = await Promise.all([ + projectInstructionFingerprintInput( + instructionSnapshots.project, + availableAgentsFiles, + workspace.root, + ), + skillFingerprintInputs(scopedSkillCatalog), + ]); const modelContext = conversationContextOutput( workspaces, workspaceContext, @@ -683,11 +707,7 @@ export function createMcpServer( agents: scopedAgentCatalog(scopedAgents, cardAgentProviders), }, { - projectInstructions: projectInstructionFingerprintInput( - instructionSnapshots.project, - availableAgentsFiles, - workspace.root, - ), + projectInstructions: projectInstructionFingerprint, globalSkills: skillFingerprints.global, projectSkills: skillFingerprints.project, },