From 1955ed2b51b0d778aa7bd0eb5e50925251d5acec Mon Sep 17 00:00:00 2001 From: Jeany Date: Sat, 17 Oct 2026 11:35:15 -0600 Subject: [PATCH] 1.39.0: the assistant keeps the map state the server added The server adds the map's current facts to the end of the last user message. The panel's history never had them, so the next request sent that message without them, and Opus 5.5, which checks that a replayed thinking block's conversation is unchanged, dropped every thinking block after it: each round of a turn ran without the reasoning of the rounds before. With a server from ai-engine 0.2.0 the answer carries the block back and the history keeps it at the end of the message; replays and the copied transcript leave it out. Co-Authored-By: Claude Opus 5.5 --- ai/assistant.ts | 26 +++++++++++++++++++++++--- dist/plugin.js | 12 ++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- plugin.json | 2 +- protocol.ts | 8 ++++++++ tests/reference.test.ts | 31 +++++++++++++++++++++++++++++++ 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/ai/assistant.ts b/ai/assistant.ts index 6ae89b3..c426a85 100644 --- a/ai/assistant.ts +++ b/ai/assistant.ts @@ -106,6 +106,22 @@ function countImages(messages: AgentMessage[]): number { return n; } +/** + * The history with the server's map-state block kept at the end of the message it was + * added to — the last one, the message just sent — so the next request sends that message + * as the model saw it. A message that differs from the one a thinking block was made after + * costs the model that block and every later one (the 5.5 models check), and before this + * the block was dropped from every message, which lost the reasoning of every round. + */ +export function keepState(messages: AgentMessage[], state: string | undefined): AgentMessage[] { + const last = messages[messages.length - 1]; + if (!state || last?.role !== "user") return messages; + return [...messages.slice(0, -1), { ...last, content: [...last.content, { type: "text", text: state }] }]; +} + +/** A kept map-state block: the server's facts, never the person's words. */ +export const isMapState = (c: AgentContent): boolean => c.type === "text" && c.text.startsWith(""); + /** * The history after a turn failed: a user message the model never answered goes, unless * it holds tool results — those record edits that were made, and the next request @@ -241,7 +257,7 @@ export function groupTurns(messages: AgentMessage[]): ReplayTurn[] { let cur: ReplayTurn | null = null; for (const m of messages) { if (m.role === "user" && !m.content.some((c) => c.type === "tool_result")) { - cur = { user: m.content.filter(isText).map((c) => c.text), steps: [], answer: null }; + cur = { user: m.content.filter(isWords).map((c) => c.text), steps: [], answer: null }; turns.push(cur); continue; } @@ -273,6 +289,8 @@ const PHASE_LABELS: Record = { idle: msg("Ready"), waiting: msg(" /** Whether a message content item is a turn's own text (not tool traffic). */ const isText = (c: AgentContent): c is Extract => c.type === "text"; +/** Text a person or the model wrote, not a map-state block kept with a message. */ +const isWords = (c: AgentContent): c is Extract => isText(c) && !isMapState(c); /** The teal outline of what the assistant is about to touch, drawn by an overlay while a tool runs. */ function intentOverlay(api: PluginApi): { handle: OverlayHandle; show(f: Footprint | null): void } { @@ -528,7 +546,7 @@ export function openAssistant(ctx: Ctx, store: Conversations): AssistantHandle { }); root.addEventListener("keydown", (e) => { if (e.key === "Escape" && running && e.target !== input) { e.preventDefault(); running.abort(); } }); - const transcript = () => state.messages.map((m) => m.content.filter(isText).map((c) => (m.role === "user" ? t("You: {text}", { text: c.text }) : t("Assistant: {text}", { text: c.text }))).join("\n")).filter(Boolean).join("\n\n"); + const transcript = () => state.messages.map((m) => m.content.filter(isWords).map((c) => (m.role === "user" ? t("You: {text}", { text: c.text }) : t("Assistant: {text}", { text: c.text }))).join("\n")).filter(Boolean).join("\n\n"); // Replay what the conversation holds, a block per turn. const replay = () => { @@ -720,8 +738,10 @@ export function openAssistant(ctx: Ctx, store: Conversations): AssistantHandle { turnCost += charged; setCost(); // Kept exactly as returned — thinking blocks included — and sent back unchanged next - // turn, since the model refuses to continue a tool-using turn without them. + // turn, since the model refuses to continue a tool-using turn without them; and the + // message it answered keeps the map state the server added to it, for the same reason. const answer = r.output.content; + conv.messages = keepState(conv.messages, r.output.state); conv.messages.push({ role: "assistant", content: answer }); const finalText = answer.filter(isText).map((c) => c.text).join("\n\n").trim(); const calls = answer.filter((c): c is Extract => c.type === "tool_use"); diff --git a/dist/plugin.js b/dist/plugin.js index fdb7ebc..ccca85e 100644 --- a/dist/plugin.js +++ b/dist/plugin.js @@ -9279,6 +9279,12 @@ function countImages(messages) { } return n2; } +function keepState(messages, state) { + const last = messages[messages.length - 1]; + if (!state || last?.role !== "user") return messages; + return [...messages.slice(0, -1), { ...last, content: [...last.content, { type: "text", text: state }] }]; +} +var isMapState = (c2) => c2.type === "text" && c2.text.startsWith(""); function afterFailedTurn(messages) { const last = messages[messages.length - 1]; return last?.role === "user" && !last.content.some((c2) => c2.type === "tool_result") ? messages.slice(0, -1) : messages; @@ -9389,7 +9395,7 @@ function groupTurns(messages) { let cur = null; for (const m of messages) { if (m.role === "user" && !m.content.some((c2) => c2.type === "tool_result")) { - cur = { user: m.content.filter(isText).map((c2) => c2.text), steps: [], answer: null }; + cur = { user: m.content.filter(isWords).map((c2) => c2.text), steps: [], answer: null }; turns.push(cur); continue; } @@ -9427,6 +9433,7 @@ ${c2.text}` : c2.text; } var PHASE_LABELS = { idle: msg("Ready"), waiting: msg("Waiting for the model"), thinking: msg("Thinking"), writing: msg("Writing"), tools: msg("Working on the map"), stopped: msg("Stopped"), failed: msg("Failed") }; var isText = (c2) => c2.type === "text"; +var isWords = (c2) => isText(c2) && !isMapState(c2); function intentOverlay(api) { let footprint = null; const handle = api.ui.overlay({ @@ -9751,7 +9758,7 @@ function openAssistant(ctx, store) { running.abort(); } }); - const transcript = () => state.messages.map((m) => m.content.filter(isText).map((c2) => m.role === "user" ? t("You: {text}", { text: c2.text }) : t("Assistant: {text}", { text: c2.text })).join("\n")).filter(Boolean).join("\n\n"); + const transcript = () => state.messages.map((m) => m.content.filter(isWords).map((c2) => m.role === "user" ? t("You: {text}", { text: c2.text }) : t("Assistant: {text}", { text: c2.text })).join("\n")).filter(Boolean).join("\n\n"); const replay = () => { chat.replaceChildren(); pinned = true; @@ -9978,6 +9985,7 @@ function openAssistant(ctx, store) { turnCost += charged; setCost(); const answer = r.output.content; + conv.messages = keepState(conv.messages, r.output.state); conv.messages.push({ role: "assistant", content: answer }); const finalText = answer.filter(isText).map((c2) => c2.text).join("\n\n").trim(); const calls = answer.filter((c2) => c2.type === "tool_use"); diff --git a/package-lock.json b/package-lock.json index 05d45e3..724090f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.38.0", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.38.0", + "version": "1.39.0", "license": "MIT", "devDependencies": { "@scm-js/plugin-api": "^1.35.0", diff --git a/package.json b/package.json index 9418189..d51899e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.38.0", + "version": "1.39.0", "private": true, "description": "scmjs.dev — a plugin for the scmJS StarCraft map editor: your account, map storage with revisions, and the AI features (scenarios, maps, triggers, an assistant) on one free trial and one sign-in.", "author": "Jeany", diff --git a/plugin.json b/plugin.json index 8c02dc5..432e9e0 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "scmjs.dev", "id": "scmjs-dev", - "version": "1.38.0", + "version": "1.39.0", "description": "Your scmjs.dev account in the editor. Keep maps on your account with revisions and notes, and share the open map so others can edit it with you at the same time. Where the server offers them, AI tools can build, lay out and review a map with you.", "author": "Jeany", "homepage": "https://github.com/scm-js/plugin-scmjs-dev", diff --git a/protocol.ts b/protocol.ts index b253420..b631744 100644 --- a/protocol.ts +++ b/protocol.ts @@ -1543,5 +1543,13 @@ export interface AgentInput { export interface AgentOutput { content: AgentContent[]; stopReason: "end_turn" | "tool_use" | "max_tokens" | "refusal"; + /** + * The map-state block the server added at the end of the last user message, exactly as + * the model saw it (servers from ai-engine 0.2.0). Keep it in the history, as a text block + * at the end of that message, so the next request sends the message unchanged: a model + * that checks replayed thinking (the 5.5 generation) drops every thinking block after a + * message that changed. + */ + state?: string; } diff --git a/tests/reference.test.ts b/tests/reference.test.ts index 21b1a2e..89a86a5 100644 --- a/tests/reference.test.ts +++ b/tests/reference.test.ts @@ -302,4 +302,35 @@ describe("replaying a transcript", () => { expect(calls.map((c) => [c.name, c.result, !!c.failed, !!c.image])).toEqual([["map_info", "{\"width\":64}", false, false], ["screenshot", "Tiles.", false, true], ["nope", "Error: no", true, false]]); expect(turns[1]).toEqual({ user: ["Thanks"], steps: [], answer: "Sure." }); }); + + it("leaves the kept map-state blocks out of what the person asked", async () => { + const { groupTurns } = await import("../ai/assistant"); + const turns = groupTurns([ + { role: "user", content: [{ type: "text", text: "Hi" }, { type: "text", text: "\nUnits on the map:\n" }] }, + { role: "assistant", content: [{ type: "text", text: "Hello." }] }, + ]); + expect(turns).toEqual([{ user: ["Hi"], steps: [], answer: "Hello." }]); + }); +}); + +describe("keeping the map state", () => { + const state = "\nView: tiles 0,0\n"; + it("puts the server's block at the end of the message it was added to, without touching the one before", async () => { + const { keepState } = await import("../ai/assistant"); + const sent: AgentMessage[] = [ + { role: "user", content: [{ type: "text", text: "add marines" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "place_units", input: {} }] }, + { role: "user", content: [{ type: "tool_result", toolUseId: "t1", content: "placed" }] }, + ]; + const kept = keepState(sent, state); + expect(kept[2]).toEqual({ role: "user", content: [{ type: "tool_result", toolUseId: "t1", content: "placed" }, { type: "text", text: state }] }); + expect(kept.slice(0, 2)).toEqual(sent.slice(0, 2)); + expect(sent[2].content).toHaveLength(1); + }); + + it("changes nothing for a server that sends no state", async () => { + const { keepState } = await import("../ai/assistant"); + const sent: AgentMessage[] = [{ role: "user", content: [{ type: "text", text: "hi" }] }]; + expect(keepState(sent, undefined)).toBe(sent); + }); });