From 92e6f166355dd6c64ecb3f4b9abdf8e29ef12e4e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:06:24 +0530 Subject: [PATCH 1/2] refactor(presentation): model review presentation profiles --- src/config.test.ts | 14 +++++--- src/config.ts | 12 ++++--- src/presentation.ts | 75 +++++++++++++++++++++++++++++++++++++++++++ src/runtime-config.ts | 3 ++ 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 src/presentation.ts diff --git a/src/config.test.ts b/src/config.test.ts index fd8ed336..58de89e2 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,10 +11,16 @@ const baseEnv = { DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); +assert.deepEqual(loadConfig(baseEnv).presentation, { mode: "inline" }); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).presentation, { + mode: "change-review", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).presentation, { + mode: "inline", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).presentation, { + mode: "off", +}); assert.deepEqual(loadConfig(baseEnv).harness, { kind: "claude-code", inspection: "shell", diff --git a/src/config.ts b/src/config.ts index 99723cf1..11340013 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,8 +10,12 @@ import { type HarnessConfig, type LegacyToolMode, } from "./harness.js"; +import { + presentationFromLegacyWidgetMode, + type LegacyWidgetMode, + type PresentationConfig, +} from "./presentation.js"; -export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; @@ -24,7 +28,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; harness: HarnessConfig; - widgets: WidgetMode; + presentation: PresentationConfig; stateDir: string; worktreeRoot: string; artifactsEnabled: boolean; @@ -169,7 +173,7 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { }; } -function parseWidgetMode(value: string | undefined): WidgetMode { +function parseLegacyWidgetMode(value: string | undefined): LegacyWidgetMode { if (!value || value === "full") return "full"; if (value === "off" || value === "changes") return value; @@ -245,7 +249,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, harness: harnessFromLegacyToolMode(parseLegacyToolMode(env)), - widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), + presentation: presentationFromLegacyWidgetMode(parseLegacyWidgetMode(env.DEVSPACE_WIDGETS)), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), artifactsEnabled: diff --git a/src/presentation.ts b/src/presentation.ts new file mode 100644 index 00000000..e1a84b07 --- /dev/null +++ b/src/presentation.ts @@ -0,0 +1,75 @@ +export type PresentationConfig = + | { mode: "off" } + | { mode: "inline" } + | { mode: "change-review" }; + +export type LegacyWidgetMode = "off" | "changes" | "full"; + +export type PresentationToolKind = + | "workspace" + | "read" + | "write" + | "edit" + | "search" + | "directory" + | "shell" + | "show_changes"; + +export type PresentationToolGroup = "change-review"; +export type WorkspacePresentationBehavior = "initialize-review"; + +export interface CompiledPresentation { + widgetKinds: readonly PresentationToolKind[]; + toolGroups: readonly PresentationToolGroup[]; + workspaceBehaviors: readonly WorkspacePresentationBehavior[]; + instructions: string; +} + +const INLINE_WIDGET_KINDS: readonly PresentationToolKind[] = [ + "workspace", + "read", + "write", + "edit", + "search", + "directory", + "shell", + "show_changes", +]; + +export function presentationFromLegacyWidgetMode(mode: LegacyWidgetMode): PresentationConfig { + switch (mode) { + case "off": + return { mode: "off" }; + case "full": + return { mode: "inline" }; + case "changes": + return { mode: "change-review" }; + } +} + +export function compilePresentation(config: PresentationConfig): CompiledPresentation { + switch (config.mode) { + case "off": + return { + widgetKinds: [], + toolGroups: [], + workspaceBehaviors: [], + instructions: "", + }; + case "inline": + return { + widgetKinds: INLINE_WIDGET_KINDS, + toolGroups: [], + workspaceBehaviors: [], + instructions: "", + }; + case "change-review": + return { + widgetKinds: ["workspace", "show_changes"], + toolGroups: ["change-review"], + workspaceBehaviors: ["initialize-review"], + instructions: + " 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; do not skip it because individual file-change tools already returned diffs.", + }; + } +} diff --git a/src/runtime-config.ts b/src/runtime-config.ts index 1a58bc48..09788550 100644 --- a/src/runtime-config.ts +++ b/src/runtime-config.ts @@ -1,5 +1,6 @@ import type { ServerConfig } from "./config.js"; import { compileHarness, type CompiledHarness } from "./harness.js"; +import { compilePresentation, type CompiledPresentation } from "./presentation.js"; export type ArtifactCapability = | { @@ -13,6 +14,7 @@ export type ArtifactCapability = export interface RuntimeConfig extends ServerConfig { runtimeHarness: CompiledHarness; + runtimePresentation: CompiledPresentation; artifactCapability: ArtifactCapability; } @@ -23,6 +25,7 @@ export function compileRuntime( return { ...config, runtimeHarness: compileHarness(config.harness, { skillsEnabled: config.skillsEnabled }), + runtimePresentation: compilePresentation(config.presentation), artifactCapability: !config.artifactsEnabled ? { status: "unavailable", reason: "disabled" } : environment.artifactDownloadSupported From e9d02385d683380c3a47ab43be3c7932e9aef361 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:06:24 +0530 Subject: [PATCH 2/2] refactor(review): compose presentation behavior --- src/server.test.ts | 30 +++++++++++++++++++++- src/server.ts | 63 +++++++++++++++++++--------------------------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 2e027071..c98f2ccb 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -105,6 +105,29 @@ test("legacy tool modes resolve to the intended coding harness tool contracts", } }); +test("legacy widget modes resolve to the intended presentation contracts", async (t) => { + const full = await fixture(t, { widgetMode: "full" }); + const fullTools = await full.client.listTools(); + assert.equal(fullTools.tools.some((tool) => tool.name === "show_changes"), false); + assert.ok(toolUiMeta(fullTools.tools.find((tool) => tool.name === "open_workspace"))); + assert.ok(toolUiMeta(fullTools.tools.find((tool) => tool.name === "read"))); + await full.close(); + + const changes = await fixture(t, { widgetMode: "changes" }); + const changeTools = await changes.client.listTools(); + assert.ok(toolUiMeta(changeTools.tools.find((tool) => tool.name === "open_workspace"))); + assert.equal(toolUiMeta(changeTools.tools.find((tool) => tool.name === "read")), undefined); + assert.ok(toolUiMeta(changeTools.tools.find((tool) => tool.name === "show_changes"))); + await changes.close(); + + const off = await fixture(t, { widgetMode: "off" }); + const offTools = await off.client.listTools(); + assert.equal(offTools.tools.some((tool) => tool.name === "show_changes"), false); + assert.equal(toolUiMeta(offTools.tools.find((tool) => tool.name === "open_workspace")), undefined); + assert.equal(toolUiMeta(offTools.tools.find((tool) => tool.name === "read")), undefined); + await off.close(); +}); + test("open_workspace refreshes provider availability for each catalog", async (t) => { let available = false; const context = await fixture(t, { @@ -273,6 +296,7 @@ async function fixture( localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; toolMode?: "minimal" | "full" | "codex"; + widgetMode?: "off" | "changes" | "full"; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -310,7 +334,7 @@ async function fixture( DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_WIDGETS: "full", + DEVSPACE_WIDGETS: options.widgetMode ?? "full", DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", @@ -370,6 +394,10 @@ async function fixture( return { client, project, config, stateDir, close }; } +function toolUiMeta(tool: { _meta?: Record } | undefined): unknown { + return tool?._meta?.ui; +} + async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } diff --git a/src/server.ts b/src/server.ts index 70adb85e..aa78563e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,8 +22,13 @@ import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js"; -import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import type { HarnessToolGroup } from "./harness.js"; +import type { + PresentationToolGroup, + PresentationToolKind, + WorkspacePresentationBehavior, +} from "./presentation.js"; import { compileRuntime, type RuntimeConfig } from "./runtime-config.js"; import { createOpenAIIncomingArtifactAdapter, @@ -117,16 +122,6 @@ interface DiffStats { removals: number; } -type ToolWidgetKind = - | "workspace" - | "read" - | "write" - | "edit" - | "search" - | "directory" - | "shell" - | "show_changes"; - interface ToolDefinitionMeta extends Record { ui: { resourceUri: string; @@ -142,22 +137,11 @@ interface ToolWidgetDescriptorMeta { _meta: ToolDefinitionMeta | EmptyToolDefinitionMeta; } -function shouldAttachWidget(mode: WidgetMode, kind: ToolWidgetKind): boolean { - switch (mode) { - case "off": - return false; - case "changes": - return kind === "workspace" || kind === "show_changes"; - case "full": - return true; - } -} - function toolWidgetDescriptorMeta( - config: ServerConfig, - kind: ToolWidgetKind, + config: RuntimeConfig, + kind: PresentationToolKind, ): ToolWidgetDescriptorMeta { - if (!shouldAttachWidget(config.widgets, kind)) return { _meta: {} }; + if (!config.runtimePresentation.widgetKinds.includes(kind)) return { _meta: {} }; return { _meta: { @@ -199,12 +183,7 @@ function serverInstructions(config: RuntimeConfig): string { const artifactInstruction = config.artifactCapability.status === "available" ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." : ""; - const showChangesInstruction = - config.widgets === "changes" - ? " 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; do not skip it because individual file-change tools already returned diffs." - : ""; - - return `${config.runtimeHarness.instructions}${artifactInstruction}${showChangesInstruction}`; + return `${config.runtimeHarness.instructions}${artifactInstruction}${config.runtimePresentation.instructions}`; } function formatVisibleAgent(agent: { @@ -550,7 +529,7 @@ function processToolResponse( function registerCodexProcessTools( server: McpServer, - config: ServerConfig, + config: RuntimeConfig, workspaces: WorkspaceRegistry, processSessions: ProcessSessionManager, ): void { @@ -806,11 +785,14 @@ export function createMcpServer( { path, mode, baseRef }, { conversationScopeId: openAiConversationScopeId(_meta) }, ); - if (config.widgets === "changes") { - await reviewCheckpoints.initializeWorkspace({ + const presentationBehaviors: Record Promise> = { + "initialize-review": () => reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, - }); + }), + }; + for (const behavior of config.runtimePresentation.workspaceBehaviors) { + await presentationBehaviors[behavior](); } const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) @@ -1284,7 +1266,7 @@ export function createMcpServer( ); }; - if (config.widgets === "changes") { + const registerChangeReviewTool = () => { registerAppTool( server, "show_changes", @@ -1337,7 +1319,7 @@ export function createMcpServer( }; }, ); - } + }; const registerDedicatedInspectionTools = () => { registerAppTool( @@ -1651,6 +1633,13 @@ export function createMcpServer( harnessRegistrations[group](); } + const presentationRegistrations: Record void> = { + "change-review": registerChangeReviewTool, + }; + for (const group of config.runtimePresentation.toolGroups) { + presentationRegistrations[group](); + } + if (config.artifactCapability.status === "available") { registerArtifactTools(server, { config,