diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 515add4..38e0e11 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -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) } diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 69d373d..70e9b10 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -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) => diff --git a/packages/fold-core/test/Projection/Projection.vi.test.ts b/packages/fold-core/test/Projection/Projection.vi.test.ts index 7cc4343..980595e 100644 --- a/packages/fold-core/test/Projection/Projection.vi.test.ts +++ b/packages/fold-core/test/Projection/Projection.vi.test.ts @@ -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 @@ -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') }), ) diff --git a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts index d7a8e5d..ddc2997 100644 --- a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts @@ -1,9 +1,3 @@ -/** - * 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' @@ -11,30 +5,7 @@ import { shortAgentId, type AgentStartedLogEntry, type AssistantMessageLogEntry 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 = {} - 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. @@ -70,6 +41,7 @@ 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( @@ -77,18 +49,12 @@ it.effect('a fork clones the caller: shared history prefix, no new leading promp ) 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) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index 86e3e42..526a531 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -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, [])