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
3 changes: 1 addition & 2 deletions packages/fold-core/src/Projection/Projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,7 @@ const entriesForAgentInternal = (
(entry) => entry.seq <= fork.atSeq,
)

const inheritedEntries =
fork.history === undefined ? parentEntries : eligibleForkHistory(parentEntries, fork.history)
const inheritedEntries = eligibleForkHistory(parentEntries, fork.history ?? 'all')
return [...inheritedEntries, ...ownEntries].sort(compareSeq)
}

Expand Down
1 change: 1 addition & 0 deletions packages/fold-core/src/Subagents/SubagentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export const subagentTool = (
prompt: forkCommand.prompt,
skill: forkCommand.skill,
forkAgentDefinitionId: options?.forkAgent?.id ?? null,
history: 'all',
})
.pipe(
Effect.catchTag('SkillNotFoundError', (error) =>
Expand Down
8 changes: 4 additions & 4 deletions packages/fold-core/test/Projection/Projection.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ it.effect('projects messages with the latest leading system message and assistan
}),
)

it.effect('projects forked agents through the parent fork sequence plus child entries', () =>
it.effect('projects legacy forks through completed parent history plus child entries', () =>
Effect.gen(function* () {
const result = yield* Effect.gen(function* () {
const log = yield* EventLog
Expand Down Expand Up @@ -338,9 +338,9 @@ it.effect('projects forked agents through the parent fork sequence plus child en

const projected = messagesForAgent(result.entries, result.childAgentId)

expect(projected.map((message) => message._tag)).toEqual(['system-message', 'user-message', 'user-message'])
expect(projected[1]).toMatchObject({ _tag: 'user-message', message: { content: 'parent before fork' } })
expect(projected[2]).toMatchObject({ _tag: 'user-message', message: { content: 'child prompt' } })
expect(projected.map((message) => message._tag)).toEqual(['system-message', 'user-message'])
expect(projected[1]).toMatchObject({ _tag: 'user-message', message: { content: 'child prompt' } })
expect(JSON.stringify(projected)).not.toContain('parent before fork')
}),
)

Expand Down
46 changes: 6 additions & 40 deletions packages/fold-core/test/Subagents/SubagentFork.vi.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,11 @@
/**
* Engine tests for fork mode (D21): the fork clones the caller - model binding, toolset, and, through
* fork-by-reference projection, its full history up to the observed head - with NO new leading system
* message, so the fork's prompt prefix is byte-identical to the caller's (the provider-cache claim,
* asserted for real against the scripted model's recorded prompts).
*/
import { expect, it } from '@effect/vitest'
import { Predicate, Effect } from 'effect'

import { shortAgentId, type AgentStartedLogEntry, type AssistantMessageLogEntry } from '../../src/index'
import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel'
import { makeDriveSession, renderedDriveResult, subagentStartedEntries } from './DriveHarness'

const withoutCacheControl = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(withoutCacheControl)
if (typeof value !== 'object' || value === null) return value

const out: Record<string, unknown> = {}
for (const [key, nested] of Object.entries(value)) {
if (key === 'cacheControl') continue
const normalized = withoutCacheControl(nested)
if (key === 'anthropic' && typeof normalized === 'object' && normalized !== null) {
if (Object.keys(normalized).length === 0) continue
}
out[key] = normalized
}
return out
}

const stablePromptJson = (value: unknown): string =>
JSON.stringify(withoutCacheControl(value), (key, nested) => {
if (key.length === 0 || Array.isArray(nested) || typeof nested !== 'object' || nested === null) return nested

return Object.fromEntries(Object.entries(nested).sort(([left], [right]) => left.localeCompare(right)))
})

it.effect('a fork clones the caller: shared history prefix, no new leading prompt, own rows after', () =>
it.effect('a fork inherits completed context without its invoking tool call', () =>
Effect.gen(function* () {
// The fork clones the ROOT, so it runs on the root's scripted model: turn 1 is the root's drive
// call, turn 2 is consumed by the fork, turn 3 finishes the root.
Expand Down Expand Up @@ -70,25 +41,20 @@ it.effect('a fork clones the caller: shared history prefix, no new leading promp
Predicate.isTagged(entry, 'assistant-message') && entry.agentId === rootStarted.agentId,
)
expect(forkStarted.fork?.atSeq).toBe(dispatchingAssistantRow?.seq)
expect(forkStarted.fork?.history).toBeUndefined()

// No new leading system message for the fork: the fold carries the caller's blocks.
const forkSystemMessages = entries.filter(
(entry) => Predicate.isTagged(entry, 'system-message') && entry.agentId === forkStarted.agentId,
)
expect(forkSystemMessages).toHaveLength(0)

// The cache claim, for real: excluding request-local cache breakpoint metadata, the fork's first
// request begins with the caller's first request, then continues with the caller's tool-call turn
// and the fork prompt.
const prompts = yield* rootScripted.scripted.prompts
const callerRequest = prompts[0]
const forkRequest = prompts[1]
if (callerRequest === undefined || forkRequest === undefined) throw new Error('expected two requests')
const prefix = forkRequest.content.slice(0, callerRequest.content.length)
expect(stablePromptJson(prefix)).toBe(stablePromptJson(callerRequest.content))
expect(JSON.stringify(forkRequest.content.slice(callerRequest.content.length))).toContain(
'continue with everything you know',
)
if (forkRequest === undefined) throw new Error('expected fork request')
expect(JSON.stringify(forkRequest.content)).toContain('continue with everything you know')
expect(JSON.stringify(forkRequest.content)).not.toContain('provider-call-0')
expect(JSON.stringify(forkRequest.content)).not.toContain('go')

// The result renders like any dispatch: resumable id + turns header + body.
const rendered = renderedDriveResult(entries, 0)
Expand Down
36 changes: 36 additions & 0 deletions packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,42 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)

it.effect('the public fork wire persists completed-history selection', () =>
Effect.gen(function* () {
const rootScripted = yield* scriptedModel(gptActiveModel, [
toolCallTurn([
{
id: 'provider-call-1',
name: 'subagent',
params: { description: 'inspect context', prompt: 'inspect the completed context', fork: true },
},
]),
textTurn('fork findings'),
textTurn('root complete'),
])

const session = yield* startSession({
agent: defineAgent({ model: rootScripted.model, systemPrompt: 'root', tools: [subagentTool([])] }),
})

const finished = yield* session.send('parent request')
expect(finished.outcome).toBe('completed')

const started = subagentStartedEntries(yield* session.entries)[0]
if (started === undefined) throw new Error('expected the fork to have started')
expect(started.fork?.history).toBe('all')

const prompts = yield* rootScripted.scripted.prompts
const forkRequest = prompts[1]
if (forkRequest === undefined) throw new Error('expected the fork request')
const forkRequestJson = JSON.stringify(forkRequest.content)
expect(forkRequestJson).toContain('inspect the completed context')
expect(forkRequestJson).not.toContain('provider-call-1')
expect(forkRequestJson).not.toContain('parent request')
expect(yield* rootScripted.scripted.remainingTurns).toBe(0)
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)

it.effect('malformed wire commands come back as instructive tool failures the model can correct from', () =>
Effect.gen(function* () {
const researcherScripted = yield* scriptedModel(claudeActiveModel, [])
Expand Down
Loading