From 61aad3879214099a339a755dda0ebb5ca0fce12e Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:36:17 -0400 Subject: [PATCH] fix: keep every answer when a model task asks again Each model run starts with only the task prompt, so the reply to an earlier ask_user question has to come from saved state. Answering overwrote state.answer, so a task that asked two questions lost the first reply and the model never saw which question a reply answered. Keep the replies as a list with their questions and send all of them. Tasks saved with a single answer still read it. --- apps/server/src/engine/model.ts | 17 ++++++++++- apps/server/src/engine/service.ts | 10 +++++-- tests/model-outcome.test.ts | 47 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/server/src/engine/model.ts b/apps/server/src/engine/model.ts index 2813def19..28c71a311 100644 --- a/apps/server/src/engine/model.ts +++ b/apps/server/src/engine/model.ts @@ -11,6 +11,15 @@ import type { AgentService } from "./service.ts"; import { tanstackAgent } from "./tanstack-agent.ts"; import type { TaskContext } from "./worker.ts"; +export type TaskAnswer = { question?: string; answer: string }; + +/** Every reply the user gave this task, oldest first, with the question it answered. */ +export function taskAnswers(state: Record): TaskAnswer[] { + if (Array.isArray(state.answers)) return state.answers as TaskAnswer[]; + // Tasks answered before replies were kept as a list hold only the latest one. + return typeof state.answer === "string" ? [{ answer: state.answer }] : []; +} + export async function executeModelTask( service: AgentService, owner: string, @@ -339,7 +348,13 @@ export async function executeModelTask( role: "user", content: task.prompt + - (task.state.answer ? `\nAdditional answer: ${String(task.state.answer)}` : ""), + taskAnswers(task.state) + .map(({ question, answer }) => + question + ? `\nYou asked: ${question}\nAnswer: ${answer}` + : `\nAdditional answer: ${answer}`, + ) + .join(""), }, ], state: {}, diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 0e071ba87..bcc673af2 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -34,7 +34,7 @@ import { backgroundFailure } from "../log.ts"; import { SearchService } from "../search.ts"; import type { WorkspaceService } from "../workspace.ts"; import { analyzeSpending } from "./finance.ts"; -import { executeModelTask } from "./model.ts"; +import { executeModelTask, taskAnswers } from "./model.ts"; import { countPageDiff, describePageDiff, @@ -341,6 +341,12 @@ export class AgentService { const task = await this.getTask(owner, id); if (task.status !== "waiting_input") throw new AppError("This task is not waiting for input", 409); + // Each run starts fresh, so keep every reply with its question, not just the latest. + const { answer: _latest, ...state } = task.state; + const answers = [ + ...taskAnswers(task.state), + { ...(task.question ? { question: task.question } : {}), answer }, + ]; const next = await this.db.compareAndSwap( owner, "tasks", @@ -350,7 +356,7 @@ export class AgentService { status: "queued", question: null, input: { ...task.input, ...(fields ? { fields } : {}) }, - state: { ...task.state, answer }, + state: { ...state, answers }, updatedAt: date(), }, ); diff --git a/tests/model-outcome.test.ts b/tests/model-outcome.test.ts index 5858a2ef8..58176af3f 100644 --- a/tests/model-outcome.test.ts +++ b/tests/model-outcome.test.ts @@ -74,3 +74,50 @@ for (const scenario of [ } }); } + +test("a model task keeps every earlier answer when it asks again", async (t) => { + const { requests } = await modelFixture(t, (index) => + index === 0 + ? { name: "ask_user", arguments: { question: "Which date should I use?" } } + : index === 1 + ? { name: "ask_user", arguments: { question: "What time works?" } } + : { name: "finish_task", arguments: { summary: "Planned." } }, + ); + const directory = await mkdtemp(join(tmpdir(), "openmuse-model-answers-")); + const db = await createStore(); + const server = await createApp(db, { + mode: "sample", + port: 8787, + host: "127.0.0.1", + publicUrl: "http://localhost:8787", + dataDir: directory, + agentBackend: "model", + model: "openai/fixture", + intelligenceApiKey: "test-project-key-never-sent", + googleRedirectUri: "http://localhost:8787/api/google/callback", + allowedOrigins: [], + }); + try { + const task = await server.agent.createTask("owner", { prompt: "Plan a sample walk" }); + await server.agent.worker.tick(); + await server.agent.answer("owner", task.id, "Friday"); + await server.agent.worker.tick(); + await server.agent.answer("owner", task.id, "3pm"); + await server.agent.worker.tick(); + assert.equal((await server.agent.getTask("owner", task.id)).status, "succeeded"); + assert.equal(requests.length, 3); + const userMessage = (body: string) => + JSON.stringify( + (JSON.parse(body) as { input: { role?: string; content: unknown }[] }).input.find( + (item) => item.role === "user", + )?.content, + ); + const last = userMessage(requests[2].body); + for (const text of ["Which date should I use?", "Friday", "What time works?", "3pm"]) + assert.ok(last.includes(text), `the third run's request is missing "${text}": ${last}`); + } finally { + await server.agent.stop(); + await db.close(); + await rm(directory, { recursive: true, force: true }); + } +});