From dc5b3ecc4c52566ea8a0c41b5c5fd138a9c4cae1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 30 May 2026 12:21:05 +0100 Subject: [PATCH] fix(tui): continue goal commands after creation --- src/auto-reply/reply/commands-goal.test.ts | 136 +++++++++++++++++- src/auto-reply/reply/commands-goal.ts | 64 ++++++++- .../reply/get-reply-native-slash-fast-path.ts | 3 +- .../reply/get-reply.fast-path.test.ts | 80 +++++++++++ src/tui/tui-command-handlers.test.ts | 70 ++++++++- src/tui/tui-command-handlers.ts | 24 ++++ 6 files changed, 363 insertions(+), 14 deletions(-) diff --git a/src/auto-reply/reply/commands-goal.test.ts b/src/auto-reply/reply/commands-goal.test.ts index e3325362adf..e43fd5f9dda 100644 --- a/src/auto-reply/reply/commands-goal.test.ts +++ b/src/auto-reply/reply/commands-goal.test.ts @@ -4,8 +4,13 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getSessionEntry, upsertSessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { handleGoalCommand, parseGoalCommand } from "./commands-goal.js"; +import { + formatGoalContinuationPrompt, + handleGoalCommand, + parseGoalCommand, +} from "./commands-goal.js"; import type { HandleCommandsParams } from "./commands-types.js"; +import { parseInlineDirectives } from "./directive-handling.parse.js"; const sessionKey = "agent:main:web:main"; let tempRoots: string[] = []; @@ -76,6 +81,18 @@ describe("goal commands", () => { }); }); + it("formats command-looking continuation prompts so inline directives leave them intact", () => { + const prompt = formatGoalContinuationPrompt("ship /fast off"); + expect(prompt).toBe( + `Pursue this goal exactly as written from this JSON string: "ship \\/fast off"`, + ); + + const directives = parseInlineDirectives(prompt); + + expect(directives.cleaned).toBe(prompt); + expect(directives.hasFastDirective).toBe(false); + }); + it("starts a goal from Codex-style bare /goal objective text", async () => { const storePath = await createStorePath(); await upsertSessionEntry({ @@ -84,13 +101,118 @@ describe("goal commands", () => { entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true }, }); - const result = await handleGoalCommand( - buildGoalParams("/goal build a 3d game", storePath), - true, - ); + const params = buildGoalParams("/goal build a 3d game", storePath); + const result = await handleGoalCommand(params, true); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toBe("Goal started: build a 3d game"); + expect(result?.shouldContinue).toBe(true); + expect(result?.reply).toBeUndefined(); + expect(params.command.commandBodyNormalized).toBe("build a 3d game"); + expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe("build a 3d game"); expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("build a 3d game"); }); + + it("wraps command-prefixed goal objectives before continuing", async () => { + const storePath = await createStorePath(); + await upsertSessionEntry({ + storePath, + sessionKey, + entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true }, + }); + + const slashParams = buildGoalParams("/goal start /status", storePath); + const slashResult = await handleGoalCommand(slashParams, true); + const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; + + expect(slashResult?.shouldContinue).toBe(true); + expect(slashParams.command.commandBodyNormalized).toBe(slashPrompt); + expect((slashParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(slashPrompt); + expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("/status"); + + const bangStorePath = await createStorePath(); + await upsertSessionEntry({ + storePath: bangStorePath, + sessionKey, + entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true }, + }); + + const bangParams = buildGoalParams("/goal start !npm test", bangStorePath); + const bangResult = await handleGoalCommand(bangParams, true); + const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`; + + expect(bangResult?.shouldContinue).toBe(true); + expect(bangParams.command.commandBodyNormalized).toBe(bangPrompt); + expect((bangParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(bangPrompt); + expect(getSessionEntry({ storePath: bangStorePath, sessionKey })?.goal?.objective).toBe( + "!npm test", + ); + }); + + it("resumes a goal and continues with a resume prompt", async () => { + const storePath = await createStorePath(); + await upsertSessionEntry({ + storePath, + sessionKey, + entry: { + sessionId: "sess-main", + updatedAt: 1, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "finish the migration", + status: "paused", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokenStartFresh: true, + tokensUsed: 0, + continuationTurns: 0, + }, + }, + }); + + const params = buildGoalParams("/goal resume CI passed", storePath); + const result = await handleGoalCommand(params, true); + + expect(result?.shouldContinue).toBe(true); + expect(params.command.commandBodyNormalized).toBe( + "Continue pursuing the current goal. Note: CI passed", + ); + expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active"); + }); + + it("wraps command-looking resume notes before continuing", async () => { + const storePath = await createStorePath(); + await upsertSessionEntry({ + storePath, + sessionKey, + entry: { + sessionId: "sess-main", + updatedAt: 1, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "finish the migration", + status: "paused", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokenStartFresh: true, + tokensUsed: 0, + continuationTurns: 0, + }, + }, + }); + + const params = buildGoalParams("/goal resume /fast off", storePath); + const result = await handleGoalCommand(params, true); + const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`; + const directives = parseInlineDirectives(prompt); + + expect(result?.shouldContinue).toBe(true); + expect(params.command.commandBodyNormalized).toBe(prompt); + expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(prompt); + expect(directives.cleaned).toBe(prompt); + expect(directives.hasFastDirective).toBe(false); + expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active"); + }); }); diff --git a/src/auto-reply/reply/commands-goal.ts b/src/auto-reply/reply/commands-goal.ts index 45bace615ea..ea62c5f3341 100644 --- a/src/auto-reply/reply/commands-goal.ts +++ b/src/auto-reply/reply/commands-goal.ts @@ -73,6 +73,61 @@ function goalReply(text: string): CommandHandlerResult { }; } +function hasCommandLikeGoalText(trimmed: string): boolean { + return /(?:^|\s)\//.test(trimmed) || trimmed.startsWith("!"); +} + +function encodeGoalJsonString(trimmed: string): string { + return JSON.stringify(trimmed).replaceAll("/", "\\/"); +} + +export function formatGoalContinuationPrompt(objective: string): string { + const trimmed = objective.trim(); + return hasCommandLikeGoalText(trimmed) + ? `Pursue this goal exactly as written from this JSON string: ${encodeGoalJsonString(trimmed)}` + : trimmed; +} + +export function formatGoalResumeContinuationPrompt(note: string): string { + const trimmed = note.trim(); + if (!trimmed) { + return "Continue pursuing the current goal."; + } + return hasCommandLikeGoalText(trimmed) + ? `Continue pursuing the current goal. Interpret this JSON string as the resume note: ${encodeGoalJsonString(trimmed)}` + : `Continue pursuing the current goal. Note: ${trimmed}`; +} + +function applyGoalPromptToContext(ctx: HandleCommandsParams["ctx"], message: string): void { + const mutableCtx = ctx as HandleCommandsParams["ctx"] & { + Body?: string; + RawBody?: string; + CommandBody?: string; + BodyForCommands?: string; + BodyForAgent?: string; + BodyStripped?: string; + }; + mutableCtx.Body = message; + mutableCtx.RawBody = message; + mutableCtx.CommandBody = message; + mutableCtx.BodyForCommands = message; + mutableCtx.BodyForAgent = message; + mutableCtx.BodyStripped = message; +} + +function applyGoalContinuationPrompt(params: HandleCommandsParams, message: string): void { + applyGoalPromptToContext(params.ctx, message); + if (params.rootCtx && params.rootCtx !== params.ctx) { + applyGoalPromptToContext(params.rootCtx, message); + } + params.command.rawBodyNormalized = message; + params.command.commandBodyNormalized = message; +} + +function goalContinuation(): CommandHandlerResult { + return { shouldContinue: true }; +} + function goalErrorReply(error: unknown): CommandHandlerResult { const message = error instanceof Error ? error.message : String(error); return goalReply(`Goal error: ${message}`); @@ -115,7 +170,8 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand fallbackEntry: params.sessionEntry, }); syncGoalSessionEntry(params); - return goalReply(`Goal started: ${goal.objective}`); + applyGoalContinuationPrompt(params, formatGoalContinuationPrompt(goal.objective)); + return goalContinuation(); } case "pause": { const goal = await updateSessionGoalStatus({ @@ -128,14 +184,16 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand return goalReply(`Goal paused: ${goal.objective}`); } case "resume": { - const goal = await updateSessionGoalStatus({ + await updateSessionGoalStatus({ sessionKey: params.sessionKey, storePath: params.storePath, status: "active", ...(parsed.text ? { note: parsed.text } : {}), }); syncGoalSessionEntry(params); - return goalReply(`Goal resumed: ${goal.objective}`); + const message = formatGoalResumeContinuationPrompt(parsed.text); + applyGoalContinuationPrompt(params, message); + return goalContinuation(); } case "complete": case "done": { diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 427d1a4d347..c7921985727 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -200,6 +200,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { if (!commandResult.shouldContinue) { return { handled: true, reply: commandResult.reply }; } + const continuationTriggerBodyNormalized = command.rawBodyNormalized; const directiveResult = await resolveReplyDirectives({ ctx: params.ctx, @@ -216,7 +217,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { sessionScope: sessionState.sessionScope, groupResolution: sessionState.groupResolution, isGroup: sessionState.isGroup, - triggerBodyNormalized: sessionState.triggerBodyNormalized, + triggerBodyNormalized: continuationTriggerBodyNormalized, resetTriggered: false, commandAuthorized: params.commandAuthorized, defaultProvider: params.defaultProvider, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 122d63dfd39..1d910f70b5b 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -12,6 +12,7 @@ import { } from "./get-reply-fast-path.js"; import { buildGetReplyCtx, + createGetReplyContinueDirectivesResult, createGetReplySessionState, expectResolvedTelegramTimezone, registerGetReplyRuntimeOverrides, @@ -28,6 +29,7 @@ function emptyAliasIndex(): ModelAliasIndex { const mocks = vi.hoisted(() => ({ ensureAgentWorkspace: vi.fn(), + handleInlineActions: vi.fn(), initSessionState: vi.fn(), loadModelCatalog: vi.fn(async () => [ { @@ -113,6 +115,8 @@ describe("getReplyFromConfig fast test bootstrap", () => { resolveRuntimeCliBackends: () => [], }); mocks.ensureAgentWorkspace.mockReset(); + mocks.handleInlineActions.mockReset(); + mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: { text: "ok" } }); mocks.initSessionState.mockReset(); mocks.loadModelCatalog.mockReset(); mocks.loadModelCatalog.mockResolvedValue([ @@ -525,6 +529,82 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(directiveParams.workspaceDir).toBe("/tmp/workspace"); }); + it("continues native slash goal starts with the rewritten command-safe prompt", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-goal-fast-")); + const targetSessionKey = "agent:main:telegram:123"; + const storePath = path.join(home, "sessions.json"); + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: storePath }, + } as OpenClawConfig); + const continuationPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; + const continueDirectives = async (params: unknown) => + createGetReplyContinueDirectivesResult({ + body: (params as { triggerBodyNormalized: string }).triggerBodyNormalized, + abortKey: targetSessionKey, + from: "telegram:user:42", + to: "telegram:123", + senderId: "telegram:user:42", + commandSource: (params as { triggerBodyNormalized: string }).triggerBodyNormalized, + senderIsOwner: true, + resetHookTriggered: false, + }); + mocks.resolveReplyDirectives + .mockImplementationOnce(continueDirectives) + .mockImplementationOnce(async (params: unknown) => { + expect((params as { triggerBodyNormalized: string }).triggerBodyNormalized).toBe( + continuationPrompt, + ); + return continueDirectives(params); + }); + mocks.handleInlineActions.mockImplementation(async (params: unknown) => { + expect(params).toMatchObject({ + command: { + rawBodyNormalized: continuationPrompt, + commandBodyNormalized: continuationPrompt, + }, + cleanedBody: continuationPrompt, + }); + return { + kind: "continue", + directives: {}, + abortedLastRun: false, + cleanedBody: continuationPrompt, + }; + }); + + await expect( + getReplyFromConfig( + buildGetReplyCtx({ + Body: "/goal start /status", + BodyForAgent: "/goal start /status", + RawBody: "/goal start /status", + CommandBody: "/goal start /status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ), + ).resolves.toEqual({ text: "ok" }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf8")) as { + sessions?: Record; + }; + expect(stored.sessions?.[targetSessionKey]?.goal?.objective).toBe("/status"); + const preparedReplyParams = requirePreparedReplyParams(); + expect(preparedReplyParams.command.commandBodyNormalized).toBe(continuationPrompt); + expect(preparedReplyParams.sessionCtx.BodyForAgent).toBe(continuationPrompt); + expect(mocks.handleInlineActions).toHaveBeenCalledTimes(2); + }); + it("uses native command target session keys during fast bootstrap", () => { const result = initFastReplySessionState({ ctx: buildGetReplyCtx({ diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 99dc0c9193a..c7cf09d7709 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -268,9 +268,9 @@ describe("tui command handlers", () => { }); }); - it("runs goal commands locally instead of sending them to the model", async () => { + it("starts local goals and sends the objective to the model", async () => { const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" }); - const { handleCommand, sendChat, addSystem, refreshSessionInfo } = createHarness({ + const { handleCommand, sendChat, addSystem, refreshSessionInfo, addUser } = createHarness({ opts: { local: true }, runGoalCommand, }); @@ -282,11 +282,75 @@ describe("tui command handlers", () => { agentId: "main", command: "/goal start ship", }); - expect(sendChat).not.toHaveBeenCalled(); + expectSendChatFields(sendChat, { + sessionKey: "agent:main:main", + message: "ship", + }); + expect(addUser).toHaveBeenCalledWith("ship"); expect(addSystem).toHaveBeenCalledWith("Goal started: ship"); expect(refreshSessionInfo).toHaveBeenCalled(); }); + it("wraps command-prefixed local goal objectives before sending", async () => { + const slashRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" }); + const slashHarness = createHarness({ + opts: { local: true }, + runGoalCommand: slashRunGoalCommand, + }); + + await slashHarness.handleCommand("/goal start /status"); + const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; + expectSendChatFields(slashHarness.sendChat, { + sessionKey: "agent:main:main", + message: slashPrompt, + }); + expect(slashHarness.addUser).toHaveBeenCalledWith(slashPrompt); + + const bangRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" }); + const bangHarness = createHarness({ + opts: { local: true }, + runGoalCommand: bangRunGoalCommand, + }); + + await bangHarness.handleCommand("/goal start !npm test"); + const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`; + expectSendChatFields(bangHarness.sendChat, { + sessionKey: "agent:main:main", + message: bangPrompt, + }); + expect(bangHarness.addUser).toHaveBeenCalledWith(bangPrompt); + }); + + it("keeps local goal status as a control command", async () => { + const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal: ship" }); + const { handleCommand, sendChat, addSystem } = createHarness({ + opts: { local: true }, + runGoalCommand, + }); + + await handleCommand("/goal status"); + + expect(sendChat).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("Goal: ship"); + }); + + it("wraps command-prefixed local goal resume notes before sending", async () => { + const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal resumed: ship" }); + const { handleCommand, sendChat, addUser } = createHarness({ + opts: { local: true }, + runGoalCommand, + }); + + await handleCommand("/goal resume /fast off"); + + const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`; + expectSendChatFields(sendChat, { + sessionKey: "agent:main:main", + message: prompt, + }); + expect(addUser).toHaveBeenCalledWith(prompt); + }); + it("passes the selected agent for local global goal commands", async () => { const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" }); const { handleCommand } = createHarness({ diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 805c8485e4c..e1f65974b84 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -3,6 +3,11 @@ import type { Component, SelectItem, TUI } from "@earendil-works/pi-tui"; import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; import { modelKey } from "../agents/model-ref-shared.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; +import { + formatGoalContinuationPrompt, + formatGoalResumeContinuationPrompt, + parseGoalCommand, +} from "../auto-reply/reply/commands-goal.js"; import { formatThinkingLevels, normalizeUsageDisplay, @@ -69,6 +74,21 @@ function isSlashStopCommand(text: string): boolean { return trimmed.startsWith("/") && isChatStopCommandText(trimmed); } +function goalContinuationPrompt(text: string): string | null { + const parsed = parseGoalCommand(text); + if (!parsed) { + return null; + } + const action = parsed.action; + if (action === "start" || action === "set" || action === "create") { + return formatGoalContinuationPrompt(parsed.text) || null; + } + if (action === "resume") { + return formatGoalResumeContinuationPrompt(parsed.text); + } + return null; +} + export function createCommandHandlers(context: CommandHandlerContext) { const { client, @@ -396,6 +416,10 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); chatLog.addSystem(result.text); await refreshSessionInfo(); + const continuation = goalContinuationPrompt(raw); + if (continuation) { + await sendMessage(continuation); + } } catch (err) { chatLog.addSystem(`goal failed: ${sanitizeRenderableText(String(err))}`); }