diff --git a/extensions/cli/src/commands/devbox-entrypoint.md b/extensions/cli/src/commands/devbox-entrypoint.md new file mode 100644 index 000000000..bd4382058 --- /dev/null +++ b/extensions/cli/src/commands/devbox-entrypoint.md @@ -0,0 +1,12 @@ +# Devbox entrypoint behavior (cn serve) + +Context: runloop resumes a devbox by re-running the same entrypoint script, which invokes `cn serve --id ...`. Because the entrypoint always replays, the CLI must avoid duplicating state on restart. + +- **Session reuse:** `serve` now calls `loadOrCreateSessionById` when `--id` is provided so the same session file is reused instead of generating a new UUID. This keeps chat history intact across suspend/resume. +- **Skip replaying the initial prompt:** `shouldQueueInitialPrompt` checks existing history and only queues the initial prompt when there are no non-system messages. This prevents the first prompt from being resent when a suspended devbox restarts. +- **Environment persistence:** The devbox entrypoint (control-plane) writes all env vars to `~/.continue/devbox-env` and sources it before `cn serve`, so keys survive suspend/resume. The CLI assumes env is already present. + +Operational notes: + +- Changing the entrypoint is expensive; prefer adapting CLI/session behavior as above. +- When testing suspend/resume, confirm a single session file under `~/.continue/sessions` for the agent id and that follow-up messages append normally without replaying the first prompt. diff --git a/extensions/cli/src/commands/serve.initialPrompt.test.ts b/extensions/cli/src/commands/serve.initialPrompt.test.ts new file mode 100644 index 000000000..6e11b4571 --- /dev/null +++ b/extensions/cli/src/commands/serve.initialPrompt.test.ts @@ -0,0 +1,30 @@ +import type { ChatHistoryItem } from "core/index.js"; +import { describe, expect, it } from "vitest"; + +import { shouldQueueInitialPrompt } from "./serve.js"; + +const systemOnly: ChatHistoryItem[] = [ + { message: { role: "system", content: "sys" }, contextItems: [] }, +]; + +const withConversation: ChatHistoryItem[] = [ + ...systemOnly, + { message: { role: "user", content: "hi" }, contextItems: [] }, + { message: { role: "assistant", content: "hello" }, contextItems: [] }, +]; + +describe("shouldQueueInitialPrompt", () => { + it("returns false when no prompt is provided", () => { + expect(shouldQueueInitialPrompt([], undefined)).toBe(false); + expect(shouldQueueInitialPrompt([], null)).toBe(false); + }); + + it("returns true when prompt exists and only system history is present", () => { + expect(shouldQueueInitialPrompt([], "prompt")).toBe(true); + expect(shouldQueueInitialPrompt(systemOnly, "prompt")).toBe(true); + }); + + it("returns false when prompt exists but conversation already has non-system messages", () => { + expect(shouldQueueInitialPrompt(withConversation, "prompt")).toBe(false); + }); +}); diff --git a/extensions/cli/src/commands/serve.ts b/extensions/cli/src/commands/serve.ts index 8eb6ef1e9..f77e86f41 100644 --- a/extensions/cli/src/commands/serve.ts +++ b/extensions/cli/src/commands/serve.ts @@ -51,6 +51,26 @@ interface ServeOptions extends ExtendedCommandOptions { id?: string; } +/** + * Decide whether to enqueue the initial prompt on server startup. + * We only want to send it when starting a brand-new session; if any non-system + * messages already exist (e.g., after resume), skip to avoid replaying. + */ +export function shouldQueueInitialPrompt( + history: ChatHistoryItem[], + prompt?: string | null, +): boolean { + if (!prompt) { + return false; + } + + // If there are any non-system messages, we already have conversation context + const hasConversation = history.some( + (item) => item.message.role !== "system", + ); + return !hasConversation; +} + // eslint-disable-next-line max-statements export async function serve(prompt?: string, options: ServeOptions = {}) { await posthogService.capture("sessionStart", {}); @@ -419,10 +439,28 @@ export async function serve(prompt?: string, options: ServeOptions = {}) { agentFileState?.agentFile?.prompt, actualPrompt, ); + if (initialPrompt) { - console.log(chalk.dim("\nProcessing initial prompt...")); - await messageQueue.enqueueMessage(initialPrompt); - processMessages(state, llmApi); + const existingHistory = + (() => { + try { + return services.chatHistory.getHistory(); + } catch { + return state.session.history; + } + })() ?? []; + + if (shouldQueueInitialPrompt(existingHistory, initialPrompt)) { + logger.info(chalk.dim("\nProcessing initial prompt...")); + await messageQueue.enqueueMessage(initialPrompt); + processMessages(state, llmApi); + } else { + logger.info( + chalk.dim( + "Skipping initial prompt because existing conversation history was found.", + ), + ); + } } });