Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 11 additions & 48 deletions packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string>): Effect.Effect<void> =>
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
Expand Down Expand Up @@ -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,
),
),
Expand All @@ -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,
)

Expand Down
34 changes: 9 additions & 25 deletions packages/fold-core/test/Api/SessionInterrupt.vi.test.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand All @@ -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),
)
Expand Down
Loading