diff --git a/apps/desktop/src/components/chat/tool-meta.ts b/apps/desktop/src/components/chat/tool-meta.ts index 68d53590..d94a1f97 100644 --- a/apps/desktop/src/components/chat/tool-meta.ts +++ b/apps/desktop/src/components/chat/tool-meta.ts @@ -20,6 +20,7 @@ import { FileSearch, FileText, FileUp, + FlagOff, FolderKanban, FolderSearch, Globe, @@ -416,6 +417,7 @@ const GODMODE_TOOLS = new Set([ "notify_user", "followup_schedule", "followup_cancel", + "goal_clear", "ask_human", "request_approval", "tasks_list", @@ -571,6 +573,8 @@ function godmodeMeta(tool: string, input: Input, ctx: ToolContext): Omit (cancelFollowup(ctx.conversationId) ? "Follow-up removed." : "This chat had no follow-up."), }), + defineTool({ + name: "goal_clear", + description: + "End this chat's `/goal` yourself, as the human would with `/goal clear`. Only when the goal is over without being met: the human told you to end, drop or replace it, or a decision of theirs leaves nothing to work towards. Never to get out of work that is merely hard, long or unfinished. The goal's check no longer holds this turn up: Godmode clears the goal when the turn is over and notes it in the chat with your reason. Never hand the human a task to type `/goal clear`.", + schema: z.object({ + reason: z.string().min(1).max(300).describe('Why it ends, shown in the chat, e.g. "Daniel decided on 10-28 to end it"'), + }), + when: canFollowUp, + run: ({ reason }, { ctx }) => { + const goal = chatGoal(ctx.conversationId, runClaudeConfigDir(ctx.runId)); + if (!goal) return "This chat has no active /goal — nothing to end."; + const refused = endGoalAfterTurn(ctx.runId, reason); + if (refused) return fail(refused); + return `The goal “${goal.length > 200 ? `${goal.slice(0, 199).trimEnd()}…` : goal}” ends when this turn is over. Finish your answer now and don't work on it any further.`; + }, + }), + defineTool({ name: "artifact_create", description: `Build an artifact: a small web app the human opens right in Godmode — a sales dashboard, a KPI tracker, a deal board, a calculator, a planner. Use it whenever they ask for a dashboard, a tracker, a tool or a page to look at or work with (not for a one-off answer or a file they asked for). It shows next to this chat and on the Artifacts page; they change it by chatting with you, and every code change is a version they can go back to. diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts index 32860de8..c7a0a94b 100644 --- a/packages/core/src/runner/prompt.ts +++ b/packages/core/src/runner/prompt.ts @@ -406,7 +406,7 @@ When you can't go on because ${human} has to do something themselves — solve a if (ctx.followups) { out.push(`### Following up later -When a task can't be finished now because you have to wait — for a reply to an email or message, a delivery, a build or deployment, a status or price change, office hours, another person — don't leave it to ${human} to remind you. Schedule a follow-up with \`followup_schedule({ at | inMinutes, note })\`, like a coworker who says "I'll check back tomorrow at 10": at that time Godmode continues this chat on its own and you pick up where you left off, with the whole conversation. Pick a realistic time (when the answer is likely there; business hours when people are involved) and write the note so you know exactly what to check and do. Then end your turn with a short summary of what you're waiting for and when you'll continue. The same holds for a \`/goal\` whose remaining parts wait on such outside events: schedule the follow-up and end the turn with a short status; Godmode pauses the goal loop until then. Don't wait inside the turn instead: no \`sleep\`, \`until\` or polling loop that runs longer than about ten minutes, in the foreground or as a background task — a run that shows no sign of life that long is stopped. A chat has one follow-up: scheduling again moves it, \`followup_cancel\` removes it. Don't schedule follow-ups for work you can do now or for things that repeat on a schedule${perms.canManageAgents ? " (those are automations)" : ""}.`); +When a task can't be finished now because you have to wait — for a reply to an email or message, a delivery, a build or deployment, a status or price change, office hours, another person — don't leave it to ${human} to remind you. Schedule a follow-up with \`followup_schedule({ at | inMinutes, note })\`, like a coworker who says "I'll check back tomorrow at 10": at that time Godmode continues this chat on its own and you pick up where you left off, with the whole conversation. Pick a realistic time (when the answer is likely there; business hours when people are involved) and write the note so you know exactly what to check and do. Then end your turn with a short summary of what you're waiting for and when you'll continue. The same holds for a \`/goal\` whose remaining parts wait on such outside events: schedule the follow-up and end the turn with a short status; Godmode pauses the goal loop until then. When ${human} ends, drops or replaces a \`/goal\`, end it yourself with \`goal_clear\`. Don't wait inside the turn instead: no \`sleep\`, \`until\` or polling loop that runs longer than about ten minutes, in the foreground or as a background task — a run that shows no sign of life that long is stopped. A chat has one follow-up: scheduling again moves it, \`followup_cancel\` removes it. Don't schedule follow-ups for work you can do now or for things that repeat on a schedule${perms.canManageAgents ? " (those are automations)" : ""}.`); } if (ctx.calls) { diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index ea192f44..129e485e 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -405,6 +405,10 @@ interface Job { private?: boolean; /** Godmode ended it because a `/goal` kept it going with nothing to do: it succeeded. */ goalPaused?: boolean; + /** The agent ended the chat's `/goal` (`goal_clear`) when the hook had blocked this many times: Godmode clears it once the process is done. */ + goalClear?: { reason: string; atCheck: number }; + /** Where its Claude Code keeps sessions (GLM runs have a config folder of their own); null = the default. */ + claudeConfigDir?: string | null; /** Resolves ids in tool input for the activity label (memoized per run). */ names?: ActivityNames; /** The human started or let it run: a used-up monthly budget doesn't hold it. */ @@ -1803,6 +1807,12 @@ async function spawnClaude( if (job.acc.push(event)) scheduleDelta(job); showContext(job); if (job.acc.goalIdle >= GOAL_IDLE_LIMIT && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) pauseGoal(job, proc); + // The agent ended the goal: the hook's next block is only the goal it no longer pursues. + if (job.goalClear && job.acc.goalChecks > job.goalClear.atCheck && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) { + job.goalPaused = true; + log.info(`run ${job.runId}: the agent ended the goal, ending the run`); + killTree(proc); + } // Claude has read the human's answer the moment it starts replying: settle it now, so a crash later in the run // can't hand the same answer (an approval: "Do it now") to the next turn again. if (job.acc.answered && !job.answersSettled) { @@ -1841,6 +1851,72 @@ function pauseGoal(job: Job, proc: Subprocess) { killTree(proc); } +/** + * `goal_clear`: the agent ends the chat's `/goal` (the human dropped it, replaced it, or it can no longer be reached). + * The goal's check doesn't hold this turn up anymore, and Godmode clears the goal in the session once the process is done. + */ +export function endGoalAfterTurn(runId: string, reason: string): string | null { + const job = jobs.get(runId); + if (!job || job.status !== "running" || job.cancelReason || job.timedOut || job.stalled) return "This run is being stopped — the goal stays. Try again in the next turn."; + job.goalClear = { reason, atCheck: job.acc.goalChecks }; + return null; +} + +/** The Claude Code config folder the run's sessions live in, or null for the default (or no such run). */ +export function runClaudeConfigDir(runId: string): string | null { + return jobs.get(runId)?.claudeConfigDir ?? null; +} + +const GOAL_CLEAR_TIMEOUT_MS = 60_000; + +/** Clear the session's `/goal` as the human would with `/goal clear`: Claude Code runs it without the model. */ +async function clearGoal(job: Job, cmd: string[], sessionId: string, cwd: string, env: Record): Promise { + const reason = job.goalClear?.reason ?? ""; + let said = ""; + let timer: ReturnType | undefined; + try { + const proc = Bun.spawn({ + cmd: [...cmd, "-p", "--resume", sessionId, "--output-format", "json", "--setting-sources", "", "--strict-mcp-config"], + cwd, + env, + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + detached: process.platform !== "win32", + }); + job.proc = proc; + timer = setTimeout(() => killTree(proc), GOAL_CLEAR_TIMEOUT_MS); + proc.stdin.write("/goal clear"); + await proc.stdin.end(); + const [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + for (const line of out.trim().split("\n").reverse()) { + try { + const event = JSON.parse(line) as { type?: unknown; result?: unknown }; + if (event.type === "result" && typeof event.result === "string") { + said = event.result; + break; + } + } catch { + /* not a line of its own */ + } + } + } catch (err) { + log.warn(`run ${job.runId}: could not clear the goal of session ${sessionId}`, err); + } finally { + clearTimeout(timer); + job.proc = null; + } + const cleared = /^Goal cleared: ([\s\S]+)/.exec(said.trim()); + if (cleared) { + const goal = cleared[1]!.replace(/\s+/g, " ").trim(); + job.acc.addNotice("info", `Goal ended: “${goal.length > 160 ? `${goal.slice(0, 159).trimEnd()}…` : goal}”${reason ? ` — ${reason.replace(/[.!]$/, "")}` : ""}.`); + log.info(`run ${job.runId}: goal of session ${sessionId} cleared`); + } else if (!/^No goal set/.test(said.trim())) { + job.acc.addNotice("warning", "Godmode couldn't end this chat's goal. Type /goal clear to end it."); + } + scheduleDelta(job); +} + /** The model a run is recorded with: what Claude reported, but for GLM the requested id (Z.ai may name it otherwise). */ function recordedModel(job: Job, acc: { model: string | null }): string | null { return job.model && modelProvider(job.model) === "zai" ? job.model : (acc.model ?? job.model ?? null); @@ -2345,6 +2421,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise t.status === "running"); + this.goalChecks++; if (this.goalFeedback !== null) this.goalIdle = this.toolCalls > this.toolCallsAtGoalCheck || busy ? 0 : this.goalIdle + 1; this.toolCallsAtGoalCheck = this.toolCalls; // "[]: Not satisfied. …": the verdict is what tells. diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index 2c88239d..7ca4f5dc 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -3,7 +3,7 @@ * A conversation belongs to one agent; every user turn starts a run (runner/runner.ts) that streams * into one assistant message. A human-readable transcript is kept in the agent repo. */ -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, extname, join } from "node:path"; import type { Agent, @@ -678,9 +678,9 @@ export function isPrivateChat(id: string): boolean { } /** Claude Code's own record of a session: its JSONL and the folder of its subagents and tool output, in any project. */ -function claudeSessionPaths(sessionId: string): string[] { +function claudeSessionPaths(sessionId: string, configDir = claudeConfigDir()): string[] { if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(sessionId)) return []; - const root = join(claudeConfigDir(), "projects"); + const root = join(configDir, "projects"); let dirs: string[]; try { dirs = readdirSync(root); @@ -690,6 +690,53 @@ function claudeSessionPaths(sessionId: string): string[] { return dirs.flatMap((dir) => [join(root, dir, `${sessionId}.jsonl`), join(root, dir, sessionId)].filter((p) => existsSync(p))); } +/** How far back a goal status is looked for at most: while a goal is set, its latest status is near the end. */ +const GOAL_LOOKBACK_BYTES = 64 * 1024 * 1024; + +/** + * The condition of the chat's `/goal` while one is set, or null. Claude Code keeps it in the session: the latest goal + * status there tells, as it does when the session resumes (met, failed or cleared = none). `configDir`: where the + * run's Claude Code keeps its sessions (GLM runs have their own). + */ +export function chatGoal(conversationId: string, configDir?: string | null): string | null { + const sessionId = get<{ claude_session_id: string | null }>("SELECT claude_session_id FROM conversations WHERE id = ?", conversationId)?.claude_session_id; + const files = sessionId ? claudeSessionPaths(sessionId, configDir || undefined).filter((p) => p.endsWith(".jsonl")) : []; + const newest = files.map((p) => ({ p, at: statSync(p, { throwIfNoEntry: false })?.mtimeMs ?? 0 })).sort((x, y) => y.at - x.at)[0]?.p; + if (!newest) return null; + let fd: number; + try { + fd = openSync(newest, "r"); + } catch { + return null; + } + try { + const size = fstatSync(fd).size; + for (let span = Math.min(size, 1 << 20); ; span = Math.min(size, GOAL_LOOKBACK_BYTES, span * 4)) { + const buf = Buffer.alloc(span); + readSync(fd, buf, 0, span, size - span); + let text = buf.toString("utf8"); + // A line cut off at the start of the window is read with the next, larger one. + if (span < size) text = text.slice(text.indexOf("\n") + 1); + for (let at = text.lastIndexOf('"goal_status"'); at >= 0; at = text.lastIndexOf('"goal_status"', at - 1)) { + const end = text.indexOf("\n", at); + let status: { type?: unknown; met?: unknown; failed?: unknown; condition?: unknown } | undefined; + try { + status = (JSON.parse(text.slice(text.lastIndexOf("\n", at) + 1, end < 0 ? undefined : end)) as { attachment?: typeof status }).attachment; + } catch { + continue; + } + if (status?.type !== "goal_status") continue; + return !status.met && !status.failed && typeof status.condition === "string" && status.condition ? status.condition : null; + } + if (span >= size || span >= GOAL_LOOKBACK_BYTES) return null; + } + } catch { + return null; + } finally { + closeSync(fd); + } +} + /** * Wipe a private chat: its messages, uploads, run logs and Claude Code session go, and its runs keep only what they * cost (budgets count it). Other chats are left alone. @@ -714,7 +761,7 @@ export async function discardPrivateChat(id: string): Promise { const files = [ ...logs, ...(agent ? uploads.filter((p) => p.startsWith("workspace/uploads/")).map((p) => join(agent.repoPath, p)) : []), - ...[...sessions].flatMap(claudeSessionPaths), + ...[...sessions].flatMap((s) => claudeSessionPaths(s)), ]; for (const file of new Set(files)) { try { diff --git a/packages/core/test/fixtures/fake-claude.ts b/packages/core/test/fixtures/fake-claude.ts index e6fe0f2e..b4db8033 100755 --- a/packages/core/test/fixtures/fake-claude.ts +++ b/packages/core/test/fixtures/fake-claude.ts @@ -84,6 +84,11 @@ * SHOTS: run tool steps that each answer with a screenshot and a line of text, then answer "shots done". * With THEN_WAIT: it first waits for the state dir's `-next` file, writes "almost there", and * waits for `-done` + * GOAL_CLEAR a turn of a chat whose `/goal` (written to the session file under CLAUDE_CONFIG_DIR) is over: the agent + * calls the gateway's goal_clear (reason from the state dir's `goal-clear-args.json`, result to + * `goal-clear-result.json`), answers, and the goal's Stop hook blocks the end of the turn; the process + * then idles for 5 s, writes `goal-clear-idled` and answers "No response requested." GOAL_CLEAR_LATE + * lets the hook block once before the call. `/goal clear` clears that goal. * / a slash command Claude Code runs locally (`/clear` resets the session, `/model bogus` is rejected, * `/effort ultracode [on|off]` switches Ultracode, and a new effort level ends an Ultracode that the * --settings file turned on) @@ -360,6 +365,22 @@ function textTurn(text: string) { const slash = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.trim()); +/** Claude Code's own record of the session, where `/goal` keeps its state. */ +const sessionFile = () => join(process.env.CLAUDE_CONFIG_DIR ?? join(stateDir, "claude"), "projects", "fake", `${sessionId}.jsonl`); +const goalStatus = (status: Record) => { + mkdirSync(join(sessionFile(), ".."), { recursive: true }); + appendFileSync(sessionFile(), JSON.stringify({ type: "attachment", uuid: crypto.randomUUID(), sessionId, attachment: { type: "goal_status", ...status } }) + "\n"); +}; +const activeGoal = (): string | null => { + if (!existsSync(sessionFile())) return null; + const statuses = readFileSync(sessionFile(), "utf8") + .split("\n") + .filter((l) => l.includes('"goal_status"')) + .map((l) => (JSON.parse(l) as { attachment: { met: boolean; condition: string } }).attachment); + const last = statuses.at(-1); + return last && !last.met ? last.condition : null; +}; + /** Ask the human through the gateway like an agent does, as one step, then stand still at the hook. */ async function ask(mode: "question" | "approval" | "twice" | "nohook") { out(init); @@ -419,6 +440,12 @@ if (slash?.[1] === "clear") { out({ type: "conversation_reset", new_conversation_id: fresh, trigger: "clear" }); out({ ...init, session_id: fresh }); result("", { session_id: fresh, num_turns: 0, local_command: "clear" }); +} else if (slash?.[1] === "goal" && slash[2].trim() === "clear") { + const goal = activeGoal(); + if (goal) goalStatus({ met: true, sentinel: true, condition: goal }); + appendFileSync(join(stateDir, "goal-clears"), `${sessionId}\n`); + const text = goal ? `Goal cleared: ${goal}` : "No goal set"; + out({ type: "result", subtype: "success", is_error: false, num_turns: 0, duration_ms: 1, total_cost_usd: 0, result: text, session_id: sessionId }); } else if (slash) { const [, name, args] = slash; out(init); @@ -1098,6 +1125,39 @@ if (slash?.[1] === "clear") { const text = `GUEST ${JSON.stringify(summary)}`; textTurn(text); result(text); +} else if (prompt.includes("GOAL_CLEAR")) { + out(init); + const goal = "sell the course and make the first sale"; + goalStatus({ met: false, sentinel: true, condition: goal }); + const cfg = JSON.parse(readFileSync(argValue("--mcp-config")!, "utf8")) as { mcpServers: Record }> }; + const gw = cfg.mcpServers.godmode!; + const rpc = async (body: unknown) => { + const res = await fetch(gw.url, { method: "POST", headers: { ...gw.headers, "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(body) }); + const raw = await res.text(); + return raw ? JSON.parse(raw) : null; + }; + await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake", version: "1" } } }); + const verdict = () => { + goalStatus({ met: false, condition: goal, reason: "No paying customer yet." }); + out({ type: "user", message: { role: "user", content: [{ type: "text", text: `Stop hook feedback:\n[${goal}]: Not satisfied. No paying customer yet.` }] }, parent_tool_use_id: null, session_id: sessionId }); + }; + if (prompt.includes("GOAL_CLEAR_LATE")) { + textTurn("Still no sale."); + verdict(); + await pause(150); + } + const argsFile = join(stateDir, "goal-clear-args.json"); + const input = existsSync(argsFile) ? (JSON.parse(readFileSync(argsFile, "utf8")) as Record) : { reason: "Daniel decided to end it." }; + out({ type: "assistant", message: { id: "msg_goal_clear", role: "assistant", content: [{ type: "tool_use", id: "toolu_goal_clear", name: "mcp__godmode__goal_clear", input }] }, parent_tool_use_id: null, session_id: sessionId }); + const called = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "goal_clear", arguments: input } }); + writeFileSync(join(stateDir, "goal-clear-result.json"), JSON.stringify(called.result)); + out({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_goal_clear", content: called.result.content[0].text, is_error: called.result.isError === true }] }, parent_tool_use_id: null, session_id: sessionId }); + textTurn("The line is closed, the site stays live."); + verdict(); + await pause(5000); + writeFileSync(join(stateDir, "goal-clear-idled"), ""); + textTurn("No response requested."); + result("No response requested."); } else if (prompt.includes("GOAL_LOOP")) { await replay(prompt.includes("GOAL_LOOP_WORKING") ? "stream-goal-working.jsonl" : "stream-goal-loop.jsonl", async (event) => { if (event.type !== "user" || !JSON.stringify(event).includes("Stop hook feedback:")) return; diff --git a/packages/core/test/goal-clear.test.ts b/packages/core/test/goal-clear.test.ts new file mode 100644 index 00000000..c9e59b38 --- /dev/null +++ b/packages/core/test/goal-clear.test.ts @@ -0,0 +1,123 @@ +/** + * `goal_clear`: an agent ends its chat's `/goal` itself when the human dropped it, instead of handing them a task to + * type `/goal clear`. The goal's check no longer holds the turn up, and Godmode clears the goal in the session after it. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Agent, MessageBlock } from "@godmode/shared"; +import { argValue, invocations, makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { callTool } from "../src/mcp/tools"; +import { chatGoal, getConversation, sendMessage, startChat } from "../src/services/conversations"; +import { waitForRun } from "../src/runner/runner"; + +let env: TestEnv; +let agent: Agent; +let configDir: string; +const configBefore = process.env.CLAUDE_CONFIG_DIR; + +beforeAll(async () => { + configDir = mkdtempSync(join(tmpdir(), "godmode-goal-claude-")); + process.env.CLAUDE_CONFIG_DIR = configDir; + env = await setupEnv("godmode-goal-clear-"); + agent = await makeAgent({ name: "Goal Bot", description: "test agent" }); +}); + +afterAll(async () => { + await env.close(); + if (configBefore === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = configBefore; + rmSync(configDir, { recursive: true, force: true }); +}); + +const notices = (blocks: MessageBlock[]) => blocks.flatMap((b) => (b.type === "notice" ? [b.text] : [])); +const clears = () => (existsSync(join(env.stateDir, "goal-clears")) ? readFileSync(join(env.stateDir, "goal-clears"), "utf8").split("\n").filter(Boolean).length : 0); + +async function chat(): Promise<{ conversationId: string; sessionId: string }> { + const started = await startChat({ agentId: agent.id, content: "hello" }); + await waitForRun(started.run.id, 20_000); + const conversationId = started.conversation.id; + return { conversationId, sessionId: getConversation(conversationId).claudeSessionId! }; +} + +function goalStatus(sessionId: string, status: Record) { + const dir = join(configDir, "projects", "-tmp-somewhere"); + mkdirSync(dir, { recursive: true }); + appendFileSync(join(dir, `${sessionId}.jsonl`), `${JSON.stringify({ type: "attachment", attachment: { type: "goal_status", ...status } })}\n`); +} + +describe("the chat's goal", () => { + test("is the latest goal status of its Claude session: none once it was met, failed or cleared", async () => { + const { conversationId, sessionId } = await chat(); + expect(chatGoal(conversationId)).toBeNull(); + goalStatus(sessionId, { met: false, sentinel: true, condition: "ship the report" }); + expect(chatGoal(conversationId)).toBe("ship the report"); + goalStatus(sessionId, { met: false, condition: "ship the report", reason: "Not yet." }); + expect(chatGoal(conversationId)).toBe("ship the report"); + goalStatus(sessionId, { met: true, sentinel: true, condition: "ship the report" }); + expect(chatGoal(conversationId)).toBeNull(); + goalStatus(sessionId, { met: false, sentinel: true, condition: "and the invoice" }); + expect(chatGoal(conversationId)).toBe("and the invoice"); + goalStatus(sessionId, { met: false, failed: true, condition: "and the invoice" }); + expect(chatGoal(conversationId)).toBeNull(); + }); +}); + +describe("goal_clear", () => { + test("the agent ends the goal: the run ends at the goal's check, the goal is cleared and the chat says why", async () => { + const { conversationId, sessionId } = await chat(); + const before = clears(); + rmSync(join(env.stateDir, "goal-clear-idled"), { force: true }); + const run = await waitForRun((await sendMessage(conversationId, { content: "GOAL_CLEAR" })).run.id, 20_000); + expect(run.status).toBe("succeeded"); + expect(run.error).toBeNull(); + // It doesn't sit out the continuation the goal's check forced. + expect(existsSync(join(env.stateDir, "goal-clear-idled"))).toBe(false); + + const called = JSON.parse(readFileSync(join(env.stateDir, "goal-clear-result.json"), "utf8")) as { isError?: boolean; content: { text: string }[] }; + expect(called.isError).toBeFalsy(); + expect(called.content[0]!.text).toContain("sell the course and make the first sale"); + + const message = getConversation(conversationId).messages.at(-1)!; + expect(message.content).toBe("The line is closed, the site stays live."); + expect(notices(message.blocks)).toContain("Goal ended: “sell the course and make the first sale” — Daniel decided to end it."); + expect(notices(message.blocks).some((t) => t.startsWith("Goal still open"))).toBe(false); + + expect(clears()).toBe(before + 1); + const clear = invocations(env).at(-1)!; + expect(clear.prompt).toBe("/goal clear"); + expect(argValue(clear, "--resume")).toBe(sessionId); + expect(chatGoal(conversationId)).toBeNull(); + // The chat goes on in the same session. + expect(getConversation(conversationId).claudeSessionId).toBe(sessionId); + }); + + test("called in a continuation the goal's check forced, it waits for the next check: the answer stays", async () => { + const { conversationId } = await chat(); + rmSync(join(env.stateDir, "goal-clear-idled"), { force: true }); + const run = await waitForRun((await sendMessage(conversationId, { content: "GOAL_CLEAR_LATE" })).run.id, 20_000); + expect(run.status).toBe("succeeded"); + expect(existsSync(join(env.stateDir, "goal-clear-idled"))).toBe(false); + const message = getConversation(conversationId).messages.at(-1)!; + expect(message.content).toContain("The line is closed, the site stays live."); + expect(notices(message.blocks).filter((t) => t.startsWith("Goal ended"))).toHaveLength(1); + expect(chatGoal(conversationId)).toBeNull(); + }); + + test("a chat without a goal has nothing to end", async () => { + const { conversationId } = await chat(); + const out = await callTool({ runId: "run_goal_test", agentId: agent.id, conversationId, workspaceId: null, depth: 0 }, "goal_clear", { reason: "Daniel ended it." }); + expect(out.isError).toBeFalsy(); + expect(out.content[0]!.text).toBe("This chat has no active /goal — nothing to end."); + }); + + test("a run that isn't working anymore keeps the goal", async () => { + const { conversationId, sessionId } = await chat(); + goalStatus(sessionId, { met: false, sentinel: true, condition: "ship the report" }); + const out = await callTool({ runId: "run_gone", agentId: agent.id, conversationId, workspaceId: null, depth: 0 }, "goal_clear", { reason: "Daniel ended it." }); + expect(out.isError).toBe(true); + expect(out.content[0]!.text).toContain("the goal stays"); + expect(chatGoal(conversationId)).toBe("ship the report"); + }); +});