Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 8 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +28,7 @@ export interface ServerConfig {
allowedHosts: string[];
publicBaseUrl: string;
harness: HarnessConfig;
widgets: WidgetMode;
presentation: PresentationConfig;
stateDir: string;
worktreeRoot: string;
artifactsEnabled: boolean;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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:
Expand Down
75 changes: 75 additions & 0 deletions src/presentation.ts
Original file line number Diff line number Diff line change
@@ -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.",
};
}
}
3 changes: 3 additions & 0 deletions src/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -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 =
| {
Expand All @@ -13,6 +14,7 @@ export type ArtifactCapability =

export interface RuntimeConfig extends ServerConfig {
runtimeHarness: CompiledHarness;
runtimePresentation: CompiledPresentation;
artifactCapability: ArtifactCapability;
}

Expand All @@ -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
Expand Down
30 changes: 29 additions & 1 deletion src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -273,6 +296,7 @@ async function fixture(
localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]);
subagents?: SubagentsConfig;
toolMode?: "minimal" | "full" | "codex";
widgetMode?: "off" | "changes" | "full";
} = {},
): Promise<ServerFixture> {
const root = await mkdtemp(join(tmpdir(), "devspace-server-test-"));
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -370,6 +394,10 @@ async function fixture(
return { client, project, config, stateDir, close };
}

function toolUiMeta(tool: { _meta?: Record<string, unknown> } | undefined): unknown {
return tool?._meta?.ui;
}

async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}
Expand Down
63 changes: 26 additions & 37 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -117,16 +122,6 @@ interface DiffStats {
removals: number;
}

type ToolWidgetKind =
| "workspace"
| "read"
| "write"
| "edit"
| "search"
| "directory"
| "shell"
| "show_changes";

interface ToolDefinitionMeta extends Record<string, unknown> {
ui: {
resourceUri: string;
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -550,7 +529,7 @@ function processToolResponse(

function registerCodexProcessTools(
server: McpServer,
config: ServerConfig,
config: RuntimeConfig,
workspaces: WorkspaceRegistry,
processSessions: ProcessSessionManager,
): void {
Expand Down Expand Up @@ -806,11 +785,14 @@ export function createMcpServer(
{ path, mode, baseRef },
{ conversationScopeId: openAiConversationScopeId(_meta) },
);
if (config.widgets === "changes") {
await reviewCheckpoints.initializeWorkspace({
const presentationBehaviors: Record<WorkspacePresentationBehavior, () => Promise<void>> = {
"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)
Expand Down Expand Up @@ -1284,7 +1266,7 @@ export function createMcpServer(
);
};

if (config.widgets === "changes") {
const registerChangeReviewTool = () => {
registerAppTool(
server,
"show_changes",
Expand Down Expand Up @@ -1337,7 +1319,7 @@ export function createMcpServer(
};
},
);
}
};

const registerDedicatedInspectionTools = () => {
registerAppTool(
Expand Down Expand Up @@ -1651,6 +1633,13 @@ export function createMcpServer(
harnessRegistrations[group]();
}

const presentationRegistrations: Record<PresentationToolGroup, () => void> = {
"change-review": registerChangeReviewTool,
};
for (const group of config.runtimePresentation.toolGroups) {
presentationRegistrations[group]();
}

if (config.artifactCapability.status === "available") {
registerArtifactTools(server, {
config,
Expand Down
Loading