diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 19ba10a..eea14a9 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -10,7 +10,7 @@ * those deltas are ephemeral and never persisted. Model provider failures become durable error + * agent-finished entries, never service failures. */ -import { Array as Arr, Cause, Effect, Exit, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect' +import { Array as Arr, Effect, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect' import { LanguageModel, Prompt, type Response, type Tool, type Toolkit } from 'effect/unstable/ai' import { AgentEvents } from '../AgentEvents/AgentEventsService' @@ -213,27 +213,6 @@ export const liveAgentRuntimeLayer: Layer.Layer< return Prompt.assistantMessage({ content, options: message.options }) }) - /** - * Flush partial assistant text on interrupt (D10): whatever streamed before the interruption is - * appended as an ordinary assistant-message, so resume sees coherent, honest history. Runs from an - * uninterruptible onExit around the model stream; a turn that completed normally never reaches it. - */ - const flushPartialAssistantText = (input: RunAgentInput, partialText: Ref.Ref): Effect.Effect => - Effect.gen(function* () { - const text = yield* Ref.get(partialText) - if (text.length === 0) return - - yield* appendToEventLog({ - _tag: 'assistant-message', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message: encodeAssistantMessage(Prompt.assistantMessage({ content: [Prompt.textPart({ text })] })), - finish: null, - }) - }) - /** * Run one compaction against the agent's current projection (D11): plan through the Compaction * service - the summarization call runs on this runtime's own LanguageModel, so every agent @@ -359,31 +338,20 @@ export const liveAgentRuntimeLayer: Layer.Layer< // enter the durable log. A failing stream fails before any part, so failure turns publish no deltas. // The whole collection runs under the active model request settings, so the provider reads the // projected reasoning configuration when it builds the request (thinking-change binds next turn). - // Text deltas also accumulate outside the interruptible region: if this turn is interrupted - // mid-stream, the uninterruptible onExit below flushes the partial assistant text durably (D10). - const partialText = yield* Ref.make('') const modelParts = yield* Stream.runCollect( languageModel.streamText({ prompt: requestPrompt, toolkit, disableToolCallResolution: true }).pipe( Stream.tap((part) => part.type === 'text-delta' || part.type === 'reasoning-delta' - ? agentEvents - .publish({ - kind: 'delta', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - part: - part.type === 'text-delta' - ? { type: 'text-delta', id: part.id, delta: part.delta } - : { type: 'reasoning-delta', id: part.id, delta: part.delta }, - }) - .pipe( - Effect.andThen( - part.type === 'text-delta' - ? Ref.update(partialText, (text) => text + part.delta) - : Effect.void, - ), - ) + ? agentEvents.publish({ + kind: 'delta', + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + part: + part.type === 'text-delta' + ? { type: 'text-delta', id: part.id, delta: part.delta } + : { type: 'reasoning-delta', id: part.id, delta: part.delta }, + }) : Effect.void, ), ), @@ -392,11 +360,6 @@ export const liveAgentRuntimeLayer: Layer.Layer< model: runtimeState.activeModel, reasoningLevel: runtimeState.reasoningLevel, }), - Effect.onExit((exit) => - Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) - ? flushPartialAssistantText(input, partialText) - : Effect.void, - ), Effect.result, ) diff --git a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts index d82742f..86e1fde 100644 --- a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts +++ b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts @@ -1,27 +1,19 @@ /** - * Slice-2 hard-interrupt tests (D10): `interrupt` cancels the live fiber tree; uninterruptible - * finalizers keep the log honest - the mid-stream partial assistant text flushes as a durable - * assistant-message, the root gets its terminal `agent-finished{interrupted}` marker, and the awaiting - * send resolves with that honest outcome. A TARGETED interrupt of one running subagent folds into its - * dispatcher's tool result as an interrupted-outcome result while the dispatcher keeps running. + * Slice-2 hard-interrupt tests (D10): `interrupt` cancels the live fiber tree, discards unfinished + * assistant output, writes the root `agent-finished{interrupted}` marker, and resolves the awaiting + * send with that honest outcome. A TARGETED interrupt of one running subagent folds into its dispatcher's + * tool result as an interrupted-outcome result while the dispatcher keeps running. */ import { expect, it } from '@effect/vitest' import { Deferred, Effect, Fiber } from 'effect' -import { - defineAgent, - defineSubagent, - shortAgentId, - startSession, - subagentTool, - type AssistantMessageLogEntry, -} from '../../src/index' +import { defineAgent, defineSubagent, shortAgentId, startSession, subagentTool } from '../../src/index' import { makeHangOnceModel } from '../Subagents/DriveHarness' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { claudeActiveModel, gptActiveModel, scriptedModel } from './ApiTestHelpers' import { makePartialHangModel } from './SessionControlHarness' -it.effect('interrupt flushes partial assistant text, writes the root marker, and resumes coherently', () => +it.effect('interrupt discards partial assistant text, writes the root marker, and resumes coherently', () => Effect.gen(function* () { const partialHang = yield* makePartialHangModel(gptActiveModel, 'I was thinking about the answer', [ textTurn('resumed cleanly'), @@ -39,30 +31,22 @@ it.effect('interrupt flushes partial assistant text, writes the root marker, and const entries = yield* session.entries - // D10: the partial assistant text streamed before the interruption is a durable entry... - const flushed = entries.find( - (entry): entry is AssistantMessageLogEntry => - entry._tag === 'assistant-message' && JSON.stringify(entry).includes('I was thinking about the answer'), - ) - if (flushed === undefined) throw new Error('expected the flushed partial assistant-message') - expect(flushed.finish).toBeNull() + expect(JSON.stringify(entries)).not.toContain('I was thinking about the answer') - // ...followed by the root's terminal marker. const rootFinished = entries.findLast((entry) => entry._tag === 'agent-finished') if (rootFinished === undefined || rootFinished._tag !== 'agent-finished') { throw new Error('expected the root terminal marker') } expect(rootFinished.outcome).toBe('interrupted') - expect(entries.indexOf(rootFinished)).toBeGreaterThan(entries.indexOf(flushed)) - // Resume over the same log: the next send completes and its request carries the partial text. + // Resume over the same log without replaying text from the discarded response. const resumed = yield* session.send('pick it back up') expect(resumed.outcome).toBe('completed') expect(resumed.resultText).toBe('resumed cleanly') const prompts = yield* partialHang.prompts const resumedPrompt = JSON.stringify(prompts[1]) - expect(resumedPrompt).toContain('I was thinking about the answer') + expect(resumedPrompt).not.toContain('I was thinking about the answer') expect(resumedPrompt).toContain('pick it back up') }).pipe(Effect.scoped), )