diff --git a/packages/agent-modules/goal/src/tool-impls.ts b/packages/agent-modules/goal/src/tool-impls.ts index ea669580..2568dd83 100644 --- a/packages/agent-modules/goal/src/tool-impls.ts +++ b/packages/agent-modules/goal/src/tool-impls.ts @@ -246,6 +246,13 @@ export class UpdateGoalTool implements ToolImpl< objectiveDigest: digestThreadGoalObjective(existing.objective), ...(summary ? { summary } : {}), }); + if (collection === 'not_a_goal_turn') { + return err( + UpdateGoalToolDef.name, + 'cannot update goal because this turn is not bound to the goal; continue handling the user request in this turn without updating goal status. This tool cannot resume a goal', + { reason: collection, currentGoal: serializeGoal(existing) }, + ); + } if (collection === 'stale') { return { ...err( diff --git a/packages/agent-modules/goal/src/types.ts b/packages/agent-modules/goal/src/types.ts index 2470a4af..fce100d3 100644 --- a/packages/agent-modules/goal/src/types.ts +++ b/packages/agent-modules/goal/src/types.ts @@ -80,7 +80,12 @@ export interface GoalTurnSignal { readonly summary?: string; } -export type ThreadGoalSignalCollectionResult = 'accepted' | 'stale' | 'no_goal' | 'paused'; +export type ThreadGoalSignalCollectionResult = + | 'accepted' + | 'not_a_goal_turn' + | 'stale' + | 'no_goal' + | 'paused'; export const THREAD_GOAL_STATUS_REASONS = [ 'complete(worker_proposal)', diff --git a/packages/agent-modules/goal/test/unit/thread-goal/tool-impls.test.ts b/packages/agent-modules/goal/test/unit/thread-goal/tool-impls.test.ts index a20f14af..50895e9f 100644 --- a/packages/agent-modules/goal/test/unit/thread-goal/tool-impls.test.ts +++ b/packages/agent-modules/goal/test/unit/thread-goal/tool-impls.test.ts @@ -563,14 +563,16 @@ describe("thread-goal tool impls", () => { const before = await store.getBySession(ctx.sessionId); const patch = vi.spyOn(store, "patch"); - // An unbound proposal: the Turn carries no Goal binding, so the host - // rejects it. Both the stale and the unbound refusals share `err()`. const stale = await new UpdateGoalTool( store, signalCollector("stale"), ).execute(ctx, { status: "complete", }); + const unbound = await new UpdateGoalTool( + store, + signalCollector("not_a_goal_turn"), + ).execute(ctx, { status: "complete" }); const missingMode = await new UpdateGoalTool( store, signalCollector(), @@ -580,6 +582,8 @@ describe("thread-goal tool impls", () => { }); expect(stale.isError).toBe(true); + expect(unbound.isError).toBe(true); + expect(unbound.terminate).toBeUndefined(); expect(missingMode.isError).toBe(true); expect(patch).not.toHaveBeenCalled(); expect(await store.getBySession(ctx.sessionId)).toEqual(before); diff --git a/packages/local-runtime/src/thread-goal/turn-context.ts b/packages/local-runtime/src/thread-goal/turn-context.ts index 79b5ed8f..935fef76 100644 --- a/packages/local-runtime/src/thread-goal/turn-context.ts +++ b/packages/local-runtime/src/thread-goal/turn-context.ts @@ -88,8 +88,8 @@ export class GoalTurnContextRegistry { collectSignal(turnId: string, signal: GoalTurnSignal): ThreadGoalSignalCollectionResult { const boundTurn = this.bindings.get(turnId); + if (!boundTurn) return 'not_a_goal_turn'; if ( - !boundTurn || boundTurn.kind !== 'main' || boundTurn.binding.goalId !== signal.goalId || boundTurn.binding.objectiveDigest !== signal.objectiveDigest diff --git a/packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts b/packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts index 3eca5730..6ad4396f 100644 --- a/packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts +++ b/packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts @@ -515,32 +515,57 @@ describe("LocalThreadGoalIntegration injected v2 Turn settlement", () => { expect(active.status).toBe("active"); }); - it("ignores an ordinary unbound Turn even when the session currently has an active Goal", async () => { - const active = goal(); - const bumpBoundUsage = vi.fn(); - const settleBoundTurn = vi.fn(); - const enqueuePostTurnContinuation = vi.fn(async () => undefined); - const { integration } = makeIntegration({ - store: makeStore({ - getBySession: async () => active, - bumpBoundUsage, - settleBoundTurn, - }), - enqueuePostTurnContinuation, - }); + it.each(["active", "complete"] as const)( + "rejects an unbound proposal without ending the user Turn or changing the %s Goal", + async (status) => { + const currentGoal = goal({ status }); + const bumpBoundUsage = vi.fn(); + const settleBoundTurn = vi.fn(); + const enqueuePostTurnContinuation = vi.fn(async () => undefined); + const { integration } = makeIntegration({ + store: makeStore({ + getBySession: async () => currentGoal, + bumpBoundUsage, + settleBoundTurn, + }), + enqueuePostTurnContinuation, + }); - await integration.settleInjectedTurn({ - sessionId: active.sessionId, - turnId: "turn_int", - status: "completed", - tokens: 99, - retracted: false, - }); + const proposal = await submitGoalProposal(integration, "complete"); - expect(bumpBoundUsage).not.toHaveBeenCalled(); - expect(settleBoundTurn).not.toHaveBeenCalled(); - expect(enqueuePostTurnContinuation).not.toHaveBeenCalled(); - }); + expect(proposal.isError).toBe(true); + expect(proposal.terminate).toBeUndefined(); + expect(JSON.parse(proposal.text)).toMatchObject({ + reason: "not_a_goal_turn", + currentGoal: { + goalId: currentGoal.goalId, + status, + objective: currentGoal.objective, + }, + }); + expect(JSON.parse(proposal.text)).not.toHaveProperty("proposal"); + + const decision = await integration.settleInjectedTurn({ + sessionId: currentGoal.sessionId, + turnId: "turn_int", + status: "completed", + tokens: 99, + retracted: false, + }); + + expect(bumpBoundUsage).not.toHaveBeenCalled(); + expect(settleBoundTurn).not.toHaveBeenCalled(); + expect(enqueuePostTurnContinuation).not.toHaveBeenCalled(); + expect(decision).toEqual({ + stage: 1, + action: "ignored", + reason: "not_a_goal_turn", + }); + expect( + await integration.store.getBySession(currentGoal.sessionId), + ).toEqual(currentGoal); + }, + ); it("never attributes an old binding to a replacement Goal in the same session", async () => { let current = goal({ goalId: "tg_old" }); @@ -567,6 +592,10 @@ describe("LocalThreadGoalIntegration injected v2 Turn settlement", () => { await admitGoalTurn(integration, current, "turn_int"); current = replacement; + const proposal = await submitGoalProposal(integration, "complete"); + expect(proposal.isError).toBe(true); + expect(proposal.terminate).toBe(true); + await integration.settleInjectedTurn({ sessionId: current.sessionId, turnId: "turn_int",