From ded62dce7e9db51b35ee31caa0babb1a8f505cdd Mon Sep 17 00:00:00 2001 From: frozaken Date: Mon, 26 Oct 2026 20:01:28 -0700 Subject: [PATCH 1/2] Never wait on a job in the Summer editor chat (SUMMER_EDITOR_CHAT=1) The editor's chat generates assets with its own summer_cloud tools, which return at once and post the finished job as a message in the conversation. This package still told that agent "Long work answers with a job: jobs.wait", let summer_call and the direct tools wait on, check and start generation jobs, and the CLI followed jobs with --wait, so the agent waited on jobs the chat already tracks. With SUMMER_EDITOR_CHAT=1 (core/editor-chat.ts): - every MCP instruction string carries the editor chat sentence in place of the job sentence; - tools/call answers jobs.wait, jobs.get and the generation operations (summer_call by op or name, the hosted and 3.x direct tools) at once with an `unsupported` refusal and no hosted call, and tools/list leaves those direct tools out; - summer_search / ops.search and summer ops search leave them out; - summer for those operations, any --wait, and summer tool for those tools exit 1 at once without calling Summer's server. Without the variable nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 + docs/DEVELOPMENT.md | 2 +- src/cli/commands/ops.editor-chat.test.ts | 92 ++++++++++++++ src/cli/commands/ops.ts | 12 +- src/cli/commands/tool.ts | 11 +- src/core/editor-chat.test.ts | 41 ++++++ src/core/editor-chat.ts | 87 +++++++++++++ src/core/ops/cloud.ts | 6 +- src/mcp/server.editor-chat.test.ts | 155 +++++++++++++++++++++++ src/mcp/server.instructions.test.ts | 68 +++++++++- src/mcp/server.ts | 41 +++++- 11 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 src/cli/commands/ops.editor-chat.test.ts create mode 100644 src/core/editor-chat.test.ts create mode 100644 src/core/editor-chat.ts create mode 100644 src/mcp/server.editor-chat.test.ts 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..1ea47f07 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 own chat's MCP server and agent shell: the MCP instructions say to generate with the chat's `summer_cloud` tools and never wait on a job; `jobs.wait`, `jobs.get` and the generation operations, by `summer_call`, their direct tools, `summer tool` or `summer `, and any `--wait`, answer `unsupported` at once with no call to Summer's server, and search leaves them out; `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..8eb224c8 --- /dev/null +++ b/src/cli/commands/ops.editor-chat.test.ts @@ -0,0 +1,92 @@ +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", "gen:5037aff5"]], + ["jobs.get", ["--job-id", "gen:5037aff5"]], + ["assets.generate_3d", ["--prompt", "a chest"]], + ["assets.generate_image", ["--prompt", "a chest", "--json"]], + ["makers.bankai_motion", []], + ["games.list", ["--wait"]], + ["store.status", ["--wait=true"]], + ])("%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("still runs everything else, and a started job names no wait command", 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]).toBe("Started: job gen:1."); + expect(formatAnswer({ ok: true, status: "running", job: { id: "gen:2", state: "queued" } })).not.toMatch(/jobs wait/); + }); + + 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) => /^jobs\.(wait|get)$|^assets\.generate_|^makers\./.test(name))).toEqual([]); + } + }); + + 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"], ["summer_tool_bankai_motion", "makers.bankai_motion"]]) { + 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":"gen:1","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(); + }); +}); + +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..a305e6e4 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 { editorChatRefusal, isEditorChat, isEditorChatBlockedOp } from "../../core/editor-chat.js"; /** * The CLI view of the operation catalog (on by default; SUMMER_SURFACE=v3 leaves it out): @@ -230,7 +231,8 @@ 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)}`; + // The Summer editor chat never waits on a job (core/editor-chat.ts). + return isEditorChat() ? `Started: job ${answer.job.id}.` : `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)}`; case "dry_run": @@ -244,6 +246,14 @@ export async function runOpFromCli(op: string, argv: readonly string[], deps: Op deps.printError(formatAnswer(errorAnswer("invalid_input", `"${op}" is not an operation name (area action).`, { next: { op: "ops.search" } }))); return 1; } + // The Summer editor chat (SUMMER_EDITOR_CHAT=1) generates with its own tools and never waits on, polls or + // checks a job: those commands and --wait fail here at once, before any call to Summer's server. + if (isEditorChat() && (isEditorChatBlockedOp(resolveOpName(op)) || argv.some((token) => token === "--wait" || token.startsWith("--wait=")))) { + const refused = editorChatRefusal(resolveOpName(op)); + noteCliError("unsupported"); + deps.printError(argv.includes("--json") ? JSON.stringify(refused) : formatAnswer(refused)); + return 1; + } let call: ParsedCall; try { const hasFields = argv.some((token) => token.startsWith("--") && !RESERVED_FLAGS.has(token.slice(2).split("=")[0])); diff --git a/src/cli/commands/tool.ts b/src/cli/commands/tool.ts index 62cf231d..49440738 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); + // The Summer editor chat never waits on, checks or starts a generation job here (core/editor-chat.ts). + 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..2aba777b --- /dev/null +++ b/src/core/editor-chat.test.ts @@ -0,0 +1,41 @@ +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("refuses waiting on and checking a job, and generation", () => { + for (const op of ["jobs.wait", "jobs.get", "assets.generate_3d", "assets.generate_image", "assets.generate_audio", "assets.generate_sfx_choices", "assets.generate_video", "assets.generate_motion", "assets.generate_sprite_sheet", "assets.generate_new_kind", "assets.edit_image", "assets.rig", "assets.retexture", "assets.remove_background", "makers.bankai_motion"]) { + expect(isEditorChatBlockedOp(op), op).toBe(true); + } + for (const op of ["jobs.list", "jobs.cancel", "assets.get", "assets.search", "assets.improve_prompt", "games.list", "store.go_live", "engine.add_node", "docs.search"]) { + expect(isEditorChatBlockedOp(op), op).toBe(false); + } + expect(isEditorChatBlockedOp("makers.newer_than_the_catalog", "spend")).toBe(true); + expect(isEditorChatBlockedOp("makers.newer_than_the_catalog", "read")).toBe(false); + }); + + it("finds the operation of a tool call by any of its names", () => { + expect(editorChatBlockedCall("summer_jobs_wait", {})).toBe("jobs.wait"); + expect(editorChatBlockedCall("summer_check_job", {})).toBe("jobs.get"); + expect(editorChatBlockedCall("summer_assets_generate_3d", {})).toBe("assets.generate_3d"); + expect(editorChatBlockedCall("summer_generate_3d", {})).toBe("assets.generate_3d"); + expect(editorChatBlockedCall("summer_tool_bankai_motion", {})).toBe("makers.bankai_motion"); + expect(editorChatBlockedCall("summer_call", { op: "jobs.wait" })).toBe("jobs.wait"); + expect(editorChatBlockedCall("summer_call", { name: "summer_jobs_wait" })).toBe("jobs.wait"); + expect(editorChatBlockedCall("summer_call", { op: "summer_check_job" })).toBe("jobs.get"); + // The board's 3.x status tools were merged into jobs.get; they are not job waits. + expect(editorChatBlockedCall("summer_board_build_status", {})).toBeNull(); + expect(editorChatBlockedCall("summer_call", { op: "summer_board_agent_task" })).toBeNull(); + for (const [tool, args] of [["summer_search", { query: "wait for a job" }], ["summer_call", {}], ["summer_call", { op: "games.list" }], ["summer_add_node", {}], ["summer_get_asset", {}]] as const) { + expect(editorChatBlockedCall(tool, args)).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..ec90f116 --- /dev/null +++ b/src/core/editor-chat.ts @@ -0,0 +1,87 @@ +import { errorAnswer, legacyNames, opForToolCall, resolveOpName, type Answer, type Effect } from "./ops/contract.js"; +import { publicCatalog } from "./ops/public-catalog.js"; + +/** + * The Summer editor's own chat (SUMMER_EDITOR_CHAT=1, set by the editor in + * this MCP server's environment and in its agent's shell). There, assets are + * generated only with the chat's summer_cloud tools: they return at once and + * the finished job arrives as a message in the conversation. So in that chat + * this package never waits on, polls or checks a job, and never starts a + * generation itself: the MCP instructions say so, and summer_call, the + * direct tools, summer_search and the CLI refuse or hide those operations. + * Without the variable nothing changes. + */ + +export const EDITOR_CHAT_ENV = "SUMMER_EDITOR_CHAT"; + +export function isEditorChat(env: NodeJS.ProcessEnv = process.env): boolean { + return env[EDITOR_CHAT_ENV] === "1"; +} + +/** The sentence the editor chat's MCP instructions carry in place of the job sentence. */ +export const EDITOR_CHAT_JOB_SENTENCE = + "In the Summer editor chat, generate assets only with the chat's summer_cloud tools: they return at once, and a message in this conversation tells you when each job finishes. Never wait for, poll or check on a job."; + +/** The job sentences of the instruction strings outside the editor chat (server.ts, ops-surface.ts). */ +const JOB_SENTENCES = ["Long work answers with a job: jobs.wait.", "A job returns a jobId: summer_check_job."]; + +/** Instructions for the editor chat: the job sentence replaced, or the editor sentence added when there is none. */ +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 = + "This does not run in the Summer editor chat: generate assets only with the chat's summer_cloud tools. The finished job arrives as a message in this conversation; never wait for, poll or check on a job."; + +/** The answer a refused operation gets, at once and with no call to Summer's server. */ +export function editorChatRefusal(op: string): Answer { + return errorAnswer("unsupported", EDITOR_CHAT_REFUSAL, { + next: { say: "Use the chat's summer_cloud tools; the result arrives as a message in this conversation." }, + details: { op, reason: "editor_chat" }, + }); +} + +const JOB_OPS: ReadonlySet = new Set(["jobs.wait", "jobs.get"]); +/** Paid, but makes no asset and answers at once (a better prompt). */ +const NOT_GENERATION: ReadonlySet = new Set(["assets.improve_prompt"]); + +let effects: Map | null = null; +const effectOf = (op: string): Effect | undefined => { + effects ??= new Map(publicCatalog().operations.map((row) => [row.name, row.effect])); + return effects.get(op); +}; + +/** + * True for the operations the editor chat refuses: waiting on or checking a + * job (jobs.wait, jobs.get) and generation: every assets.generate_*, and the + * other paid assets and makers operations (edit, rig, retexture, remove a + * background, the makers' jobs). `effect` is the live catalog row's, for an + * operation newer than the shipped catalog. + */ +export function isEditorChatBlockedOp(op: string, effect?: Effect): boolean { + if (JOB_OPS.has(op) || op.startsWith("assets.generate_")) return true; + if (NOT_GENERATION.has(op) || !/^(assets|makers)\./.test(op)) return false; + return (effect ?? effectOf(op)) === "spend"; +} + +/** + * The refused operation a tool call would run, or null: summer_call's op (or + * its `name`), or a direct tool's (summer_jobs_wait, summer_check_job, + * summer_assets_generate_3d, summer_generate_image, summer_tool_*). A 3.x + * tool merged into jobs.get (the board's task and build status) is not a job + * wait and stays. + */ +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() : ""; + // An operation name, or a tool name (3.x or v4) passed as the op. + return name.includes(".") ? blocked(resolveOpName(name)) : name ? editorChatBlockedCall(name, call.input) : null; + } + if (legacyNames()[tool]?.kind === "merge") return null; + return blocked(opForToolCall(tool, args)); +} + +const blocked = (op: string | null): string | null => (op && isEditorChatBlockedOp(op) ? op : null); diff --git a/src/core/ops/cloud.ts b/src/core/ops/cloud.ts index 8dc39b95..a191c1a6 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 }) => + // The Summer editor chat never waits on a job or generates here (core/editor-chat.ts): those operations are not found. + const editorChat = isEditorChat(); + const candidates = [...localEntries(), ...cloud.values()].filter((entry) => !editorChat || !isEditorChatBlockedOp(entry.name, entry.effect)); + 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..ac1111a9 --- /dev/null +++ b/src/mcp/server.editor-chat.test.ts @@ -0,0 +1,155 @@ +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 would start jobs and answer waits; the editor chat must never reach it for those. */ +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: "gen:5037aff5", 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) never waits on, checks or starts a generation job", () => { + it.each([ + ["jobs.wait", { jobId: "gen:5037aff5", timeoutSeconds: 50 }, "jobs.wait"], + ["jobs.get", { jobId: "gen:5037aff5" }, "jobs.get"], + ["summer_check_job", { jobId: "gen:5037aff5" }, "jobs.get"], + ["summer_jobs_wait", { jobId: "gen:5037aff5" }, "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"], + ["makers.bankai_motion", {}, "makers.bankai_motion"], + ])("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("refuses the hosted direct tools and leaves them out of tools/list", 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_jobs_wait", "summer_assets_generate_3d", "summer_tool_bankai_motion"]) expect(names).not.toContain(hidden); + expect(names).toEqual(expect.arrayContaining(["summer_call", "summer_search", "summer_games_list"])); + expectRefusal(await client.callTool({ name: "summer_jobs_wait", arguments: { jobId: "gen:1" } }), "jobs.wait"); + expectRefusal(await client.callTool({ name: "summer_assets_generate_3d", arguments: { prompt: "a chest" } }), "assets.generate_3d"); + expectRefusal(await client.callTool({ name: "summer_tool_bankai_motion", arguments: {} }), "makers.bankai_motion"); + expect(hostedHandler).not.toHaveBeenCalled(); + // Everything else still runs. + await client.callTool({ name: "summer_games_list", arguments: {} }); + expect(hostedHandler).toHaveBeenCalledTimes(1); + } 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", "summer_check_job"]) expect(names).not.toContain(hidden); + expectRefusal(await client.callTool({ name: "summer_check_job", arguments: { jobId: "gen:1" } }), "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 do not find those operations", 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) => /^jobs\.(wait|get)$|^assets\.generate_|^makers\./.test(name))).toEqual([]); + } + } + } 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..8f05aeb6 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,68 @@ 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: the job sentence becomes the editor chat sentence, and no job tool is named", (_label, text) => { + const editor = editorChatInstructions(text); + expect(editor.split(EDITOR_CHAT_JOB_SENTENCE)).toHaveLength(2); + expect(editor).not.toMatch(JOB_WORDS); + 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..84d4497c 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) + + // The Summer editor chat generates with its own tools and never waits on a job (core/editor-chat.ts). + (editorChat ? editorChatInstructions(instructions) : instructions) + (options.notice ? ` ${options.notice} Tell the person once.` : ""), } ); @@ -561,10 +567,39 @@ 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 or starts a generation job. 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 + * direct tools out. + */ +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"; From 92f89f495a2bd4e3a3fb18f550ab1ac84a4ae3d5 Mon Sep 17 00:00:00 2001 From: Fro <20891628+frozaken@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:46:34 +0100 Subject: [PATCH 2/2] fix: preserve non-native job tracking in editor chat --- docs/DEVELOPMENT.md | 2 +- src/cli/commands/ops.editor-chat.test.ts | 99 +++++++++++++++++++++--- src/cli/commands/ops.ts | 30 ++++--- src/cli/commands/tool.ts | 2 +- src/core/editor-chat.test.ts | 52 +++++++++---- src/core/editor-chat.ts | 96 +++++++++++------------ src/core/ops/cloud.ts | 4 +- src/mcp/server.editor-chat.test.ts | 63 ++++++++++----- src/mcp/server.instructions.test.ts | 4 +- src/mcp/server.ts | 7 +- 10 files changed, 244 insertions(+), 115 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1ea47f07..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_EDITOR_CHAT=1` (set by the Summer editor for its own chat's MCP server and agent shell: the MCP instructions say to generate with the chat's `summer_cloud` tools and never wait on a job; `jobs.wait`, `jobs.get` and the generation operations, by `summer_call`, their direct tools, `summer tool` or `summer `, and any `--wait`, answer `unsupported` at once with no call to Summer's server, and search leaves them out; `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). +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 index 8eb224c8..c60c174a 100644 --- a/src/cli/commands/ops.editor-chat.test.ts +++ b/src/cli/commands/ops.editor-chat.test.ts @@ -23,13 +23,13 @@ afterEach(() => { describe("summer in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => { it.each([ - ["jobs.wait", ["--job-id", "gen:5037aff5"]], - ["jobs.get", ["--job-id", "gen:5037aff5"]], + ["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"]], - ["makers.bankai_motion", []], - ["games.list", ["--wait"]], - ["store.status", ["--wait=true"]], + ["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(); @@ -41,13 +41,73 @@ describe("summer in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => { if (argv.includes("--json")) expect(JSON.parse(d.err[0])).toMatchObject({ ok: false, error: { code: "unsupported", details: { op, reason: "editor_chat" } } }); }); - it("still runs everything else, and a started job names no wait command", async () => { + 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]).toBe("Started: job gen:1."); - expect(formatAnswer({ ok: true, status: "running", job: { id: "gen:2", state: "queued" } })).not.toMatch(/jobs wait/); + 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 () => { @@ -57,20 +117,25 @@ describe("summer in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => { 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) => /^jobs\.(wait|get)$|^assets\.generate_|^makers\./.test(name))).toEqual([]); + 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"], ["summer_tool_bankai_motion", "makers.bankai_motion"]]) { + 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":"gen:1","prompt":"a chest"}'], { from: "user" }); + await toolCommand.parseAsync([name, "--args", '{"jobId":"aj_native-job","prompt":"a chest"}'], { from: "user" }); expect(process.exitCode).toBe(1); } finally { process.exitCode = previous; @@ -80,6 +145,18 @@ describe("summer in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => { } 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", () => { diff --git a/src/cli/commands/ops.ts b/src/cli/commands/ops.ts index a305e6e4..74a5ba6f 100644 --- a/src/cli/commands/ops.ts +++ b/src/cli/commands/ops.ts @@ -10,7 +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 { editorChatRefusal, isEditorChat, isEditorChatBlockedOp } from "../../core/editor-chat.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): @@ -182,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). @@ -221,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; @@ -231,10 +234,10 @@ export function formatAnswer(answer: Answer, out?: string): string { case "done": return JSON.stringify(answer.result, null, 2); case "running": - // The Summer editor chat never waits on a job (core/editor-chat.ts). - return isEditorChat() ? `Started: job ${answer.job.id}.` : `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,16 +249,19 @@ export async function runOpFromCli(op: string, argv: readonly string[], deps: Op deps.printError(formatAnswer(errorAnswer("invalid_input", `"${op}" is not an operation name (area action).`, { next: { op: "ops.search" } }))); return 1; } - // The Summer editor chat (SUMMER_EDITOR_CHAT=1) generates with its own tools and never waits on, polls or - // checks a job: those commands and --wait fail here at once, before any call to Summer's server. - if (isEditorChat() && (isEditorChatBlockedOp(resolveOpName(op)) || argv.some((token) => token === "--wait" || token.startsWith("--wait=")))) { - const refused = editorChatRefusal(resolveOpName(op)); - noteCliError("unsupported"); - deps.printError(argv.includes("--json") ? JSON.stringify(refused) : formatAnswer(refused)); - return 1; - } 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 49440738..cbaea2f5 100644 --- a/src/cli/commands/tool.ts +++ b/src/cli/commands/tool.ts @@ -155,7 +155,7 @@ export const toolCommand = new Command("tool") } const entry = resolveToolForCli(name); - // The Summer editor chat never waits on, checks or starts a generation job here (core/editor-chat.ts). + // 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"); diff --git a/src/core/editor-chat.test.ts b/src/core/editor-chat.test.ts index 2aba777b..111fe5dd 100644 --- a/src/core/editor-chat.test.ts +++ b/src/core/editor-chat.test.ts @@ -7,32 +7,50 @@ describe("editor chat gate", () => { for (const value of [undefined, "", "0", "true", "yes"]) expect(isEditorChat({ SUMMER_EDITOR_CHAT: value })).toBe(false); }); - it("refuses waiting on and checking a job, and generation", () => { - for (const op of ["jobs.wait", "jobs.get", "assets.generate_3d", "assets.generate_image", "assets.generate_audio", "assets.generate_sfx_choices", "assets.generate_video", "assets.generate_motion", "assets.generate_sprite_sheet", "assets.generate_new_kind", "assets.edit_image", "assets.rig", "assets.retexture", "assets.remove_background", "makers.bankai_motion"]) { + 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.list", "jobs.cancel", "assets.get", "assets.search", "assets.improve_prompt", "games.list", "store.go_live", "engine.add_node", "docs.search"]) { + 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); } - expect(isEditorChatBlockedOp("makers.newer_than_the_catalog", "spend")).toBe(true); - expect(isEditorChatBlockedOp("makers.newer_than_the_catalog", "read")).toBe(false); }); - it("finds the operation of a tool call by any of its names", () => { - expect(editorChatBlockedCall("summer_jobs_wait", {})).toBe("jobs.wait"); - expect(editorChatBlockedCall("summer_check_job", {})).toBe("jobs.get"); - expect(editorChatBlockedCall("summer_assets_generate_3d", {})).toBe("assets.generate_3d"); - expect(editorChatBlockedCall("summer_generate_3d", {})).toBe("assets.generate_3d"); - expect(editorChatBlockedCall("summer_tool_bankai_motion", {})).toBe("makers.bankai_motion"); - expect(editorChatBlockedCall("summer_call", { op: "jobs.wait" })).toBe("jobs.wait"); - expect(editorChatBlockedCall("summer_call", { name: "summer_jobs_wait" })).toBe("jobs.wait"); - expect(editorChatBlockedCall("summer_call", { op: "summer_check_job" })).toBe("jobs.get"); - // The board's 3.x status tools were merged into jobs.get; they are not job waits. + 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(); - for (const [tool, args] of [["summer_search", { query: "wait for a job" }], ["summer_call", {}], ["summer_call", { op: "games.list" }], ["summer_add_node", {}], ["summer_get_asset", {}]] as const) { - expect(editorChatBlockedCall(tool, args)).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", () => { diff --git a/src/core/editor-chat.ts b/src/core/editor-chat.ts index ec90f116..97ca100c 100644 --- a/src/core/editor-chat.ts +++ b/src/core/editor-chat.ts @@ -1,87 +1,85 @@ -import { errorAnswer, legacyNames, opForToolCall, resolveOpName, type Answer, type Effect } from "./ops/contract.js"; -import { publicCatalog } from "./ops/public-catalog.js"; - -/** - * The Summer editor's own chat (SUMMER_EDITOR_CHAT=1, set by the editor in - * this MCP server's environment and in its agent's shell). There, assets are - * generated only with the chat's summer_cloud tools: they return at once and - * the finished job arrives as a message in the conversation. So in that chat - * this package never waits on, polls or checks a job, and never starts a - * generation itself: the MCP instructions say so, and summer_call, the - * direct tools, summer_search and the CLI refuse or hide those operations. - * Without the variable nothing changes. - */ +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"; } -/** The sentence the editor chat's MCP instructions carry in place of the job sentence. */ export const EDITOR_CHAT_JOB_SENTENCE = - "In the Summer editor chat, generate assets only with the chat's summer_cloud tools: they return at once, and a message in this conversation tells you when each job finishes. Never wait for, poll or check on a job."; + "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."; -/** The job sentences of the instruction strings outside the editor chat (server.ts, ops-surface.ts). */ const JOB_SENTENCES = ["Long work answers with a job: jobs.wait.", "A job returns a jobId: summer_check_job."]; -/** Instructions for the editor chat: the job sentence replaced, or the editor sentence added when there is none. */ 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 = - "This does not run in the Summer editor chat: generate assets only with the chat's summer_cloud tools. The finished job arrives as a message in this conversation; never wait for, poll or check on a job."; + "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."; -/** The answer a refused operation gets, at once and with no call to Summer's server. */ export function editorChatRefusal(op: string): Answer { return errorAnswer("unsupported", EDITOR_CHAT_REFUSAL, { - next: { say: "Use the chat's summer_cloud tools; the result arrives as a message in this conversation." }, + next: { say: "Use the chat's summer_cloud tools; a native asset job's result arrives in this conversation." }, details: { op, reason: "editor_chat" }, }); } -const JOB_OPS: ReadonlySet = new Set(["jobs.wait", "jobs.get"]); -/** Paid, but makes no asset and answers at once (a better prompt). */ -const NOT_GENERATION: ReadonlySet = new Set(["assets.improve_prompt"]); - -let effects: Map | null = null; -const effectOf = (op: string): Effect | undefined => { - effects ??= new Map(publicCatalog().operations.map((row) => [row.name, row.effect])); - return effects.get(op); -}; - /** - * True for the operations the editor chat refuses: waiting on or checking a - * job (jobs.wait, jobs.get) and generation: every assets.generate_*, and the - * other paid assets and makers operations (edit, rig, retexture, remove a - * background, the makers' jobs). `effect` is the live catalog row's, for an - * operation newer than the shipped catalog. + * 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. */ -export function isEditorChatBlockedOp(op: string, effect?: Effect): boolean { - if (JOB_OPS.has(op) || op.startsWith("assets.generate_")) return true; - if (NOT_GENERATION.has(op) || !/^(assets|makers)\./.test(op)) return false; - return (effect ?? effectOf(op)) === "spend"; +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 refused operation a tool call would run, or null: summer_call's op (or - * its `name`), or a direct tool's (summer_jobs_wait, summer_check_job, - * summer_assets_generate_3d, summer_generate_image, summer_tool_*). A 3.x - * tool merged into jobs.get (the board's task and build status) is not a job - * wait and stays. + * 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() : ""; - // An operation name, or a tool name (3.x or v4) passed as the op. - return name.includes(".") ? blocked(resolveOpName(name)) : name ? editorChatBlockedCall(name, call.input) : null; + 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)); + return blocked(opForToolCall(tool, args), args); } - -const blocked = (op: string | null): string | null => (op && isEditorChatBlockedOp(op) ? op : null); diff --git a/src/core/ops/cloud.ts b/src/core/ops/cloud.ts index a191c1a6..df334f01 100644 --- a/src/core/ops/cloud.ts +++ b/src/core/ops/cloud.ts @@ -207,9 +207,9 @@ 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 } : {}) }); } - // The Summer editor chat never waits on a job or generates here (core/editor-chat.ts): those operations are not found. + // Hide only native generation equivalents; job tracking stays discoverable. const editorChat = isEditorChat(); - const candidates = [...localEntries(), ...cloud.values()].filter((entry) => !editorChat || !isEditorChatBlockedOp(entry.name, entry.effect)); + 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 } diff --git a/src/mcp/server.editor-chat.test.ts b/src/mcp/server.editor-chat.test.ts index ac1111a9..acb40580 100644 --- a/src/mcp/server.editor-chat.test.ts +++ b/src/mcp/server.editor-chat.test.ts @@ -27,13 +27,13 @@ afterEach(async () => { 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 would start jobs and answer waits; the editor chat must never reach it for those. */ +/** 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: "gen:5037aff5", state: "queued" } }); + return envelope({ ok: true, status: "running", job: { id: "aj_native-job", state: "queued" } }); }), }; } @@ -61,17 +61,16 @@ function expectRefusal(result: unknown, op: string) { }); } -describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) never waits on, checks or starts a generation job", () => { +describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) redirects native asset operations and polling", () => { it.each([ - ["jobs.wait", { jobId: "gen:5037aff5", timeoutSeconds: 50 }, "jobs.wait"], - ["jobs.get", { jobId: "gen:5037aff5" }, "jobs.get"], - ["summer_check_job", { jobId: "gen:5037aff5" }, "jobs.get"], - ["summer_jobs_wait", { jobId: "gen:5037aff5" }, "jobs.wait"], + ["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"], - ["makers.bankai_motion", {}, "makers.bankai_motion"], ])("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"); @@ -85,20 +84,21 @@ describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) never waits on, checks o } }); - it("refuses the hosted direct tools and leaves them out of tools/list", async () => { + 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_jobs_wait", "summer_assets_generate_3d", "summer_tool_bankai_motion"]) expect(names).not.toContain(hidden); - expect(names).toEqual(expect.arrayContaining(["summer_call", "summer_search", "summer_games_list"])); - expectRefusal(await client.callTool({ name: "summer_jobs_wait", arguments: { jobId: "gen:1" } }), "jobs.wait"); + 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"); - expectRefusal(await client.callTool({ name: "summer_tool_bankai_motion", arguments: {} }), "makers.bankai_motion"); expect(hostedHandler).not.toHaveBeenCalled(); // Everything else still runs. await client.callTool({ name: "summer_games_list", arguments: {} }); - expect(hostedHandler).toHaveBeenCalledTimes(1); + 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(); } @@ -110,8 +110,9 @@ describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) never waits on, checks o 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", "summer_check_job"]) expect(names).not.toContain(hidden); - expectRefusal(await client.callTool({ name: "summer_check_job", arguments: { jobId: "gen:1" } }), "jobs.get"); + 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(); @@ -119,7 +120,7 @@ describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) never waits on, checks o } }); - it("summer_search and ops.search do not find those operations", async () => { + 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 { @@ -128,7 +129,7 @@ describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) never waits on, checks o 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) => /^jobs\.(wait|get)$|^assets\.generate_|^makers\./.test(name))).toEqual([]); + expect(found.filter((name) => /^assets\.generate_(3d|image|audio|motion)$/.test(name))).toEqual([]); } } } finally { @@ -136,6 +137,32 @@ describe("the Summer editor chat (SUMMER_EDITOR_CHAT=1) never waits on, checks o } }); + 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 { diff --git a/src/mcp/server.instructions.test.ts b/src/mcp/server.instructions.test.ts index 8f05aeb6..94fcd32e 100644 --- a/src/mcp/server.instructions.test.ts +++ b/src/mcp/server.instructions.test.ts @@ -142,10 +142,12 @@ describe("MCP server instructions in the Summer editor chat (SUMMER_EDITOR_CHAT= ["v3 assets", SUMMER_MCP_ASSETS_INSTRUCTIONS], ] as const; - it.each(variants)("%s: the job sentence becomes the editor chat sentence, and no job tool is named", (_label, text) => { + 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); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 84d4497c..a148a5c6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -505,7 +505,7 @@ export function createMcpServer( }, { instructions: - // The Summer editor chat generates with its own tools and never waits on a job (core/editor-chat.ts). + // Native asset jobs deliver to editor chat; other jobs retain normal tracking. (editorChat ? editorChatInstructions(instructions) : instructions) + (options.notice ? ` ${options.notice} Tell the person once.` : ""), } @@ -574,10 +574,11 @@ export function createMcpServer( /** * The Summer editor chat (SUMMER_EDITOR_CHAT=1): no tool call waits on, - * checks or starts a generation job. tools/call answers those at once with + * 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 - * direct tools out. + * 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;