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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <area> <action>` 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 `<gateway>/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 <area> <action>` 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 `<gateway>/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).

---

Expand Down
169 changes: 169 additions & 0 deletions src/cli/commands/ops.editor-chat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EDITOR_CHAT_REFUSAL } from "../../core/editor-chat.js";
import { formatAnswer, runOpFromCli, searchFromCli, type OpsCliDeps } from "./ops.js";
import { hostedCli, toolCommand } from "./tool.js";

const envelope = (value: unknown) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });

function deps(): OpsCliDeps & { out: string[]; err: string[]; hosted: ReturnType<typeof vi.fn> } {
const out: string[] = [];
const err: string[] = [];
const hosted = vi.fn(async (_name: string, args: Record<string, unknown>) =>
args.op === "jobs.wait"
? envelope({ ok: true, status: "done", result: { job: { id: "gen:1", state: "done", result: { assetId: "a1" } }, finished: true } })
: envelope({ ok: true, status: "running", job: { id: "gen:1", state: "queued" } })
);
return { hosted, dispatch: vi.fn(async () => ({ ok: true })), print: (text) => out.push(text), printError: (text) => err.push(text), out, err };
}

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

describe("summer in the Summer editor chat (SUMMER_EDITOR_CHAT=1)", () => {
it.each([
["jobs.wait", ["--job-id", "aj_native-job"]],
["jobs.get", ["--job-id", "aj_native-job"]],
["assets.generate_3d", ["--prompt", "a chest"]],
["assets.generate_image", ["--prompt", "a chest", "--json"]],
["jobs.wait", ["--input", '{"jobId":"gen:aj_native-job"}', "--json"]],
["jobs.get", ["--job-id=aj_native-job"]],
["jobs.wait", ["--input", '{"jobId":"build:1"}', "--job-id", "aj_native-job"]],
])("%s %j exits 1 at once with the refusal and never calls Summer's server", async (op, argv) => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
expect(await runOpFromCli(op, argv, d)).toBe(1);
expect(d.hosted).not.toHaveBeenCalled();
expect(d.dispatch).not.toHaveBeenCalled();
expect(d.out).toEqual([]);
expect(d.err.join("\n")).toContain(EDITOR_CHAT_REFUSAL);
if (argv.includes("--json")) expect(JSON.parse(d.err[0])).toMatchObject({ ok: false, error: { code: "unsupported", details: { op, reason: "editor_chat" } } });
});

it("retains wait instructions for ordinary jobs and omits them only for native assets", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
expect(await runOpFromCli("games.list", [], d)).toBe(0);
expect(d.hosted).toHaveBeenCalledTimes(1);
expect(d.out[0]).toContain("summer jobs wait --job-id gen:1");
expect(formatAnswer({ ok: true, status: "running", job: { id: "aj_native-job", state: "queued" } })).not.toMatch(/jobs wait/);
});

it.each(["build:1", "upload:1", "apr:1", "gen:existing-cli-job", "legacy-generation-id"])("keeps explicit reads and waits for %s", async (jobId) => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
for (const op of ["jobs.get", "jobs.wait"]) {
const d = deps();
expect(await runOpFromCli(op, ["--input", JSON.stringify({ jobId }), "--json"], d)).toBe(0);
expect(d.hosted).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op, input: { jobId } }));
}
});

it("honors a job-id flag overriding --input before deciding whether to block", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
expect(await runOpFromCli("jobs.wait", ["--input", '{"jobId":"aj_native-job"}', "--job-id", "build:1"], d)).toBe(0);
expect(d.hosted).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait", input: { jobId: "build:1" } }));
});

it.each([
["projects.build", "build:1"],
["store.publish", "apr:1"],
["makers.bankai_motion", "gen:maker-job"],
["assets.generate_video", "gen:video-job"],
])("follows %s to completion with --wait", async (op, jobId) => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
const approval = { id: "a1", who: "owner" as const, summary: "Publish game", url: "https://example.com/approval" };
const started = op === "store.publish"
? { ok: true as const, status: "needs_approval" as const, approval, job: { id: jobId, state: "awaiting_approval" as const } }
: { ok: true as const, status: "running" as const, job: { id: jobId, state: "queued" as const } };
d.hosted.mockImplementation(async (_name, args) => envelope(args.op === "jobs.wait"
? { ok: true, status: "done", result: { job: { id: jobId, state: "done", result: { completed: true } }, finished: true } }
: started));

expect(formatAnswer(started)).toContain(`summer jobs wait --job-id ${jobId}`);
expect(await runOpFromCli(op, ["--input", "{}", "--wait", "--json"], d)).toBe(0);
expect(JSON.parse(d.out.at(-1)!)).toMatchObject({ ok: true, status: "done", result: { completed: true } });
expect(d.hosted.mock.calls.map(([, args]) => args.op)).toEqual([op, "jobs.wait"]);
expect(d.hosted).toHaveBeenLastCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait", input: { jobId, timeoutSeconds: 50 } }));
});

it("stops automatic waiting when an approval hands off to a native asset delivery", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
d.hosted.mockImplementation(async (_name, args) => envelope(args.op === "jobs.wait"
? { ok: true, status: "done", result: { job: { id: "apr:1", state: "done", result: { status: "running", job: { id: "aj_native-job", state: "queued" } } }, finished: true } }
: { ok: true, status: "needs_approval", approval: { id: "a1", who: "owner", summary: "Approve", url: "https://example.com/approval" }, job: { id: "apr:1", state: "awaiting_approval" } }));
expect(await runOpFromCli("makers.bankai_motion", ["--input", "{}", "--wait"], d)).toBe(0);
expect(d.out.at(-1)).toContain("The editor will deliver its result");
expect(d.out.at(-1)).not.toContain("summer jobs wait");
expect(d.hosted.mock.calls.filter(([, args]) => args.op === "jobs.wait")).toHaveLength(1);
});

it("returns a native job immediately even if an allowed operation was called with --wait", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
d.hosted.mockResolvedValue(envelope({ ok: true, status: "running", job: { id: "aj_native-job", state: "queued" } }));
expect(await runOpFromCli("makers.bankai_motion", ["--input", "{}", "--wait", "--json"], d)).toBe(0);
expect(d.hosted).toHaveBeenCalledTimes(1);
expect(JSON.parse(d.out[0])).toMatchObject({ ok: true, status: "running", job: { id: "aj_native-job" } });
});

it("ops search does not list those operations", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const d = deps();
d.hosted.mockImplementation(async () => envelope({ ok: true, status: "done", result: { items: [] } }));
for (const query of ["wait for a job", "generate a 3D model"]) {
await searchFromCli(query, { limit: 20, json: true }, d);
const names = (JSON.parse(d.out.pop() ?? "{}").result.items as Array<{ name: string }>).map((item) => item.name);
expect(names.filter((name) => /^assets\.generate_(3d|image|audio|motion)$/.test(name))).toEqual([]);
}
for (const op of ["jobs.wait", "jobs.get", "makers.bankai_motion", "assets.generate_video"]) {
await searchFromCli(op, { limit: 20, json: true }, d);
const names = JSON.parse(d.out.pop()!).result.items.map((item: { name: string }) => item.name);
expect(names).toContain(op);
}
});

it("summer tool refuses the job and generation tools, local and hosted, without running them", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const call = vi.spyOn(hostedCli, "call");
for (const [name, op] of [["summer_check_job", "jobs.get"], ["summer_generate_3d", "assets.generate_3d"], ["summer_jobs_wait", "jobs.wait"]]) {
const lines: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => void lines.push(String(line)));
const previous = process.exitCode;
process.exitCode = undefined;
try {
await toolCommand.parseAsync([name, "--args", '{"jobId":"aj_native-job","prompt":"a chest"}'], { from: "user" });
expect(process.exitCode).toBe(1);
} finally {
process.exitCode = previous;
log.mockRestore();
}
expect(JSON.parse(lines.join("\n"))).toMatchObject({ ok: false, error: { code: "unsupported", message: EDITOR_CHAT_REFUSAL, details: { op } } });
}
expect(call).not.toHaveBeenCalled();
});

it("summer tool forwards build and approval waits and maker operations", async () => {
vi.stubEnv("SUMMER_EDITOR_CHAT", "1");
const call = vi.spyOn(hostedCli, "call").mockImplementation(async (name) => ({
status: "called", tool: name, result: envelope({ ok: true, status: "done", result: {} }),
}));
vi.spyOn(console, "log").mockImplementation(() => {});
for (const [name, args] of [["summer_jobs_wait", { jobId: "build:1" }], ["summer_jobs_wait", { jobId: "apr:1" }], ["summer_tool_bankai_motion", {}]] as const) {
await toolCommand.parseAsync([name, "--args", JSON.stringify(args)], { from: "user" });
expect(call).toHaveBeenLastCalledWith(name, args);
}
});
});

describe("summer outside the editor chat", () => {
it("waits on a generation job with --wait, as before", async () => {
const d = deps();
expect(await runOpFromCli("assets.generate_3d", ["--input", '{"prompt":"a chest"}', "--wait", "--json"], d)).toBe(0);
expect(d.hosted).toHaveBeenCalledWith("summer_call", expect.objectContaining({ op: "jobs.wait" }));
expect(formatAnswer({ ok: true, status: "running", job: { id: "gen:2", state: "queued" } })).toContain("summer jobs wait --job-id gen:2");
});
});
Loading
Loading