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
1 change: 1 addition & 0 deletions ts/packages/agents/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
},
"devDependencies": {
"@typeagent/action-grammar-compiler": "workspace:*",
"@typeagent/action-schema": "workspace:*",
"@typeagent/action-schema-compiler": "workspace:*",
"@types/better-sqlite3": "7.6.13",
"@types/debug": "^4.1.12",
Expand Down
67 changes: 55 additions & 12 deletions ts/packages/agents/code/src/codeActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { WebSocketMessageV2 } from "@typeagent/websocket-utils";
import { CodeAgentWebSocketServer } from "./codeAgentWebSocketServer.js";
import {
ActionContext,
ActionResult,
AppAction,
AppAgent,
ReadinessReport,
Expand Down Expand Up @@ -59,7 +60,7 @@ const sharedActiveSessions = new Set<SessionContext<CodeActionContext>>();
const sharedPendingCalls: Map<
number,
{
resolve: (value?: undefined) => void;
resolve: (errorMessage?: string) => void;
context?: ActionContext<CodeActionContext> | undefined;
}
> = new Map();
Expand Down Expand Up @@ -99,7 +100,7 @@ type CodeActionContext = {
pendingCall: Map<
number,
{
resolve: (value?: undefined) => void;
resolve: (errorMessage?: string) => void;
context?: ActionContext<CodeActionContext> | undefined;
}
>;
Expand Down Expand Up @@ -187,6 +188,40 @@ function getCodeBindPort(): number {
return n;
}

// Detect an operational-failure result from the coda extension: a WebSocket
// `result` that is JSON encoding a plain object with exactly one key,
// `error` (a string) — the shape produced by handleReadActions' catch-all
// and explicit error returns. Any other shape (including the "OK"/"pong"
// strings used by non-action messages) is treated as success and yields
// undefined, so this only changes behavior for genuinely error-shaped results.
export function extractOperationalError(result: unknown): string | undefined {
if (typeof result !== "string") {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(result);
} catch {
return undefined;
}
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
const keys = Object.keys(parsed);
const errorValue = (parsed as Record<string, unknown>).error;
if (
keys.length === 1 &&
keys[0] === "error" &&
typeof errorValue === "string"
) {
return errorValue;
}
}
return undefined;
}

// Wire the shared server's onMessage handler. Module-scoped because the
// server itself is module-scoped — all sessions route their pending-call
// completions through the same handler.
Expand All @@ -204,7 +239,7 @@ function attachSharedOnMessage(server: CodeAgentWebSocketServer): void {
if (context?.actionIO) {
context.actionIO.setDisplay(data.result);
}
resolve();
resolve(extractOperationalError(data.result));
}
}
} catch (error) {
Expand Down Expand Up @@ -497,9 +532,14 @@ export async function getActiveFileFromVSCode(

// NOTE: pendingCall entry has no ActionContext because this isn’t a UI action
agentContext.pendingCall.set(callId, {
resolve: (value?: any) => {
// This call site doesn't currently receive real ActiveFile
// payloads through the pending-call channel (the coda side has
// no "code/getActiveFile" handler, so it always falls through to
// the default "OK" response) — only the timeout/undefined path
// is exercised today. Any non-error result still resolves undefined.
resolve: () => {
clearTimeout(t);
resolve(value as ActiveFile | undefined);
resolve(undefined);
},
context: undefined as any,
});
Expand Down Expand Up @@ -543,23 +583,26 @@ async function executeCodeAction(
}

const callId = nextSharedCallId++;
return new Promise<undefined>((resolve) => {
return new Promise<ActionResult | undefined>((resolve) => {
const timeoutMs = 5000;
const timeoutHandle = setTimeout(() => {
if (agentContext.pendingCall.has(callId)) {
agentContext.pendingCall.delete(callId);
const timeoutMessage = `No connected coda extension handled action "${action.actionName}". If multiple VS Code windows are open, reload the others (Ctrl+Shift+P → Developer: Reload Window) so they pick up the latest coda bundle.`;
if (context.actionIO) {
context.actionIO.setDisplay(
`No connected coda extension handled action "${action.actionName}". If multiple VS Code windows are open, reload the others (Ctrl+Shift+P → Developer: Reload Window) so they pick up the latest coda bundle.`,
);
context.actionIO.setDisplay(timeoutMessage);
}
resolve(undefined);
resolve(createActionResultFromError(timeoutMessage));
}
}, timeoutMs);
agentContext.pendingCall.set(callId, {
resolve: (value?: undefined) => {
resolve: (errorMessage?: string) => {
clearTimeout(timeoutHandle);
resolve(value);
resolve(
errorMessage !== undefined
? createActionResultFromError(errorMessage)
: undefined,
);
},
context,
});
Expand Down
42 changes: 42 additions & 0 deletions ts/packages/agents/code/src/codeActionsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type CodeActions =
| ListOpenEditorsAction
| GetFileContentAction
| GetWorkspaceChangesAction
| GetGitDiffAction
| LaunchCopilotChatAction;

export type CodeActivity = LaunchVSCodeAction;
Expand Down Expand Up @@ -229,3 +230,44 @@ export type GetWorkspaceChangesAction = {
actionName: "getWorkspaceChanges";
parameters: {};
};

// Get the actual Git diff for the workspace: changed files plus bounded unified-patch/hunk text, split into staged and unstaged sections by default.
//
// Complements getWorkspaceChanges (which only reports file paths and
// statuses) by returning real patch content, truncated with explicit
// metadata when a diff is large.
//
// Omit `base` (or pass "HEAD") to diff against HEAD: staged (index vs HEAD)
// and unstaged (working tree vs index) changes are reported as two separate
// sections. Pass `base` to instead diff the working tree against an
// arbitrary ref (branch, tag, or commit-ish), reported as one combined
// section since git does not distinguish staged/unstaged against a custom
// base.
//
// Each section reports: files (path, oldPath for renames/copies, status,
// binary, and bounded patch text -- omitted for binary files), plus
// filesTruncated/patchTruncated booleans when the file-count or byte-budget
// caps were hit, and filesOutsideWorkspace (only present when > 0) counting
// files dropped because they fall outside every open workspace folder (the
// git repository root can sit above the folder(s) actually opened).
//
// Example:
// User: show me my uncommitted changes
// Agent: { actionName: "getGitDiff", parameters: {} }
//
// Example:
// User: diff my branch against main
// Agent: { actionName: "getGitDiff", parameters: { base: "main" } }
export type GetGitDiffAction = {
actionName: "getGitDiff";
parameters: {
// Git ref (branch, tag, or commit-ish, e.g. "main", "HEAD~3") to diff
// the working tree against. Omit (or pass "HEAD") to diff against
// HEAD and get separate staged/unstaged sections.
base?: string;
// Workspace-relative repository root or folder name to select which
// git repository to diff when more than one is open. Ignored (and
// unnecessary) when only one repository is open.
repository?: string;
};
};
88 changes: 88 additions & 0 deletions ts/packages/agents/code/test/codeActionErrorSemantics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Tests for `extractOperationalError`, the shape-detector that lets
* `executeCodeAction` distinguish a genuine operational failure reported by
* the coda VS Code extension (a WebSocket `result` that JSON-encodes
* `{ error: string }`) from a normal success payload, so failures surface as
* a failed `ActionResult` (via `createActionResultFromError`) instead of a
* success-shaped `ActionResult` whose display just happens to contain the
* word "error".
*/

import { extractOperationalError } from "../src/codeActionHandler.js";

describe("extractOperationalError", () => {
test("detects the single-key { error: string } shape used by handleReadActions' catch-all", () => {
expect(extractOperationalError(JSON.stringify({ error: "boom" }))).toBe(
"boom",
);
});

test("returns undefined for non-string results (e.g. already-parsed objects)", () => {
expect(extractOperationalError({ error: "boom" })).toBeUndefined();
expect(extractOperationalError(undefined)).toBeUndefined();
expect(extractOperationalError(42)).toBeUndefined();
});

test("returns undefined for non-JSON strings (e.g. the 'OK'/'pong' acks)", () => {
expect(extractOperationalError("OK")).toBeUndefined();
expect(extractOperationalError("pong")).toBeUndefined();
});

test("returns undefined for JSON arrays and primitives", () => {
expect(
extractOperationalError(JSON.stringify(["error"])),
).toBeUndefined();
expect(
extractOperationalError(JSON.stringify("error")),
).toBeUndefined();
expect(extractOperationalError(JSON.stringify(null))).toBeUndefined();
expect(extractOperationalError(JSON.stringify(5))).toBeUndefined();
});

test("returns undefined for success-shaped JSON objects with unrelated keys", () => {
// Real success payloads for read actions (e.g. getGitDiff) are
// multi-key objects — must never be mistaken for an error.
expect(
extractOperationalError(
JSON.stringify({ files: [], truncated: false }),
),
).toBeUndefined();
});

test("returns undefined when 'error' is present alongside other keys", () => {
// Only the exact single-key { error: string } shape produced by
// handleReadActions' explicit error returns counts — an object that
// happens to also carry an "error" field among real success data
// must not be treated as an operational failure.
expect(
extractOperationalError(
JSON.stringify({ error: "boom", files: [] }),
),
).toBeUndefined();
});

test("returns undefined when 'error' is present but not a string", () => {
expect(
extractOperationalError(JSON.stringify({ error: 123 })),
).toBeUndefined();
expect(
extractOperationalError(JSON.stringify({ error: null })),
).toBeUndefined();
});

test("detects the JSON-encoded 'Did not handle the action' failure from handleVSCodeActions", () => {
// The unhandled-action fallback in handleVSCodeActions.ts must encode
// as { error: string } (not a bare string) so this failure path is
// classified the same way as every other operational error.
expect(
extractOperationalError(
JSON.stringify({
error: 'Did not handle the action: "someUnknownAction"',
}),
),
).toBe('Did not handle the action: "someUnknownAction"');
});
});
61 changes: 61 additions & 0 deletions ts/packages/agents/code/test/codeActionsSchemaDiscovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Proves that getGitDiff is discoverable through the same catalog pipeline
* external MCP clients use (e.g. commandExecutor's discover_agents/execute_action,
* which read schemas via Dispatcher.getAgentSchemas -> ParsedActionSchema.actionSchemas).
*
* This parses the real codeActionsSchema.ts source with the same
* @typeagent/action-schema APIs used to build/round-trip the compiled
* dist/codeSchema.pas.json catalog artifact (see
* packages/actionSchema/test/regen.spec.ts, which already round-trips this
* exact schema as part of its generic per-agent coverage). Rather than
* duplicate that generic round-trip, this test asserts the specific,
* externally-visible shape (name/description/parameters) of the new action.
*/

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
parseActionSchemaSource,
getActionDescription,
getParameterNames,
} from "@typeagent/action-schema";

// This spec compiles to dist/test/*.js, so the source tree (../src from the
// package root) is two levels up from there.
const schemaPath = fileURLToPath(
new URL("../../src/codeActionsSchema.ts", import.meta.url),
);

describe("code agent action catalog discoverability", () => {
const source = fs.readFileSync(schemaPath, "utf-8");
const parsed = parseActionSchemaSource(
source,
"code",
{ action: "CodeActions", activity: "CodeActivity" },
path.basename(schemaPath),
);

it("includes getGitDiff with a concise, standalone description", () => {
const actionDef = parsed.actionSchemas.get("getGitDiff");
expect(actionDef).toBeDefined();
expect(getActionDescription(actionDef!)).toBe(
"Get the actual Git diff for the workspace: changed files plus bounded unified-patch/hunk text, split into staged and unstaged sections by default.",
);
});

it("exposes getGitDiff's base and repository parameters", () => {
const actionDef = parsed.actionSchemas.get("getGitDiff")!;
const names = getParameterNames(actionDef, () => undefined).sort();
expect(names).toEqual(["parameters.base", "parameters.repository"]);
});

it("still includes getWorkspaceChanges (compatibility)", () => {
const actionDef = parsed.actionSchemas.get("getWorkspaceChanges");
expect(actionDef).toBeDefined();
expect(getActionDescription(actionDef!)).toBeTruthy();
});
});
7 changes: 5 additions & 2 deletions ts/packages/coda/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
"package": "mkdirp dist-pub && vsce package --allow-star-activation --allow-missing-repository --no-dependencies -o dist-pub/aisystems-coda.vsix",
"prettier": "prettier --check . --ignore-path ../../.prettierignore",
"prettier:fix": "prettier --write . --ignore-path ../../.prettierignore",
"pretest": "pnpm run build",
"test": "npm run test:local",
"test-compile": "tsc -p ./src",
"pretest:full": "pnpm run build",
"test:full": "vscode-test",
"test:local": "tsx --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=../../tools/scripts/nodeTestFailureReporter.mjs --test-reporter-destination=stdout test/*.spec.ts",
"vscode:prepublish": "pnpm run esbuild-base --minify",
"watch": "tsc -w"
},
Expand Down Expand Up @@ -75,7 +77,8 @@
"esbuild": "^0.28.2",
"mkdirp": "^3.0.1",
"prettier": "^3.5.3",
"rimraf": "^6.0.1"
"rimraf": "^6.0.1",
"tsx": "^4.21.0"
},
"engines": {
"vscode": "^1.88.0"
Expand Down
Loading
Loading