diff --git a/CHANGELOG.md b/CHANGELOG.md
index f932e411..d1193d14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ All notable changes to summer-engine will be documented here. Following [Keep a
## [Unreleased]
+- **Editor chat generation stays native.** With `SUMMER_EDITOR_CHAT=1`, operations with native `summer_cloud` equivalents and polling of native `aj_` asset jobs return a clear redirect. Build, upload, approval and ordinary CLI job tracking remain available, including `--wait`; makers and other operations without a native equivalent remain available. Outside editor chat, behavior is unchanged.
+
- Startup skills now distinguish missing MCP tools from a stopped editor, use an explicit project path, and share the Summer CLI's installation and readiness checks. Agents can continue through the CLI without switching engines or asking the user to launch an already installed Summer Engine.
## [4.9.1] - 2026-10-28
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index ba77b450..957146e8 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -204,7 +204,7 @@ Summer Engine app (LocalApiServer -> OpsExecutor)
- **Shared `~/.summer/` store** (`src/core/store.ts`): `0700` dir, `0600` files, atomic replacement, symlink refusal. Filenames (`api-token`, `auth-token`, `oauth-token` and `oauth-client` (the store sign-in, `src/core/oauth.ts`), `creator-token` (legacy; only removed, by logout), `user.json`, `config.json`, `credential-metadata.json`, `creator-audit.jsonl`) are shared with the desktop engine — do not rename them.
- **Ops values are engine variant strings** (`"Vector3(0, 10, 0)"`, `"Color(1, 0.9, 0.8)"`), never JSON objects. This crosses both repos; coordinate changes.
-No environment variables are required for normal use. Optional: `SUMMER_GATEWAY_URL` (gateway override; `gateway.url` in `~/.summer/config.json` does the same for every gateway caller), `SUMMER_TOKEN` (auth token override for CI/cloud sessions), `SUMMER_CHANNEL` (npm dist-tag or exact version `summer setup` writes into the agent's MCP entry — same as `--channel`), `SUMMER_BIN` (engine executable to launch/probe — the flag form is `summer run --bin`; on macOS the in-bundle `…/Summer.app/Contents/MacOS/Summer`, never the `.app`), `SUMMER_ENGINE_BINARY` (alias of `SUMMER_BIN`), `SUMMER_ENGINE_PROJECT` / `SUMMER_ENGINE_INSTANCE_ID` (pin the editor for the CLI face, same names `summer mcp` reads; without them the CLI uses the global api-token pointer, then the instance registry), `SUMMER_MCP_DEBUG=1` (structured stderr line per tool call), `SUMMER_NO_TELEMETRY=1` / `DO_NOT_TRACK=1` (also `true`, `yes` or `on`; disable the feedback mailbox, the agent-app and tool headers on requests to Summer's servers, and the signed-in usage lines: one per MCP session and one per tool call, with tool name, success or its error code, duration and host app name, `src/core/telemetry.ts`; and one per CLI command, with the command or operation name, success or error code, duration, version and agent app, sent by a detached child at exit, `src/core/cli-usage.ts`), `SUMMER_AGENT_HOST` (override the agent app id sent as `X-Summer-Agent-Host` on requests to Summer's servers; otherwise the MCP handshake's clientInfo, then the agent's environment; `src/core/agent-host.ts`), `SUMMER_SURFACE=v3` (the 3.x tools in `summer mcp`, and no `summer ` commands; default `v4`; `src/core/tool-profiles.ts`), `SUMMER_PROFILE` / `SUMMER_TOOLSETS` (the `summer mcp --profile` / `--toolsets` tool selection; `src/core/tool-profiles.ts`, `src/mcp/tool-surface.ts`), `SUMMER_LEGACY_TOOLS=on|off` (the v4 surface's legacy toolset, which lists every 3.x tool name again: on while installed Summer skills are older than 4.0, or with `--toolsets legacy`; `src/mcp/legacy-toolset.ts`), `SUMMER_CAPABILITY_PREFLIGHT=off` (send every call even when the engine lacks the op), `SUMMER_HEADLESS_ROUTING=1` (headless worker routing — needs an engine build with worker mode), `SUMMER_AUTO_LAUNCH=0` (the MCP does not open the session's project in Summer Engine when no editor runs; `core/engine-self-start.ts`; an explicit `projects.open_local` still opens; the test setup sets it so no test launches an installed engine), `SUMMER_TRAJECTORY_DIR` (opt-in per-tool-call JSONL capture, redacted), `SUMMER_TRAJECTORY_EVAL=1` (with the directory set: additionally writes an unredacted `trajectory.full.jsonl` with a bounded result summary — for eval fixtures only, never a default; `summer_get_project_context` reports `trajectory_eval_mode: true` while it is on), `SUMMER_PRE_COMMIT_DOCTOR=1` (the opt-in pre-commit hook), `SUMMER_EMBED_URL` (embedding endpoint for semantic library search and `generate-registry --embed`, default `/api/mcp/embed`; `off` forces lexical-only search), `SUMMER_FETCH_TIMEOUT_S` (seconds before `summer create` kills a hanging template fetch; default 120), `SUMMER_ENGINE_URL` (artifact URL `summer install` downloads instead of the release feed: a `.tar.gz`, `.zip`, or raw executable), `SUMMER_SKILLS_DIR` (directory `summer skills install` / `summer setup` write skills into, overriding the per-agent default; for Cursor, Windsurf and Cline it is the rules directory), `SUMMER_ENGINE_REPO` (tests only, above), `SUMMER_E2E=1` (tests only: opt into the network test that fetches a real pinned template repository), `SUMMER_EDITOR_BIN` (outcome evals: the editor binary to boot).
+No environment variables are required for normal use. Optional: `SUMMER_GATEWAY_URL` (gateway override; `gateway.url` in `~/.summer/config.json` does the same for every gateway caller), `SUMMER_TOKEN` (auth token override for CI/cloud sessions), `SUMMER_CHANNEL` (npm dist-tag or exact version `summer setup` writes into the agent's MCP entry — same as `--channel`), `SUMMER_BIN` (engine executable to launch/probe — the flag form is `summer run --bin`; on macOS the in-bundle `…/Summer.app/Contents/MacOS/Summer`, never the `.app`), `SUMMER_ENGINE_BINARY` (alias of `SUMMER_BIN`), `SUMMER_ENGINE_PROJECT` / `SUMMER_ENGINE_INSTANCE_ID` (pin the editor for the CLI face, same names `summer mcp` reads; without them the CLI uses the global api-token pointer, then the instance registry), `SUMMER_MCP_DEBUG=1` (structured stderr line per tool call), `SUMMER_NO_TELEMETRY=1` / `DO_NOT_TRACK=1` (also `true`, `yes` or `on`; disable the feedback mailbox, the agent-app and tool headers on requests to Summer's servers, and the signed-in usage lines: one per MCP session and one per tool call, with tool name, success or its error code, duration and host app name, `src/core/telemetry.ts`; and one per CLI command, with the command or operation name, success or error code, duration, version and agent app, sent by a detached child at exit, `src/core/cli-usage.ts`), `SUMMER_AGENT_HOST` (override the agent app id sent as `X-Summer-Agent-Host` on requests to Summer's servers; otherwise the MCP handshake's clientInfo, then the agent's environment; `src/core/agent-host.ts`), `SUMMER_SURFACE=v3` (the 3.x tools in `summer mcp`, and no `summer ` commands; default `v4`; `src/core/tool-profiles.ts`), `SUMMER_PROFILE` / `SUMMER_TOOLSETS` (the `summer mcp --profile` / `--toolsets` tool selection; `src/core/tool-profiles.ts`, `src/mcp/tool-surface.ts`), `SUMMER_LEGACY_TOOLS=on|off` (the v4 surface's legacy toolset, which lists every 3.x tool name again: on while installed Summer skills are older than 4.0, or with `--toolsets legacy`; `src/mcp/legacy-toolset.ts`), `SUMMER_CAPABILITY_PREFLIGHT=off` (send every call even when the engine lacks the op), `SUMMER_EDITOR_CHAT=1` (set by the Summer editor for its chat's shell and, in older engine integrations, MCP server: operations with native `summer_cloud` equivalents are redirected, and `jobs.get` / `jobs.wait` refuse native `aj_` asset job IDs, including `gen:aj_` wrappers. Build, upload, approval and ordinary CLI jobs keep their normal tracking and `--wait`. Makers, video and sprite-sheet operations stay available. Search hides only the explicit native-equivalent operations, not job tools; `src/core/editor-chat.ts`), `SUMMER_HEADLESS_ROUTING=1` (headless worker routing — needs an engine build with worker mode), `SUMMER_AUTO_LAUNCH=0` (the MCP does not open the session's project in Summer Engine when no editor runs; `core/engine-self-start.ts`; an explicit `projects.open_local` still opens; the test setup sets it so no test launches an installed engine), `SUMMER_TRAJECTORY_DIR` (opt-in per-tool-call JSONL capture, redacted), `SUMMER_TRAJECTORY_EVAL=1` (with the directory set: additionally writes an unredacted `trajectory.full.jsonl` with a bounded result summary — for eval fixtures only, never a default; `summer_get_project_context` reports `trajectory_eval_mode: true` while it is on), `SUMMER_PRE_COMMIT_DOCTOR=1` (the opt-in pre-commit hook), `SUMMER_EMBED_URL` (embedding endpoint for semantic library search and `generate-registry --embed`, default `/api/mcp/embed`; `off` forces lexical-only search), `SUMMER_FETCH_TIMEOUT_S` (seconds before `summer create` kills a hanging template fetch; default 120), `SUMMER_ENGINE_URL` (artifact URL `summer install` downloads instead of the release feed: a `.tar.gz`, `.zip`, or raw executable), `SUMMER_SKILLS_DIR` (directory `summer skills install` / `summer setup` write skills into, overriding the per-agent default; for Cursor, Windsurf and Cline it is the rules directory), `SUMMER_ENGINE_REPO` (tests only, above), `SUMMER_E2E=1` (tests only: opt into the network test that fetches a real pinned template repository), `SUMMER_EDITOR_BIN` (outcome evals: the editor binary to boot).
---
diff --git a/src/cli/commands/ops.editor-chat.test.ts b/src/cli/commands/ops.editor-chat.test.ts
new file mode 100644
index 00000000..c60c174a
--- /dev/null
+++ b/src/cli/commands/ops.editor-chat.test.ts
@@ -0,0 +1,169 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { EDITOR_CHAT_REFUSAL } from "../../core/editor-chat.js";
+import { formatAnswer, runOpFromCli, searchFromCli, type OpsCliDeps } from "./ops.js";
+import { hostedCli, toolCommand } from "./tool.js";
+
+const envelope = (value: unknown) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
+
+function deps(): OpsCliDeps & { out: string[]; err: string[]; hosted: ReturnType } {
+ const out: string[] = [];
+ const err: string[] = [];
+ const hosted = vi.fn(async (_name: string, args: Record) =>
+ args.op === "jobs.wait"
+ ? envelope({ ok: true, status: "done", result: { job: { id: "gen:1", state: "done", result: { assetId: "a1" } }, finished: true } })
+ : envelope({ ok: true, status: "running", job: { id: "gen:1", state: "queued" } })
+ );
+ return { hosted, dispatch: vi.fn(async () => ({ ok: true })), print: (text) => out.push(text), printError: (text) => err.push(text), out, err };
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+describe("summer in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => {
+ it.each([
+ ["jobs.wait", ["--job-id", "aj_native-job"]],
+ ["jobs.get", ["--job-id", "aj_native-job"]],
+ ["assets.generate_3d", ["--prompt", "a chest"]],
+ ["assets.generate_image", ["--prompt", "a chest", "--json"]],
+ ["jobs.wait", ["--input", '{"jobId":"gen:aj_native-job"}', "--json"]],
+ ["jobs.get", ["--job-id=aj_native-job"]],
+ ["jobs.wait", ["--input", '{"jobId":"build:1"}', "--job-id", "aj_native-job"]],
+ ])("%s %j exits 1 at once with the refusal and never calls Summer's server", async (op, argv) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ expect(await runOpFromCli(op, argv, d)).toBe(1);
+ expect(d.hosted).not.toHaveBeenCalled();
+ expect(d.dispatch).not.toHaveBeenCalled();
+ expect(d.out).toEqual([]);
+ expect(d.err.join("\n")).toContain(EDITOR_CHAT_REFUSAL);
+ if (argv.includes("--json")) expect(JSON.parse(d.err[0])).toMatchObject({ ok: false, error: { code: "unsupported", details: { op, reason: "editor_chat" } } });
+ });
+
+ it("retains wait instructions for ordinary jobs and omits them only for native assets", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ expect(await runOpFromCli("games.list", [], d)).toBe(0);
+ expect(d.hosted).toHaveBeenCalledTimes(1);
+ expect(d.out[0]).toContain("summer jobs wait --job-id gen:1");
+ expect(formatAnswer({ ok: true, status: "running", job: { id: "aj_native-job", state: "queued" } })).not.toMatch(/jobs wait/);
+ });
+
+ it.each(["build:1", "upload:1", "apr:1", "gen:existing-cli-job", "legacy-generation-id"])("keeps explicit reads and waits for %s", async (jobId) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ for (const op of ["jobs.get", "jobs.wait"]) {
+ const d = deps();
+ expect(await runOpFromCli(op, ["--input", JSON.stringify({ jobId }), "--json"], d)).toBe(0);
+ expect(d.hosted).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op, input: { jobId } }));
+ }
+ });
+
+ it("honors a job-id flag overriding --input before deciding whether to block", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ expect(await runOpFromCli("jobs.wait", ["--input", '{"jobId":"aj_native-job"}', "--job-id", "build:1"], d)).toBe(0);
+ expect(d.hosted).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait", input: { jobId: "build:1" } }));
+ });
+
+ it.each([
+ ["projects.build", "build:1"],
+ ["store.publish", "apr:1"],
+ ["makers.bankai_motion", "gen:maker-job"],
+ ["assets.generate_video", "gen:video-job"],
+ ])("follows %s to completion with --wait", async (op, jobId) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ const approval = { id: "a1", who: "owner" as const, summary: "Publish game", url: "https://example.com/approval" };
+ const started = op === "store.publish"
+ ? { ok: true as const, status: "needs_approval" as const, approval, job: { id: jobId, state: "awaiting_approval" as const } }
+ : { ok: true as const, status: "running" as const, job: { id: jobId, state: "queued" as const } };
+ d.hosted.mockImplementation(async (_name, args) => envelope(args.op === "jobs.wait"
+ ? { ok: true, status: "done", result: { job: { id: jobId, state: "done", result: { completed: true } }, finished: true } }
+ : started));
+
+ expect(formatAnswer(started)).toContain(`summer jobs wait --job-id ${jobId}`);
+ expect(await runOpFromCli(op, ["--input", "{}", "--wait", "--json"], d)).toBe(0);
+ expect(JSON.parse(d.out.at(-1)!)).toMatchObject({ ok: true, status: "done", result: { completed: true } });
+ expect(d.hosted.mock.calls.map(([, args]) => args.op)).toEqual([op, "jobs.wait"]);
+ expect(d.hosted).toHaveBeenLastCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait", input: { jobId, timeoutSeconds: 50 } }));
+ });
+
+ it("stops automatic waiting when an approval hands off to a native asset delivery", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ d.hosted.mockImplementation(async (_name, args) => envelope(args.op === "jobs.wait"
+ ? { ok: true, status: "done", result: { job: { id: "apr:1", state: "done", result: { status: "running", job: { id: "aj_native-job", state: "queued" } } }, finished: true } }
+ : { ok: true, status: "needs_approval", approval: { id: "a1", who: "owner", summary: "Approve", url: "https://example.com/approval" }, job: { id: "apr:1", state: "awaiting_approval" } }));
+ expect(await runOpFromCli("makers.bankai_motion", ["--input", "{}", "--wait"], d)).toBe(0);
+ expect(d.out.at(-1)).toContain("The editor will deliver its result");
+ expect(d.out.at(-1)).not.toContain("summer jobs wait");
+ expect(d.hosted.mock.calls.filter(([, args]) => args.op === "jobs.wait")).toHaveLength(1);
+ });
+
+ it("returns a native job immediately even if an allowed operation was called with --wait", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ d.hosted.mockResolvedValue(envelope({ ok: true, status: "running", job: { id: "aj_native-job", state: "queued" } }));
+ expect(await runOpFromCli("makers.bankai_motion", ["--input", "{}", "--wait", "--json"], d)).toBe(0);
+ expect(d.hosted).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(d.out[0])).toMatchObject({ ok: true, status: "running", job: { id: "aj_native-job" } });
+ });
+
+ it("ops search does not list those operations", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const d = deps();
+ d.hosted.mockImplementation(async () => envelope({ ok: true, status: "done", result: { items: [] } }));
+ for (const query of ["wait for a job", "generate a 3D model"]) {
+ await searchFromCli(query, { limit: 20, json: true }, d);
+ const names = (JSON.parse(d.out.pop() ?? "{}").result.items as Array<{ name: string }>).map((item) => item.name);
+ expect(names.filter((name) => /^assets\.generate_(3d|image|audio|motion)$/.test(name))).toEqual([]);
+ }
+ for (const op of ["jobs.wait", "jobs.get", "makers.bankai_motion", "assets.generate_video"]) {
+ await searchFromCli(op, { limit: 20, json: true }, d);
+ const names = JSON.parse(d.out.pop()!).result.items.map((item: { name: string }) => item.name);
+ expect(names).toContain(op);
+ }
+ });
+
+ it("summer tool refuses the job and generation tools, local and hosted, without running them", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const call = vi.spyOn(hostedCli, "call");
+ for (const [name, op] of [["summer_check_job", "jobs.get"], ["summer_generate_3d", "assets.generate_3d"], ["summer_jobs_wait", "jobs.wait"]]) {
+ const lines: string[] = [];
+ const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => void lines.push(String(line)));
+ const previous = process.exitCode;
+ process.exitCode = undefined;
+ try {
+ await toolCommand.parseAsync([name, "--args", '{"jobId":"aj_native-job","prompt":"a chest"}'], { from: "user" });
+ expect(process.exitCode).toBe(1);
+ } finally {
+ process.exitCode = previous;
+ log.mockRestore();
+ }
+ expect(JSON.parse(lines.join("\n"))).toMatchObject({ ok: false, error: { code: "unsupported", message: EDITOR_CHAT_REFUSAL, details: { op } } });
+ }
+ expect(call).not.toHaveBeenCalled();
+ });
+
+ it("summer tool forwards build and approval waits and maker operations", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const call = vi.spyOn(hostedCli, "call").mockImplementation(async (name) => ({
+ status: "called", tool: name, result: envelope({ ok: true, status: "done", result: {} }),
+ }));
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ for (const [name, args] of [["summer_jobs_wait", { jobId: "build:1" }], ["summer_jobs_wait", { jobId: "apr:1" }], ["summer_tool_bankai_motion", {}]] as const) {
+ await toolCommand.parseAsync([name, "--args", JSON.stringify(args)], { from: "user" });
+ expect(call).toHaveBeenLastCalledWith(name, args);
+ }
+ });
+});
+
+describe("summer outside the editor chat", () => {
+ it("waits on a generation job with --wait, as before", async () => {
+ const d = deps();
+ expect(await runOpFromCli("assets.generate_3d", ["--input", '{"prompt":"a chest"}', "--wait", "--json"], d)).toBe(0);
+ expect(d.hosted).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait" }));
+ expect(formatAnswer({ ok: true, status: "running", job: { id: "gen:2", state: "queued" } })).toContain("summer jobs wait --job-id gen:2");
+ });
+});
diff --git a/src/cli/commands/ops.ts b/src/cli/commands/ops.ts
index 254f6a07..74a5ba6f 100644
--- a/src/cli/commands/ops.ts
+++ b/src/cli/commands/ops.ts
@@ -10,6 +10,7 @@ import { loadLibraryIndex } from "../../core/library-search.js";
import { FileSaver, withOutField } from "../../core/ops/save-files.js";
import { dispatchTool, EngineUnavailableError, ToolResultError } from "../../core/capabilities/tool-dispatch.js";
import { noteCliError, noteCliHosted } from "../../core/cli-usage.js";
+import { editorChatBlockedCall, editorChatRefusal, isEditorChat, isEditorNativeAssetJob } from "../../core/editor-chat.js";
/**
* The CLI view of the operation catalog (on by default; SUMMER_SURFACE=v3 leaves it out):
@@ -181,6 +182,8 @@ async function waitFor(answer: Answer, deps: OpsCliDeps): Promise {
if (current.ok && current.status === "needs_approval") deps.printError(`Waiting for approval: ${current.approval.url}`);
for (let round = 0; round < 120 && current.ok && (current.status === "running" || current.status === "needs_approval"); round++) {
const job = current.job.id;
+ // An approval can hand off to a native asset job. Its delivery belongs to the editor.
+ if (isEditorChat() && isEditorNativeAssetJob(job)) return current;
const waited = await runCloud("jobs.wait", { input: { jobId: job, timeoutSeconds: 50 }, json: true, dryRun: false, wait: false }, deps);
// The job runs on whatever the wait answered: a failed wait names it, so the job is waited on, not started again.
// One that did not get through says to wait again; any other keeps its own next step (sign in, slow down).
@@ -220,6 +223,7 @@ function problemLines(details: Record | undefined): string {
}
export function formatAnswer(answer: Answer, out?: string): string {
+ const nativeJob = (id: string) => isEditorChat() && isEditorNativeAssetJob(id);
const waitFlags = (id: string) => `summer jobs wait --job-id ${id}${out ? ` --out ${shellWord(out)}` : ""}`;
if (!answer.ok) {
const next = answer.error.next;
@@ -230,9 +234,10 @@ export function formatAnswer(answer: Answer, out?: string): string {
case "done":
return JSON.stringify(answer.result, null, 2);
case "running":
- return `Started: job ${answer.job.id}. Wait for it: ${waitFlags(answer.job.id)}`;
+ // Only native asset deliveries omit the wait command.
+ return nativeJob(answer.job.id) ? `Started: job ${answer.job.id}. The editor will deliver its result in this conversation.` : `Started: job ${answer.job.id}. Wait for it: ${waitFlags(answer.job.id)}`;
case "needs_approval":
- return `Needs approval (${answer.approval.who}): ${answer.approval.summary}\n${answer.approval.url}\nNothing changes until they approve. Follow it: ${waitFlags(answer.job.id)}`;
+ return `Needs approval (${answer.approval.who}): ${answer.approval.summary}\n${answer.approval.url}\nNothing changes until they approve. ${nativeJob(answer.job.id) ? "The editor will deliver its result in this conversation." : `Follow it: ${waitFlags(answer.job.id)}`}`;
case "dry_run":
return `Dry run: ${String(answer.preview.summary ?? "")}\n${JSON.stringify(answer.preview, null, 2)}`;
}
@@ -246,6 +251,17 @@ export async function runOpFromCli(op: string, argv: readonly string[], deps: Op
}
let call: ParsedCall;
try {
+ // Parse jobId before schema lookup so native jobs are refused without a
+ // network call. Ordinary jobs and --wait retain the usual execution path.
+ const refused = isEditorChat()
+ ? editorChatBlockedCall("summer_call", { op, input: parseCallFlags(argv, null).input })
+ : null;
+ if (refused) {
+ noteCliError("unsupported");
+ const answer = editorChatRefusal(refused);
+ deps.printError(argv.includes("--json") ? JSON.stringify(answer) : formatAnswer(answer));
+ return 1;
+ }
const hasFields = argv.some((token) => token.startsWith("--") && !RESERVED_FLAGS.has(token.slice(2).split("=")[0]));
call = parseCallFlags(argv, hasFields ? await schemaFor(op, deps) : null);
} catch (error) {
diff --git a/src/cli/commands/tool.ts b/src/cli/commands/tool.ts
index 62cf231d..cbaea2f5 100644
--- a/src/cli/commands/tool.ts
+++ b/src/cli/commands/tool.ts
@@ -21,7 +21,8 @@ import {
type ToolDispatchEntry,
} from "../../core/capabilities/tool-dispatch.js";
import { c } from "../../core/format.js";
-import { noteCliHosted, noteCliTool } from "../../core/cli-usage.js";
+import { noteCliError, noteCliHosted, noteCliTool } from "../../core/cli-usage.js";
+import { editorChatBlockedCall, editorChatRefusal, isEditorChat } from "../../core/editor-chat.js";
import {
callHostedToolForCli,
hostedResultPayload,
@@ -154,6 +155,14 @@ export const toolCommand = new Command("tool")
}
const entry = resolveToolForCli(name);
+ // Redirect native asset operations and native job polling; leave other jobs available.
+ const refused = isEditorChat() ? editorChatBlockedCall(entry?.name ?? name, parseJsonArgs(options.args ?? options.json)) : null;
+ if (refused) {
+ noteCliError("unsupported");
+ console.log(JSON.stringify(editorChatRefusal(refused), null, 2));
+ process.exitCode = 1;
+ return;
+ }
if (!entry) {
// Not a local tool: the hosted store and publishing tools, with the store sign-in.
// Summer's server records the call itself (X-Summer-Client-Surface: cli).
diff --git a/src/core/editor-chat.test.ts b/src/core/editor-chat.test.ts
new file mode 100644
index 00000000..111fe5dd
--- /dev/null
+++ b/src/core/editor-chat.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import { editorChatBlockedCall, editorChatInstructions, EDITOR_CHAT_JOB_SENTENCE, isEditorChat, isEditorChatBlockedOp } from "./editor-chat.js";
+
+describe("editor chat gate", () => {
+ it("is on only for SUMMER_EDITOR_CHAT=1", () => {
+ expect(isEditorChat({ SUMMER_EDITOR_CHAT: "1" })).toBe(true);
+ for (const value of [undefined, "", "0", "true", "yes"]) expect(isEditorChat({ SUMMER_EDITOR_CHAT: value })).toBe(false);
+ });
+
+ it("redirects only operations with a native summer_cloud equivalent", () => {
+ for (const op of ["assets.generate_3d", "assets.generate_image", "assets.generate_audio", "assets.generate_motion", "assets.edit_image", "assets.rig", "assets.retexture", "assets.remove_background"]) {
+ expect(isEditorChatBlockedOp(op), op).toBe(true);
+ }
+ for (const op of ["jobs.wait", "jobs.get", "jobs.list", "jobs.cancel", "assets.get", "assets.search", "assets.improve_prompt", "assets.generate_video", "assets.generate_sprite_sheet", "assets.generate_sfx_choices", "assets.generate_new_kind", "makers.bankai_motion", "makers.newer_than_the_catalog", "projects.build", "store.publish", "engine.add_node", "docs.search"]) {
+ expect(isEditorChatBlockedOp(op), op).toBe(false);
+ }
+ });
+
+ it.each(["aj_call-status", "gen:aj_call-status"])("refuses native asset job %s through every call shape", (jobId) => {
+ expect(editorChatBlockedCall("summer_jobs_wait", { jobId })).toBe("jobs.wait");
+ expect(editorChatBlockedCall("summer_check_job", { jobId })).toBe("jobs.get");
+ for (const key of ["op", "name"]) {
+ for (const name of ["jobs.wait", "summer_jobs_wait"]) {
+ expect(editorChatBlockedCall("summer_call", { [key]: name, input: { jobId } })).toBe("jobs.wait");
+ }
+ expect(editorChatBlockedCall("summer_call", { [key]: "summer_check_job", input: { jobId } })).toBe("jobs.get");
+ }
+ });
+
+ it.each(["build:1", "upload:1", "apr:1", "op:1", "plan:1", "engine:1", "gen:existing-cli-job", "legacy-generation-id", "cj_00000000-0000-4000-8000-000000000001"])("preserves tracking for %s", (jobId) => {
+ expect(editorChatBlockedCall("summer_jobs_wait", { jobId })).toBeNull();
+ expect(editorChatBlockedCall("summer_check_job", { jobId })).toBeNull();
+ expect(editorChatBlockedCall("summer_call", { op: "jobs.get", input: { jobId } })).toBeNull();
+ expect(editorChatBlockedCall("summer_call", { name: "summer_jobs_wait", input: { jobId } })).toBeNull();
+ });
+
+ it("keeps job tools discoverable and leaves missing/invalid IDs to validation", () => {
+ for (const jobId of [undefined, null, 1, "", "aj_", "aj_ bad"]) {
+ expect(editorChatBlockedCall("summer_jobs_wait", { jobId })).toBeNull();
+ }
+ expect(editorChatBlockedCall("summer_jobs_wait", {})).toBeNull();
+ expect(editorChatBlockedCall("summer_check_job", {})).toBeNull();
+ expect(editorChatBlockedCall("summer_board_build_status", {})).toBeNull();
+ expect(editorChatBlockedCall("summer_call", { op: "summer_board_agent_task" })).toBeNull();
+ });
+
+ it("finds native generation aliases without blocking makers", () => {
+ for (const name of ["assets.generate_3d", "summer_assets_generate_3d", "summer_generate_3d"]) {
+ expect(editorChatBlockedCall("summer_call", { op: name })).toBe("assets.generate_3d");
+ }
+ expect(editorChatBlockedCall("summer_assets_generate_3d", {})).toBe("assets.generate_3d");
+ expect(editorChatBlockedCall("summer_tool_bankai_motion", {})).toBeNull();
+ expect(editorChatBlockedCall("summer_call", { op: "makers.bankai_motion" })).toBeNull();
+ });
+
+ it("adds the sentence to instructions with no job sentence", () => {
+ expect(editorChatInstructions("Summer MCP.")).toBe(`Summer MCP. ${EDITOR_CHAT_JOB_SENTENCE}`);
+ });
+});
diff --git a/src/core/editor-chat.ts b/src/core/editor-chat.ts
new file mode 100644
index 00000000..97ca100c
--- /dev/null
+++ b/src/core/editor-chat.ts
@@ -0,0 +1,85 @@
+import { errorAnswer, legacyNames, opForToolCall, resolveOpName, type Answer } from "./ops/contract.js";
+
+/** The editor sets this for its own chat's MCP server and agent shell. */
+export const EDITOR_CHAT_ENV = "SUMMER_EDITOR_CHAT";
+
+export function isEditorChat(env: NodeJS.ProcessEnv = process.env): boolean {
+ return env[EDITOR_CHAT_ENV] === "1";
+}
+
+export const EDITOR_CHAT_JOB_SENTENCE =
+ "In the Summer editor chat, use summer_cloud for supported asset generation: native aj_ asset jobs deliver their results in this conversation, so never poll or wait for them. Other operations, including makers, builds and approvals, keep their normal job tracking.";
+
+const JOB_SENTENCES = ["Long work answers with a job: jobs.wait.", "A job returns a jobId: summer_check_job."];
+
+export function editorChatInstructions(text: string): string {
+ const sentence = JOB_SENTENCES.find((candidate) => text.includes(candidate));
+ return sentence ? text.split(sentence).join(EDITOR_CHAT_JOB_SENTENCE) : `${text} ${EDITOR_CHAT_JOB_SENTENCE}`;
+}
+
+export const EDITOR_CHAT_REFUSAL =
+ "Use the Summer editor chat's summer_cloud tools for this asset operation. Native aj_ asset jobs deliver their results in this conversation; do not poll, wait or resubmit them. Other jobs keep their normal tracking.";
+
+export function editorChatRefusal(op: string): Answer {
+ return errorAnswer("unsupported", EDITOR_CHAT_REFUSAL, {
+ next: { say: "Use the chat's summer_cloud tools; a native asset job's result arrives in this conversation." },
+ details: { op, reason: "editor_chat" },
+ });
+}
+
+/**
+ * Explicit equivalents of the editor's generate3D / generate3DFromImage,
+ * generateImage, generateAudio, generateAnimation / animateFromText,
+ * editImage, rigModel, retexture3D and removeImageBackground tools.
+ * Keep this list narrow: makers, videos, sprite sheets and future operations
+ * have no guaranteed native equivalent. A paid operation is not enough to
+ * establish that the editor can perform it or deliver its result.
+ */
+const NATIVE_ASSET_OPS: ReadonlySet = new Set([
+ "assets.generate_3d",
+ "assets.generate_image",
+ "assets.generate_audio",
+ "assets.generate_motion",
+ "assets.edit_image",
+ "assets.rig",
+ "assets.retexture",
+ "assets.remove_background",
+]);
+
+/** Only these operations can be hidden without inspecting their input. */
+export function isEditorChatBlockedOp(op: string): boolean {
+ return NATIVE_ASSET_OPS.has(op);
+}
+
+/**
+ * The editor's equinox_user_events.cpp accepts aj_ IDs for native asset jobs
+ * (jobFamily: asset). The CLI can wrap a backend ID as gen:. Do not infer
+ * ownership from gen: alone or from an unprefixed legacy generation ID:
+ * those may belong to makers or other CLI operations we still support.
+ * cj_ native tasks and build:/upload:/apr:/op: jobs are not asset deliveries.
+ */
+export function isEditorNativeAssetJob(jobId: unknown): boolean {
+ if (typeof jobId !== "string") return false;
+ const id = jobId.trim().replace(/^gen:/, "");
+ return id.length <= 256 && /^aj_[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(id);
+}
+
+function blocked(op: string | null, args: unknown): string | null {
+ if (!op) return null;
+ if (isEditorChatBlockedOp(op)) return op;
+ const input = args && typeof args === "object" ? args as { jobId?: unknown } : {};
+ return (op === "jobs.get" || op === "jobs.wait") && isEditorNativeAssetJob(input.jobId) ? op : null;
+}
+
+/** Resolve generic, direct, v3 and v4 calls before any handler can run. */
+export function editorChatBlockedCall(tool: string, args: unknown): string | null {
+ if (tool === "summer_search") return null;
+ if (tool === "summer_call") {
+ const call = args && typeof args === "object" ? (args as { op?: unknown; name?: unknown; input?: unknown }) : {};
+ const name = typeof call.op === "string" ? call.op.trim() : typeof call.name === "string" ? call.name.trim() : "";
+ return name.includes(".") ? blocked(resolveOpName(name), call.input) : name ? editorChatBlockedCall(name, call.input) : null;
+ }
+ // The board's v3 status tools were merged into jobs.get; keep them intact.
+ if (legacyNames()[tool]?.kind === "merge") return null;
+ return blocked(opForToolCall(tool, args), args);
+}
diff --git a/src/core/ops/cloud.ts b/src/core/ops/cloud.ts
index 8dc39b95..df334f01 100644
--- a/src/core/ops/cloud.ts
+++ b/src/core/ops/cloud.ts
@@ -5,6 +5,7 @@ import { errorAnswer, isLocalOp, resolveOpName, type Answer, type ErrorCode } fr
import { shippedCloudEntries, shippedWireForm } from "./public-catalog.js";
import { withOutField } from "./save-files.js";
import { searchEntries } from "./search.js";
+import { isEditorChat, isEditorChatBlockedOp } from "../editor-chat.js";
/**
* The hosted Summer server's v4 surface: the same address with `?surface=v4`
@@ -206,7 +207,10 @@ export async function searchEverywhere(
for (const entry of live) {
cloud.set(entry.name, { ...cloud.get(entry.name), ...entry, ...(entry.input ? { input: withOutField(entry.name, entry.input) as Record } : {}) });
}
- const items = searchEntries([...localEntries(), ...cloud.values()], query, filters).map(({ topics: _topics, ...entry }) =>
+ // Hide only native generation equivalents; job tracking stays discoverable.
+ const editorChat = isEditorChat();
+ const candidates = [...localEntries(), ...cloud.values()].filter((entry) => !editorChat || !isEditorChatBlockedOp(entry.name));
+ const items = searchEntries(candidates, query, filters).map(({ topics: _topics, ...entry }) =>
isLocalOp(entry.name)
? { ...entry, input: localInputSchema(entry.name) ?? { type: "object" }, mcpTool: entry.mcpTool && isDirect(entry.mcpTool) ? entry.mcpTool : undefined }
: entry
diff --git a/src/mcp/server.editor-chat.test.ts b/src/mcp/server.editor-chat.test.ts
new file mode 100644
index 00000000..acb40580
--- /dev/null
+++ b/src/mcp/server.editor-chat.test.ts
@@ -0,0 +1,182 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { setSummerDirForTests } from "../core/store.js";
+import { EDITOR_CHAT_REFUSAL } from "../core/editor-chat.js";
+import { createMcpServer } from "./server.js";
+import { resolveToolSelection } from "./tool-surface.js";
+import type { CloudCaller } from "./ops-surface.js";
+
+let root = "";
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), "summer-editor-chat-test-"));
+ setSummerDirForTests(join(root, ".summer"));
+ vi.stubEnv("SUMMER_NO_TELEMETRY", "1");
+});
+
+afterEach(async () => {
+ vi.unstubAllEnvs();
+ setSummerDirForTests(null);
+ await rm(root, { recursive: true, force: true });
+});
+
+const envelope = (value: unknown) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
+const answerOf = (result: unknown) => JSON.parse((result as { content: Array<{ text: string }> }).content[0].text);
+
+/** A hosted server that starts jobs and answers waits; native asset calls must not reach it. */
+function fakeCloud(): CloudCaller & { call: ReturnType } {
+ return {
+ call: vi.fn(async (name: string, args: Record) => {
+ if (name === "summer_search") return envelope({ ok: true, status: "done", result: { items: [] } });
+ if (args.op === "jobs.wait") return envelope({ ok: true, status: "done", result: { job: { id: "gen:1", state: "running" }, finished: false } });
+ return envelope({ ok: true, status: "running", job: { id: "aj_native-job", state: "queued" } });
+ }),
+ };
+}
+
+async function session(surface: "v3" | "v4", options: { profile?: string; legacy?: boolean } = {}) {
+ const cloud = fakeCloud();
+ const { server } = createMcpServer(resolveToolSelection({ profile: options.profile ?? "full" }, {}), { surface, cloud, legacy: options.legacy });
+ // A hosted direct tool as the hosted mount registers it (hosted-mount.ts): registerTool, after the local tools.
+ const hostedHandler = vi.fn(async () => envelope({ ok: true, status: "running", job: { id: "gen:2", state: "queued" } }));
+ for (const name of ["summer_jobs_wait", "summer_assets_generate_3d", "summer_tool_bankai_motion", "summer_games_list"]) {
+ server.registerTool(name, { description: `hosted ${name}` }, hostedHandler as never);
+ }
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
+ await server.connect(serverSide);
+ const client = new Client({ name: "summer-editor-chat", version: "1.0.0" });
+ await client.connect(clientSide);
+ return { client, cloud, hostedHandler, close: async () => { await client.close(); await server.close(); } };
+}
+
+function expectRefusal(result: unknown, op: string) {
+ expect((result as { isError?: boolean }).isError).toBe(true);
+ expect(answerOf(result)).toEqual({
+ ok: false,
+ error: { code: "unsupported", message: EDITOR_CHAT_REFUSAL, retry: "no", next: { say: expect.stringContaining("summer_cloud") }, details: { op, reason: "editor_chat" } },
+ });
+}
+
+describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) redirects native asset operations and polling", () => {
+ it.each([
+ ["jobs.wait", { jobId: "aj_native-job", timeoutSeconds: 50 }, "jobs.wait"],
+ ["jobs.get", { jobId: "aj_native-job" }, "jobs.get"],
+ ["summer_check_job", { jobId: "aj_native-job" }, "jobs.get"],
+ ["summer_jobs_wait", { jobId: "aj_native-job" }, "jobs.wait"],
+ ["assets.generate_3d", { prompt: "a chest" }, "assets.generate_3d"],
+ ["assets.generate_image", { prompt: "a chest" }, "assets.generate_image"],
+ ["summer_generate_image", { prompt: "a chest" }, "assets.generate_image"],
+ ["assets.rig", { assetId: "a1" }, "assets.rig"],
+ ])("summer_call %s answers at once with the refusal, without Summer's server", async (op, input, resolved) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const { client, cloud, close } = await session("v4");
+ try {
+ expectRefusal(await client.callTool({ name: "summer_call", arguments: { op, input } }), resolved);
+ // `name` is an alias of op: it is refused the same way.
+ expectRefusal(await client.callTool({ name: "summer_call", arguments: { name: op, input } }), resolved);
+ expect(cloud.call).not.toHaveBeenCalled();
+ } finally {
+ await close();
+ }
+ });
+
+ it("hides native generation tools but keeps job and maker tools discoverable", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const { client, hostedHandler, close } = await session("v4");
+ try {
+ const names = (await client.listTools()).tools.map((tool) => tool.name);
+ for (const hidden of ["summer_assets_generate_3d"]) expect(names).not.toContain(hidden);
+ expect(names).toEqual(expect.arrayContaining(["summer_call", "summer_search", "summer_games_list", "summer_jobs_wait", "summer_tool_bankai_motion"]));
+ expectRefusal(await client.callTool({ name: "summer_jobs_wait", arguments: { jobId: "aj_native-job" } }), "jobs.wait");
+ expectRefusal(await client.callTool({ name: "summer_assets_generate_3d", arguments: { prompt: "a chest" } }), "assets.generate_3d");
+ expect(hostedHandler).not.toHaveBeenCalled();
+ // Everything else still runs.
+ await client.callTool({ name: "summer_games_list", arguments: {} });
+ await client.callTool({ name: "summer_jobs_wait", arguments: { jobId: "build:1" } });
+ await client.callTool({ name: "summer_tool_bankai_motion", arguments: {} });
+ expect(hostedHandler).toHaveBeenCalledTimes(3);
+ } finally {
+ await close();
+ }
+ });
+
+ it("refuses the 3.x generation and job tools on the v3 surface and the legacy toolset", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ for (const surface of ["v3", "v4"] as const) {
+ const { client, close } = await session(surface, { legacy: surface === "v4" });
+ try {
+ const names = (await client.listTools()).tools.map((tool) => tool.name);
+ for (const hidden of ["summer_generate_image", "summer_generate_3d"]) expect(names).not.toContain(hidden);
+ expect(names).toContain("summer_check_job");
+ expectRefusal(await client.callTool({ name: "summer_check_job", arguments: { jobId: "aj_native-job" } }), "jobs.get");
+ expectRefusal(await client.callTool({ name: "summer_generate_3d", arguments: { prompt: "a chest" } }), "assets.generate_3d");
+ } finally {
+ await close();
+ }
+ }
+ });
+
+ it("summer_search and ops.search hide only native generation equivalents", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const { client, close } = await session("v4");
+ try {
+ for (const query of ["wait for a job", "check a job", "generate a 3D model", "generate an image"]) {
+ const direct = answerOf(await client.callTool({ name: "summer_search", arguments: { query, limit: 20 } }));
+ const viaCall = answerOf(await client.callTool({ name: "summer_call", arguments: { op: "ops.search", input: { query, limit: 20 } } }));
+ for (const answer of [direct, viaCall]) {
+ const found = (answer.result.items as Array<{ name: string }>).map((item) => item.name);
+ expect(found.filter((name) => /^assets\.generate_(3d|image|audio|motion)$/.test(name))).toEqual([]);
+ }
+ }
+ } finally {
+ await close();
+ }
+ });
+
+ it.each(["jobs.wait", "jobs.get"])("keeps %s callable for builds, approvals and ordinary CLI jobs", async (op) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const { client, cloud, close } = await session("v4");
+ try {
+ for (const jobId of ["build:1", "upload:1", "apr:1", "gen:existing-cli-job", "legacy-generation-id"]) {
+ await client.callTool({ name: "summer_call", arguments: { op, input: { jobId } } });
+ expect(cloud.call).toHaveBeenLastCalledWith("summer_call", expect.objectContaining({ op, input: { jobId } }));
+ }
+ } finally {
+ await close();
+ }
+ });
+
+ it.each(["jobs.wait", "jobs.get", "makers.bankai_motion", "assets.generate_video"])("keeps %s searchable and callable", async (op) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const { client, cloud, close } = await session("v4");
+ try {
+ const found = answerOf(await client.callTool({ name: "summer_search", arguments: { query: op, limit: 20 } }));
+ expect(found.result.items.map((item: { name: string }) => item.name)).toContain(op);
+ await client.callTool({ name: "summer_call", arguments: { op, input: { jobId: "build:1" } } });
+ expect(cloud.call).toHaveBeenLastCalledWith("summer_call", expect.objectContaining({ op }));
+ } finally {
+ await close();
+ }
+ });
+
+ it("outside the editor chat, summer_call forwards jobs.wait and generation, and search finds them, as before", async () => {
+ const { client, cloud, close } = await session("v4");
+ try {
+ const waited = answerOf(await client.callTool({ name: "summer_call", arguments: { op: "jobs.wait", input: { jobId: "gen:1" } } }));
+ expect(waited).toMatchObject({ ok: true, status: "done" });
+ const started = answerOf(await client.callTool({ name: "summer_call", arguments: { op: "assets.generate_3d", input: { prompt: "a chest" } } }));
+ expect(started).toMatchObject({ ok: true, status: "running" });
+ expect(cloud.call).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait" }));
+ const names = (await client.listTools()).tools.map((tool) => tool.name);
+ expect(names).toContain("summer_jobs_wait");
+ const found = answerOf(await client.callTool({ name: "summer_search", arguments: { query: "wait for a job", limit: 20 } }));
+ expect((found.result.items as Array<{ name: string }>).map((item) => item.name)).toContain("jobs.wait");
+ } finally {
+ await close();
+ }
+ });
+});
diff --git a/src/mcp/server.instructions.test.ts b/src/mcp/server.instructions.test.ts
index 249a5893..94fcd32e 100644
--- a/src/mcp/server.instructions.test.ts
+++ b/src/mcp/server.instructions.test.ts
@@ -1,10 +1,11 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { SUMMER_MCP_ASSETS_INSTRUCTIONS, SUMMER_MCP_INSTRUCTIONS, createMcpServer } from "./server.js";
import { getSkillRegistry, selectSkillsForBulkInstall } from "../core/skills-registry.js";
import { resolveToolSelection } from "./tool-surface.js";
import { FIRST_GAME_LOOK_RULE, OPS_MCP_ASSETS_INSTRUCTIONS, OPS_MCP_INSTRUCTIONS } from "./ops-surface.js";
+import { EDITOR_CHAT_JOB_SENTENCE, editorChatInstructions } from "../core/editor-chat.js";
describe("MCP server instructions (initialize response)", () => {
it("is one tight paragraph naming the five first-turn habits", () => {
@@ -127,3 +128,70 @@ describe("MCP server instructions (initialize response)", () => {
}
});
});
+
+describe("MCP server instructions in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ const JOB_WORDS = /jobs\.wait|jobs\.get|summer_check_job|summer_jobs_wait|summer_jobs_get/;
+ const variants = [
+ ["v4 engine", OPS_MCP_INSTRUCTIONS],
+ ["v4 assets", OPS_MCP_ASSETS_INSTRUCTIONS],
+ ["v3 engine", SUMMER_MCP_INSTRUCTIONS],
+ ["v3 assets", SUMMER_MCP_ASSETS_INSTRUCTIONS],
+ ] as const;
+
+ it.each(variants)("%s: distinguishes native asset delivery from other job tracking", (_label, text) => {
+ const editor = editorChatInstructions(text);
+ expect(editor.split(EDITOR_CHAT_JOB_SENTENCE)).toHaveLength(2);
+ expect(editor).not.toMatch(JOB_WORDS);
+ expect(editor).toContain("native aj_ asset jobs");
+ expect(editor).toContain("builds and approvals, keep their normal job tracking");
+ expect(editor).not.toContain("\n");
+ expect(editor.length).toBeLessThanOrEqual(2048);
+ });
+
+ it("keeps the first-game look rule inside the first 512 characters of the v4 engine variant", () => {
+ const editor = editorChatInstructions(OPS_MCP_INSTRUCTIONS);
+ expect(editor.indexOf(FIRST_GAME_LOOK_RULE) + FIRST_GAME_LOOK_RULE.length).toBeLessThanOrEqual(512);
+ expect(editor).toBe(OPS_MCP_INSTRUCTIONS.replace("Long work answers with a job: jobs.wait.", EDITOR_CHAT_JOB_SENTENCE));
+ });
+
+ it.each([
+ ["v4", "full", OPS_MCP_INSTRUCTIONS],
+ ["v4", "assets", OPS_MCP_ASSETS_INSTRUCTIONS],
+ ["v3", "full", SUMMER_MCP_INSTRUCTIONS],
+ ["v3", "assets", SUMMER_MCP_ASSETS_INSTRUCTIONS],
+ ] as const)("reaches a real client on %s, %s profile", async (surface, profile, text) => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
+ const { server } = createMcpServer(resolveToolSelection({ profile }, {}), { surface });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: "summer-instructions-test", version: "1.0.0" });
+ await server.connect(serverTransport);
+ try {
+ await client.connect(clientTransport);
+ expect(client.getInstructions()).toBe(editorChatInstructions(text));
+ expect(client.getInstructions()).toContain(EDITOR_CHAT_JOB_SENTENCE);
+ expect(client.getInstructions()).not.toMatch(JOB_WORDS);
+ } finally {
+ await client.close();
+ await server.close();
+ }
+ });
+
+ it("sends the usual instructions when the variable is not 1", async () => {
+ vi.stubEnv("SUMMER_EDITOR_CHAT", "0");
+ const { server } = createMcpServer(resolveToolSelection({ profile: "full" }, {}), { surface: "v4" });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: "summer-instructions-test", version: "1.0.0" });
+ await server.connect(serverTransport);
+ try {
+ await client.connect(clientTransport);
+ expect(client.getInstructions()).toBe(OPS_MCP_INSTRUCTIONS);
+ } finally {
+ await client.close();
+ await server.close();
+ }
+ });
+});
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 71247e86..a148a5c6 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -1,4 +1,5 @@
import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from "@modelcontextprotocol/sdk/types.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { EngineApiClient } from "../core/api-client.js";
import type { EngineSelection } from "../core/engine.js";
@@ -45,6 +46,7 @@ import { applyOpsSurface, hostedFileTools, OPS_MCP_ASSETS_INSTRUCTIONS, OPS_MCP_
import { FileSaver } from "../core/ops/save-files.js";
import { runInAppStudio } from "../core/ops/studio-page.js";
import { opForToolCall } from "../core/ops/contract.js";
+import { editorChatBlockedCall, editorChatInstructions, editorChatRefusal, isEditorChat } from "../core/editor-chat.js";
import { knownOp } from "../core/ops/catalog.js";
import { v4HostedUrl } from "../core/ops/cloud.js";
import { serveProviderSafeToolSchemas } from "./provider-safe-schema.js";
@@ -491,6 +493,11 @@ export function createMcpServer(
// A profile without engine tools (assets) gets the no-engine instructions,
// playbook and task plan, so nothing points the agent at a tool it lacks.
const engine = !selection.toolsets || selection.toolsets.has("engine");
+ const editorChat = isEditorChat();
+ const instructions =
+ surface === "v4"
+ ? engine ? OPS_MCP_INSTRUCTIONS : OPS_MCP_ASSETS_INSTRUCTIONS
+ : engine ? SUMMER_MCP_INSTRUCTIONS : SUMMER_MCP_ASSETS_INSTRUCTIONS;
const server = new McpServer(
{
name: "summer-engine",
@@ -498,9 +505,8 @@ export function createMcpServer(
},
{
instructions:
- (surface === "v4"
- ? engine ? OPS_MCP_INSTRUCTIONS : OPS_MCP_ASSETS_INSTRUCTIONS
- : engine ? SUMMER_MCP_INSTRUCTIONS : SUMMER_MCP_ASSETS_INSTRUCTIONS) +
+ // Native asset jobs deliver to editor chat; other jobs retain normal tracking.
+ (editorChat ? editorChatInstructions(instructions) : instructions) +
(options.notice ? ` ${options.notice} Tell the person once.` : ""),
}
);
@@ -561,10 +567,40 @@ export function createMcpServer(
const studio = (op: string, input: Record) => runInAppStudio(op, input, () => getClient({ launch: false }));
applyOpsSurface(server, { engine, cloud: options.cloud ?? NO_CLOUD, legacy: options.legacy === true, files, studio });
}
+ if (editorChat) gateEditorChat(server);
return { server, getRegisteredToolCount: () => getWrappedToolCount() - removed, files };
}
+/**
+ * The Summer editor chat (SUMMER_EDITOR_CHAT=1): no tool call waits on,
+ * checks a native asset job or starts an operation with a native equivalent.
+ * tools/call answers those at once with
+ * the refusal, before any handler or hosted call runs, local and hosted tools
+ * alike (the hosted mount's own handlers wrap these); tools/list leaves their
+ * generation tools out. Job tools stay listed because other jobs need them.
+ */
+function gateEditorChat(server: McpServer): void {
+ const handlers = (server.server as unknown as { _requestHandlers: Map Promise> })._requestHandlers;
+ const listTools = handlers.get("tools/list") as ((request: unknown, extra: unknown) => Promise<{ tools: Tool[] }>) | undefined;
+ if (listTools) {
+ server.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
+ const result = await listTools(request, extra);
+ return { ...result, tools: result.tools.filter((tool) => !editorChatBlockedCall(tool.name, {})) };
+ });
+ }
+ const callTool = handlers.get("tools/call");
+ if (callTool) {
+ server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
+ const { name, arguments: args } = request.params;
+ const op = editorChatBlockedCall(name, args ?? {});
+ if (!op) return (await callTool(request, extra)) as never;
+ const answer = editorChatRefusal(op);
+ return { content: [{ type: "text" as const, text: JSON.stringify(answer) }], structuredContent: answer as unknown as Record, isError: true };
+ });
+ }
+}
+
/** What the hosted tools a profile mounts are, for summer_store_tools. */
function hostedToolsLabel(toolsets: ReadonlySet): string | undefined {
if (toolsets.has("ship")) return "Summer store and publishing tools";