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
27 changes: 21 additions & 6 deletions packages/fold-agent/src/EventLog/JsonlLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
EventLogUnavailableError,
Ids,
LogEntry as LogEntrySchema,
decodeStoredLogEntry,
layerLiveIdFactory,
makeStoredLogEntry,
type EventLogError,
type EventLogService,
type EventLogUnsupportedVersionError,
type LogEntry,
type LogEntryInput,
type LogSeq,
Expand Down Expand Up @@ -58,7 +60,10 @@ const jsonlLines = (contents: string): ReadonlyArray<string> => {
return contents.split('\n')
}

const decodeJsonlLine = (line: string, lineNumber: number): Effect.Effect<LogEntry, EventLogCorruptEntryError> =>
const decodeJsonlLine = (
line: string,
lineNumber: number,
): Effect.Effect<LogEntry, EventLogCorruptEntryError | EventLogUnsupportedVersionError> =>
Effect.gen(function* () {
if (line.length === 0) {
return yield* corruptEntryError(lineNumber, `Empty JSONL line at line ${lineNumber}`)
Expand All @@ -68,9 +73,14 @@ const decodeJsonlLine = (line: string, lineNumber: number): Effect.Effect<LogEnt
try: (): unknown => JSON.parse(line),
catch: (cause) => corruptEntryError(lineNumber, `Invalid JSON at line ${lineNumber}`, cause),
})
const entry = yield* Schema.decodeUnknownEffect(LogEntrySchema)(parsed).pipe(
Effect.mapError((cause) =>
corruptEntryError(lineNumber, `Invalid EventLog entry at line ${lineNumber}`, cause),
const entry = yield* decodeStoredLogEntry(parsed).pipe(
Effect.catchTag('EventLogCorruptEntryError', (error) =>
corruptEntryError(
lineNumber,
`Invalid EventLog entry at line ${lineNumber}`,
error.cause ?? error,
error.seq,
),
),
)
const expectedSeq = lineNumber - 1
Expand All @@ -87,7 +97,9 @@ const decodeJsonlLine = (line: string, lineNumber: number): Effect.Effect<LogEnt
return entry
})

const decodeJsonl = (contents: string): Effect.Effect<ReadonlyArray<LogEntry>, EventLogCorruptEntryError> =>
const decodeJsonl = (
contents: string,
): Effect.Effect<ReadonlyArray<LogEntry>, EventLogCorruptEntryError | EventLogUnsupportedVersionError> =>
Effect.forEach(jsonlLines(contents), (line, index) => decodeJsonlLine(line, index + 1), { concurrency: 1 })

const encodeJsonlLine = (entry: LogEntry): Effect.Effect<string, EventLogInvalidEntryError> =>
Expand All @@ -105,7 +117,10 @@ const encodeJsonlLine = (entry: LogEntry): Effect.Effect<string, EventLogInvalid
const loadEntries = (
fs: FileSystem.FileSystem,
filePath: string,
): Effect.Effect<ReadonlyArray<LogEntry>, EventLogCorruptEntryError | EventLogUnavailableError> =>
): Effect.Effect<
ReadonlyArray<LogEntry>,
EventLogCorruptEntryError | EventLogUnsupportedVersionError | EventLogUnavailableError
> =>
Effect.gen(function* () {
yield* fs
.makeDirectory(dirname(filePath), { recursive: true })
Expand Down
33 changes: 32 additions & 1 deletion packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
EventId,
EventLog,
EventLogCorruptEntryError,
EventLogUnsupportedVersionError,
MessageId,
SessionId,
StateId,
Expand All @@ -21,7 +22,6 @@ const makeSessionStarted = (cwd: string): LogEntryInput => ({
agentId: null,
parentAgentId: null,
toolCallId: null,
version: 1,
cwd,
sessionId: SessionId.create(),
rootAgentId: AgentId.create(),
Expand Down Expand Up @@ -174,6 +174,37 @@ it.effect('jsonl layer replays assistant usage when cache fields are absent', ()
expect(entry.finish?.usage.inputTokens?.cacheWrite).toBeUndefined()
expect(entry.finish?.usage.inputTokens?.cacheRead).toBe(0)
expect(entry.finish?.usage.outputTokens?.total).toBe(2)
expect(entry.version).toBe(1)
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)

it.effect('jsonl layer rejects event formats newer than the installed Fold runtime', () =>
Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped({ prefix: 'fold-event-log-' })
const filePath = join(dir, 'future-version.jsonl')
const line = JSON.stringify({
...makeSessionStarted('/tmp/future-version'),
seq: 0,
eventId: EventId.create(),
ts: 1,
version: 2,
})

yield* fs.writeFileString(filePath, `${line}\n`)

const error = yield* Effect.gen(function* () {
const log = yield* EventLog
return yield* Stream.runCollect(log.entries())
}).pipe(Effect.provide(layerJsonl(filePath)), Effect.flip)

expect(error).toBeInstanceOf(EventLogUnsupportedVersionError)
if (error instanceof EventLogUnsupportedVersionError) {
expect(error.version).toBe(2)
expect(error.supportedVersions).toEqual([1])
}
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
Expand Down
3 changes: 3 additions & 0 deletions packages/fold-agent/test/Session/TitleGenerator.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const entries: ReadonlyArray<LogEntry> = [
seq: 1,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: root,
parentAgentId: null,
toolCallId: null,
Expand All @@ -28,6 +29,7 @@ const entries: ReadonlyArray<LogEntry> = [
seq: 2,
eventId: EventId.create(),
ts: 2,
version: 1,
agentId: root,
parentAgentId: null,
toolCallId: null,
Expand All @@ -40,6 +42,7 @@ const entries: ReadonlyArray<LogEntry> = [
seq: 3,
eventId: EventId.create(),
ts: 3,
version: 1,
agentId: AgentId.make('agent_bbbbbbbbbbbbbbbbbbbbbbbb'),
parentAgentId: null,
toolCallId: null,
Expand Down
1 change: 1 addition & 0 deletions packages/fold-cli/test/PromptRenderer.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const finished = (resultText: string | null): AgentFinishedLogEntry => ({
seq: 3,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'),
parentAgentId: null,
toolCallId: null,
Expand Down
11 changes: 11 additions & 0 deletions packages/fold-cli/test/Renderer.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ it.effect('renders the session id in the header and finish line', () =>
seq: 2,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -60,6 +61,7 @@ it.effect('renders the session id in the header and finish line', () =>
seq: 3,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId,
parentAgentId: null,
toolCallId: null,
Expand Down Expand Up @@ -101,6 +103,7 @@ it.effect('json renderer emits only log rows in concise mode and finish is not d
seq: 7,
eventId: EventId.create(),
ts: 1,
version: 1 as const,
agentId,
parentAgentId: null,
toolCallId: null,
Expand Down Expand Up @@ -192,6 +195,7 @@ it.effect('renders profile-based resume command when the session used --profile'
seq: 1,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId,
parentAgentId: null,
toolCallId: null,
Expand Down Expand Up @@ -271,6 +275,7 @@ it.effect('with a catalog entry the usage table shows a real cost and the catalo
seq: 2,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -290,6 +295,7 @@ it.effect('with a catalog entry the usage table shows a real cost and the catalo
seq: 3,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId,
parentAgentId: null,
toolCallId: null,
Expand Down Expand Up @@ -346,6 +352,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved
seq: 1,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: rootId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -364,6 +371,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved
seq: 2,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: subId,
parentAgentId: rootId,
toolCallId,
Expand Down Expand Up @@ -399,6 +407,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved
seq: 3,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: subId,
parentAgentId: rootId,
toolCallId,
Expand All @@ -424,6 +433,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved
seq: 4,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: subId,
parentAgentId: rootId,
toolCallId,
Expand All @@ -439,6 +449,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved
seq: 5,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: subId,
parentAgentId: rootId,
toolCallId,
Expand Down
2 changes: 2 additions & 0 deletions packages/fold-cli/test/TuiSubagents.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const startedEntry = (agentId: string, seq: number, ts: number): AgentStartedLog
seq,
eventId: EventId.create(),
ts,
version: 1,
agentId: AgentId.make(agentId),
parentAgentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'),
toolCallId: null,
Expand Down Expand Up @@ -66,6 +67,7 @@ describe('skillViews', () => {
seq: 1,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId,
parentAgentId: null,
toolCallId: null,
Expand Down
9 changes: 9 additions & 0 deletions packages/fold-cli/test/fixtures/TuiAppFixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const overflowSubagentEntries: ReadonlyArray<LogEntry> =
seq: 100 + index * 2,
eventId: EventId.create(),
ts: 100 + index * 2,
version: 1,
agentId: rootAgentId,
parentAgentId: null,
toolCallId: null,
Expand Down Expand Up @@ -75,6 +76,7 @@ const overflowSubagentEntries: ReadonlyArray<LogEntry> =
seq: 101 + index * 2,
eventId: EventId.create(),
ts: 101 + index * 2,
version: 1,
agentId,
parentAgentId: rootAgentId,
toolCallId,
Expand All @@ -94,6 +96,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 0,
eventId: EventId.create(),
ts: 0,
version: 1,
agentId: rootAgentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -109,6 +112,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 2,
eventId: EventId.create(),
ts: 2,
version: 1,
agentId: rootAgentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -129,6 +133,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 6,
eventId: EventId.create(),
ts: 6,
version: 1,
agentId: rootAgentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -154,6 +159,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 3,
eventId: EventId.create(),
ts: 3,
version: 1,
agentId: researcherAgentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -172,6 +178,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 4,
eventId: EventId.create(),
ts: 4,
version: 1,
agentId: researcherAgentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -197,6 +204,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 5,
eventId: EventId.create(),
ts: 5,
version: 1,
agentId: researcherAgentId,
parentAgentId: null,
toolCallId: null,
Expand All @@ -211,6 +219,7 @@ const subagentEntries: ReadonlyArray<LogEntry> = [
seq: 1,
eventId: EventId.create(),
ts: 1,
version: 1,
agentId: researcherAgentId,
parentAgentId: rootAgentId,
toolCallId: subagentToolCallId,
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-cli/test/tui/SessionState.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaa
const childAgentId = Schema.decodeUnknownSync(AgentId)('agent_bbbbbbbbbbbbbbbbbbbbbbbb')

const entry = (input: Record<string, unknown>) =>
Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create() })
Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create(), version: 1 })
const assistant = (seq: number, agentId = rootAgentId) =>
entry({
_tag: 'assistant-message',
Expand Down
18 changes: 17 additions & 1 deletion packages/fold-core/src/EventLog/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,21 @@ export class EventLogCorruptEntryError extends Schema.TaggedError<EventLogCorrup
},
) {}

/** The persisted entry uses a format newer than this Fold runtime can safely interpret. */
export class EventLogUnsupportedVersionError extends Schema.TaggedError<EventLogUnsupportedVersionError>()(
'EventLogUnsupportedVersionError',
{
operation: EventLogOperation,
message: Schema.String,
version: Schema.Int,
seq: Schema.optional(Schema.Int),
supportedVersions: Schema.Array(Schema.Int),
},
) {}

/** Public EventLog error union. */
export type EventLogError = EventLogInvalidEntryError | EventLogUnavailableError | EventLogCorruptEntryError
export type EventLogError =
| EventLogInvalidEntryError
| EventLogUnavailableError
| EventLogCorruptEntryError
| EventLogUnsupportedVersionError
5 changes: 3 additions & 2 deletions packages/fold-core/src/EventLog/LogEntryFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Clock, Effect, Schema } from 'effect'

import type { IdsService } from '../Ids'
import { EventLogInvalidEntryError } from './Errors'
import { LogEntry, LogEntryInput, type LogSeq } from './Schemas'
import { CURRENT_LOG_ENTRY_VERSION, LogEntry, LogEntryInput, type LogSeq } from './Schemas'

const invalidEntryError = (message: string, cause: unknown) =>
new EventLogInvalidEntryError({
Expand All @@ -11,7 +11,7 @@ const invalidEntryError = (message: string, cause: unknown) =>
cause,
})

/** Validate append input and assign the canonical EventLog sequence, event ID, and timestamp. */
/** Validate append input and assign the canonical event envelope. */
export const makeStoredLogEntry = (
input: LogEntryInput,
seq: LogSeq,
Expand All @@ -29,5 +29,6 @@ export const makeStoredLogEntry = (
seq,
eventId,
ts,
version: CURRENT_LOG_ENTRY_VERSION,
}).pipe(Effect.mapError((cause) => invalidEntryError('Invalid stored EventLog entry', cause)))
})
Loading
Loading