experimental-a2 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/dist/{actor-DJi3RsNu.d.ts → actor-BfQSE0KC.d.ts} +4 -4
- package/dist/{actor-DJi3RsNu.d.ts.map → actor-BfQSE0KC.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +2 -2
- package/dist/{actor-shared-DI7J5upy.js → actor-shared-B5tJfzt-.js} +2 -2
- package/dist/{actor-shared-DI7J5upy.js.map → actor-shared-B5tJfzt-.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +3 -3
- package/dist/ai-Cai-lCbj.d.ts +580 -0
- package/dist/ai-Cai-lCbj.d.ts.map +1 -0
- package/dist/ai-control-CcD4hh3y.js +119 -0
- package/dist/ai-control-CcD4hh3y.js.map +1 -0
- package/dist/ai-server.d.ts +16 -8
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +1322 -475
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -334
- package/dist/ai.js +838 -85
- package/dist/ai.js.map +1 -1
- package/dist/{client-P_NNNRM-.d.ts → client-BAEABRZB.d.ts} +2 -2
- package/dist/{client-P_NNNRM-.d.ts.map → client-BAEABRZB.d.ts.map} +1 -1
- package/dist/{client-Bf6uSEAk.js → client-BYzHjkwU.js} +21 -6
- package/dist/client-BYzHjkwU.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/{contract-48bUMgcL.js → contract-CKRg_E4q.js} +3 -26
- package/dist/contract-CKRg_E4q.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{reducer-DJKWm3cp.d.ts → reducer-BcS9VDKC.d.ts} +4 -1
- package/dist/{reducer-DJKWm3cp.d.ts.map → reducer-BcS9VDKC.d.ts.map} +1 -1
- package/dist/reducer-DEMjEY_O.js +29 -0
- package/dist/reducer-DEMjEY_O.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-DjZZa1wr.d.ts → server-Bp5Nd1pF.d.ts} +3 -3
- package/dist/{server-DjZZa1wr.d.ts.map → server-Bp5Nd1pF.d.ts.map} +1 -1
- package/dist/{server-BeNADlCI.js → server-CjJSGcF7.js} +4 -3
- package/dist/server-CjJSGcF7.js.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-DtDOWLSn.d.ts → store-D_yhNdPz.d.ts} +7 -2
- package/dist/{store-DtDOWLSn.d.ts.map → store-D_yhNdPz.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +19 -0
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +1 -1
- package/dist/{store-redis-notify-BUCyXOn0.js → store-redis-notify-D2EI6gwX.js} +27 -2
- package/dist/store-redis-notify-D2EI6gwX.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/docs/guides/06-ai-agents.mdx +361 -76
- package/docs/reference/01-api.mdx +159 -27
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +145 -33
- package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +14 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +38 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +294 -0
- package/examples/playground/app/agent/compaction-settings.test.ts +144 -0
- package/examples/playground/app/agent/compaction-settings.ts +49 -0
- package/examples/playground/app/agent/compaction-timeline.test.ts +337 -0
- package/examples/playground/app/agent/compaction-timeline.ts +198 -0
- package/examples/playground/app/agent/model.ts +56 -1
- package/examples/playground/app/agent/server.ts +9 -2
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
- package/examples/playground/app/chat/model.ts +2 -2
- package/examples/playground/app/chat/server.ts +24 -17
- package/examples/playground/app/globals.css +333 -0
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/ai-client-state.ts +185 -0
- package/src/ai-control-server.ts +829 -0
- package/src/ai-control-state.ts +152 -0
- package/src/ai-control.ts +139 -0
- package/src/ai-coordinator.ts +99 -32
- package/src/ai-model-metadata.ts +108 -0
- package/src/ai-progress-batches.ts +68 -0
- package/src/ai-projector.ts +76 -15
- package/src/ai-sdk-step.ts +5 -2
- package/src/ai-server.ts +920 -638
- package/src/ai.ts +650 -110
- package/src/client.ts +31 -9
- package/src/licenses/Apache-2.0.txt +55 -0
- package/src/parse-partial-json.ts +441 -0
- package/src/reducer.ts +6 -0
- package/src/server.ts +8 -4
- package/src/store-postgres.ts +27 -0
- package/src/store-redis-core.ts +53 -1
- package/src/store-redis-notify.ts +1 -0
- package/src/store.ts +6 -0
- package/dist/ai.d.ts.map +0 -1
- package/dist/client-Bf6uSEAk.js.map +0 -1
- package/dist/contract-48bUMgcL.js.map +0 -1
- package/dist/server-BeNADlCI.js.map +0 -1
- package/dist/store-redis-notify-BUCyXOn0.js.map +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { expect, it } from 'vitest'
|
|
2
|
+
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
3
|
+
import { memory } from 'experimental-a2/store-memory'
|
|
4
|
+
import {
|
|
5
|
+
compactionOptions,
|
|
6
|
+
compactionSettings,
|
|
7
|
+
demoAgent,
|
|
8
|
+
latestCompactionSettings,
|
|
9
|
+
} from './model'
|
|
10
|
+
import { updateCompactionSettings } from './compaction-settings'
|
|
11
|
+
|
|
12
|
+
const request = (
|
|
13
|
+
settings: unknown,
|
|
14
|
+
options: { origin?: string; contentType?: string; id?: string } = {},
|
|
15
|
+
): Request =>
|
|
16
|
+
new Request('https://example.test/agent/session/compaction', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
origin: options.origin ?? 'https://example.test',
|
|
20
|
+
'content-type': options.contentType ?? 'application/json',
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({ id: options.id ?? crypto.randomUUID(), settings }),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('uses the request Host when Next normalizes the internal URL', async () => {
|
|
26
|
+
let writes = 0
|
|
27
|
+
const response = await updateCompactionSettings({
|
|
28
|
+
request: new Request('http://localhost:3107/agent/s/compaction', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
host: '127.0.0.1:3107',
|
|
32
|
+
origin: 'http://127.0.0.1:3107',
|
|
33
|
+
'content-type': 'application/json',
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
id: crypto.randomUUID(),
|
|
37
|
+
settings: { mode: 'manual', thresholdTokens: 1 },
|
|
38
|
+
}),
|
|
39
|
+
}),
|
|
40
|
+
sessionId: 's',
|
|
41
|
+
append: async () => {
|
|
42
|
+
writes += 1
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
expect(response.status).toBe(200)
|
|
46
|
+
expect(writes).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('saves session settings durably and idempotently without starting a turn', async () => {
|
|
50
|
+
let generations = 0
|
|
51
|
+
const server = createAgentServer({
|
|
52
|
+
agent: demoAgent,
|
|
53
|
+
store: memory(),
|
|
54
|
+
model: 'test/model',
|
|
55
|
+
compaction: async ({ session }) =>
|
|
56
|
+
compactionOptions((await session.state(compactionSettings)).state),
|
|
57
|
+
generate: () => {
|
|
58
|
+
generations += 1
|
|
59
|
+
return new ReadableStream({
|
|
60
|
+
start(controller) {
|
|
61
|
+
controller.close()
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
const session = server.session('settings-test')
|
|
67
|
+
const id = crypto.randomUUID()
|
|
68
|
+
const save = () =>
|
|
69
|
+
updateCompactionSettings({
|
|
70
|
+
request: request({ mode: 'manual', thresholdTokens: 1 }, { id }),
|
|
71
|
+
sessionId: session.id,
|
|
72
|
+
append: (event) => session.append(event),
|
|
73
|
+
})
|
|
74
|
+
expect((await save()).status).toBe(200)
|
|
75
|
+
expect((await save()).status).toBe(200)
|
|
76
|
+
await server.drain(session.id)
|
|
77
|
+
const history = await session.history()
|
|
78
|
+
expect(history.map((event) => event.type)).toEqual([
|
|
79
|
+
'compaction.settings.updated',
|
|
80
|
+
])
|
|
81
|
+
expect(latestCompactionSettings(history).settings).toEqual({
|
|
82
|
+
mode: 'manual',
|
|
83
|
+
thresholdTokens: 1,
|
|
84
|
+
})
|
|
85
|
+
expect(
|
|
86
|
+
compactionOptions((await session.state(compactionSettings)).state),
|
|
87
|
+
).toEqual({ thresholdTokens: 1 })
|
|
88
|
+
expect(
|
|
89
|
+
compactionOptions(
|
|
90
|
+
(await server.session('other').state(compactionSettings)).state,
|
|
91
|
+
),
|
|
92
|
+
).toEqual({})
|
|
93
|
+
expect(generations).toBe(0)
|
|
94
|
+
|
|
95
|
+
await updateCompactionSettings({
|
|
96
|
+
request: request({ mode: 'disabled' }),
|
|
97
|
+
sessionId: session.id,
|
|
98
|
+
append: (event) => session.append(event),
|
|
99
|
+
})
|
|
100
|
+
expect(
|
|
101
|
+
compactionOptions((await session.state(compactionSettings)).state),
|
|
102
|
+
).toBe(false)
|
|
103
|
+
await updateCompactionSettings({
|
|
104
|
+
request: request({ mode: 'automatic' }),
|
|
105
|
+
sessionId: session.id,
|
|
106
|
+
append: (event) => session.append(event),
|
|
107
|
+
})
|
|
108
|
+
expect(
|
|
109
|
+
compactionOptions((await session.state(compactionSettings)).state),
|
|
110
|
+
).toEqual({})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1', null])(
|
|
114
|
+
'rejects invalid threshold %s',
|
|
115
|
+
async (thresholdTokens) => {
|
|
116
|
+
let writes = 0
|
|
117
|
+
const response = await updateCompactionSettings({
|
|
118
|
+
request: request({ mode: 'manual', thresholdTokens }),
|
|
119
|
+
sessionId: 's',
|
|
120
|
+
append: async () => {
|
|
121
|
+
writes += 1
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
expect(response.status).toBe(400)
|
|
125
|
+
expect(writes).toBe(0)
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
it.each([
|
|
130
|
+
{ origin: 'https://other.test', status: 403 },
|
|
131
|
+
{ contentType: 'text/plain', status: 415 },
|
|
132
|
+
{ id: 'not-an-id', status: 400 },
|
|
133
|
+
])('rejects invalid request boundaries %j', async ({ status, ...options }) => {
|
|
134
|
+
let writes = 0
|
|
135
|
+
const response = await updateCompactionSettings({
|
|
136
|
+
request: request({ mode: 'automatic' }, options),
|
|
137
|
+
sessionId: 's',
|
|
138
|
+
append: async () => {
|
|
139
|
+
writes += 1
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
expect(response.status).toBe(status)
|
|
143
|
+
expect(writes).toBe(0)
|
|
144
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { compactionSettingsSchema, type CompactionSettings } from './model'
|
|
3
|
+
|
|
4
|
+
const updateSchema = z.strictObject({
|
|
5
|
+
id: z.uuid(),
|
|
6
|
+
settings: compactionSettingsSchema,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export async function updateCompactionSettings(options: {
|
|
10
|
+
request: Request
|
|
11
|
+
sessionId: string
|
|
12
|
+
append: (event: {
|
|
13
|
+
id: string
|
|
14
|
+
type: 'compaction.settings.updated'
|
|
15
|
+
payload: CompactionSettings
|
|
16
|
+
}) => Promise<unknown>
|
|
17
|
+
}): Promise<Response> {
|
|
18
|
+
const { request, sessionId } = options
|
|
19
|
+
const origin = request.headers.get('origin')
|
|
20
|
+
const requestUrl = new URL(request.url)
|
|
21
|
+
requestUrl.host = request.headers.get('host') ?? requestUrl.host
|
|
22
|
+
if (origin !== null && origin !== requestUrl.origin) {
|
|
23
|
+
return Response.json(
|
|
24
|
+
{ error: 'Use this page to update compaction settings.' },
|
|
25
|
+
{ status: 403 },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
request.headers.get('content-type')?.split(';')[0]?.trim() !==
|
|
30
|
+
'application/json'
|
|
31
|
+
) {
|
|
32
|
+
return Response.json({ error: 'Expected JSON settings.' }, { status: 415 })
|
|
33
|
+
}
|
|
34
|
+
if (sessionId.length === 0)
|
|
35
|
+
return Response.json({ error: 'Session is required.' }, { status: 400 })
|
|
36
|
+
const parsed = updateSchema.safeParse(await request.json().catch(() => null))
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
return Response.json(
|
|
39
|
+
{ error: 'Choose a mode and a positive whole-number threshold.' },
|
|
40
|
+
{ status: 400 },
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
await options.append({
|
|
44
|
+
id: `agent.compaction.settings:${encodeURIComponent(sessionId)}:${parsed.data.id}`,
|
|
45
|
+
type: 'compaction.settings.updated',
|
|
46
|
+
payload: parsed.data.settings,
|
|
47
|
+
})
|
|
48
|
+
return Response.json({ settings: parsed.data.settings })
|
|
49
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { UIMessageChunk } from 'ai'
|
|
2
|
+
import { reduceAIState } from 'experimental-a2/ai'
|
|
3
|
+
import { expect, it } from 'vitest'
|
|
4
|
+
import { compactionTimeline, type TimelineEntry } from './compaction-timeline'
|
|
5
|
+
import { demoAgent, type AgentEvent } from './model'
|
|
6
|
+
|
|
7
|
+
type EventInput = {
|
|
8
|
+
[K in AgentEvent['type']]: Pick<
|
|
9
|
+
Extract<AgentEvent, { type: K }>,
|
|
10
|
+
'type' | 'payload'
|
|
11
|
+
> & { id?: string }
|
|
12
|
+
}[AgentEvent['type']]
|
|
13
|
+
type Generation = Extract<
|
|
14
|
+
AgentEvent,
|
|
15
|
+
{ type: 'ai.generation.started' }
|
|
16
|
+
>['payload']
|
|
17
|
+
|
|
18
|
+
function transcript() {
|
|
19
|
+
const events: AgentEvent[] = []
|
|
20
|
+
const append = (input: EventInput): void => {
|
|
21
|
+
events.push({
|
|
22
|
+
...input,
|
|
23
|
+
id: input.id ?? `event:${events.length + 1}`,
|
|
24
|
+
index: events.length + 1,
|
|
25
|
+
sessionId: 'timeline',
|
|
26
|
+
createdAt: new Date('2026-09-14T12:00:00Z'),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
const user = (id: string): void =>
|
|
30
|
+
append({
|
|
31
|
+
type: 'ai.message.created',
|
|
32
|
+
payload: {
|
|
33
|
+
message: { id, role: 'user', parts: [{ type: 'text', text: id }] },
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
const start = ({
|
|
37
|
+
requestId = 'request',
|
|
38
|
+
reason = 'message',
|
|
39
|
+
attempt = 1,
|
|
40
|
+
}: {
|
|
41
|
+
requestId?: string
|
|
42
|
+
reason?: 'message' | 'tool' | 'retry'
|
|
43
|
+
attempt?: number
|
|
44
|
+
} = {}): Generation => {
|
|
45
|
+
const generation = {
|
|
46
|
+
requestId,
|
|
47
|
+
messageId: reason === 'tool' ? 'assistant' : 'user',
|
|
48
|
+
responseMessageId: 'assistant',
|
|
49
|
+
generationId: `${requestId}:generation:${attempt}`,
|
|
50
|
+
attempt,
|
|
51
|
+
model: 'test/model',
|
|
52
|
+
}
|
|
53
|
+
if (attempt === 1)
|
|
54
|
+
append({
|
|
55
|
+
id: requestId,
|
|
56
|
+
type: 'ai.generation.requested',
|
|
57
|
+
payload: {
|
|
58
|
+
messageId: generation.messageId,
|
|
59
|
+
responseMessageId: 'assistant',
|
|
60
|
+
reason,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
append({ type: 'ai.generation.started', payload: generation })
|
|
64
|
+
return generation
|
|
65
|
+
}
|
|
66
|
+
const progress = (generation: Generation, chunks: UIMessageChunk[]): void =>
|
|
67
|
+
append({
|
|
68
|
+
type: 'ai.generation.progress',
|
|
69
|
+
payload: { ...generation, sequence: events.length, chunks },
|
|
70
|
+
})
|
|
71
|
+
const text = (generation: Generation, value: string, step = true): void =>
|
|
72
|
+
progress(generation, [
|
|
73
|
+
{ type: 'start', messageId: generation.responseMessageId },
|
|
74
|
+
...(step ? [{ type: 'start-step' as const }] : []),
|
|
75
|
+
{ type: 'text-start', id: generation.generationId },
|
|
76
|
+
{ type: 'text-delta', id: generation.generationId, delta: value },
|
|
77
|
+
{ type: 'text-end', id: generation.generationId },
|
|
78
|
+
])
|
|
79
|
+
const compact = (generation: Generation, completed = true): void => {
|
|
80
|
+
const payload = {
|
|
81
|
+
generationId: generation.generationId,
|
|
82
|
+
throughMessageId: generation.messageId,
|
|
83
|
+
throughIndex: events.length,
|
|
84
|
+
}
|
|
85
|
+
append({ type: 'ai.compaction.requested', payload })
|
|
86
|
+
if (completed)
|
|
87
|
+
append({
|
|
88
|
+
type: 'ai.compaction.completed',
|
|
89
|
+
payload: {
|
|
90
|
+
...payload,
|
|
91
|
+
messages: [],
|
|
92
|
+
summary: `Summary ${generation.generationId}`,
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
const state = () =>
|
|
97
|
+
events.reduce(reduceAIState, demoAgent.reducer.initialState)
|
|
98
|
+
const timeline = () => compactionTimeline({ state: state(), events })
|
|
99
|
+
return {
|
|
100
|
+
events,
|
|
101
|
+
append,
|
|
102
|
+
user,
|
|
103
|
+
start,
|
|
104
|
+
progress,
|
|
105
|
+
text,
|
|
106
|
+
compact,
|
|
107
|
+
state,
|
|
108
|
+
timeline,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const labels = (entries: TimelineEntry[]): string[] =>
|
|
113
|
+
entries.map((entry) =>
|
|
114
|
+
entry.type === 'compaction'
|
|
115
|
+
? entry.id
|
|
116
|
+
: entry.message.parts
|
|
117
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
118
|
+
.join(''),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
it('keeps a stable marker from running to completed, including after replay', () => {
|
|
122
|
+
const log = transcript()
|
|
123
|
+
log.user('user')
|
|
124
|
+
const generation = log.start()
|
|
125
|
+
log.compact(generation, false)
|
|
126
|
+
const running = log.timeline().find((entry) => entry.type === 'compaction')
|
|
127
|
+
expect(running?.running).toBe(true)
|
|
128
|
+
expect(labels(log.timeline())).toEqual([
|
|
129
|
+
'user',
|
|
130
|
+
`compaction:${generation.generationId}`,
|
|
131
|
+
])
|
|
132
|
+
log.append({
|
|
133
|
+
type: 'ai.compaction.completed',
|
|
134
|
+
payload: {
|
|
135
|
+
generationId: generation.generationId,
|
|
136
|
+
throughMessageId: 'user',
|
|
137
|
+
messages: [],
|
|
138
|
+
summary: 'Summary',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
log.text(generation, 'Answer')
|
|
142
|
+
log.append({ type: 'ai.generation.completed', payload: generation })
|
|
143
|
+
log.append({
|
|
144
|
+
type: 'ai.message.completed',
|
|
145
|
+
payload: { messageId: 'assistant' },
|
|
146
|
+
})
|
|
147
|
+
const completed = log.timeline().find((entry) => entry.type === 'compaction')
|
|
148
|
+
expect(completed).toMatchObject({
|
|
149
|
+
id: running?.id,
|
|
150
|
+
running: false,
|
|
151
|
+
event: { type: 'ai.compaction.completed' },
|
|
152
|
+
})
|
|
153
|
+
expect(labels(log.timeline())).toEqual([
|
|
154
|
+
'user',
|
|
155
|
+
`compaction:${generation.generationId}`,
|
|
156
|
+
'Answer',
|
|
157
|
+
])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('places compaction between tool steps without moving a queued user into the response', () => {
|
|
161
|
+
const log = transcript()
|
|
162
|
+
log.user('user')
|
|
163
|
+
const first = log.start()
|
|
164
|
+
log.text(first, 'Before tool')
|
|
165
|
+
log.append({
|
|
166
|
+
type: 'ai.generation.completed',
|
|
167
|
+
payload: { ...first, finishReason: 'tool-calls' },
|
|
168
|
+
})
|
|
169
|
+
log.user('queued')
|
|
170
|
+
const second = log.start({
|
|
171
|
+
requestId: `ai.generate:tools:${first.generationId}`,
|
|
172
|
+
reason: 'tool',
|
|
173
|
+
})
|
|
174
|
+
log.compact(second, false)
|
|
175
|
+
expect(log.state().compaction?.status).toBe('running')
|
|
176
|
+
expect(labels(log.timeline())).toEqual([
|
|
177
|
+
'user',
|
|
178
|
+
'Before tool',
|
|
179
|
+
`compaction:${second.generationId}`,
|
|
180
|
+
'queued',
|
|
181
|
+
])
|
|
182
|
+
log.append({
|
|
183
|
+
type: 'ai.compaction.completed',
|
|
184
|
+
payload: {
|
|
185
|
+
generationId: second.generationId,
|
|
186
|
+
throughMessageId: 'assistant',
|
|
187
|
+
messages: [],
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
log.text(second, 'After tool')
|
|
191
|
+
expect(labels(log.timeline())).toEqual([
|
|
192
|
+
'user',
|
|
193
|
+
'Before tool',
|
|
194
|
+
`compaction:${second.generationId}`,
|
|
195
|
+
'After tool',
|
|
196
|
+
'queued',
|
|
197
|
+
])
|
|
198
|
+
expect(
|
|
199
|
+
log
|
|
200
|
+
.timeline()
|
|
201
|
+
.flatMap((entry) =>
|
|
202
|
+
entry.type === 'message' && entry.message.id === 'assistant'
|
|
203
|
+
? entry.message.parts
|
|
204
|
+
: [],
|
|
205
|
+
),
|
|
206
|
+
).toEqual(
|
|
207
|
+
log.state().messages.find((message) => message.id === 'assistant')?.parts,
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('preserves compaction history but drops superseded attempt content', () => {
|
|
212
|
+
const log = transcript()
|
|
213
|
+
log.user('user')
|
|
214
|
+
const first = log.start()
|
|
215
|
+
log.compact(first)
|
|
216
|
+
log.text(first, 'Superseded')
|
|
217
|
+
const second = log.start({ attempt: 2 })
|
|
218
|
+
log.compact(second)
|
|
219
|
+
log.text(second, 'Replacement')
|
|
220
|
+
expect(log.state().terminalGenerations?.[first.generationId]).toBe(
|
|
221
|
+
'superseded',
|
|
222
|
+
)
|
|
223
|
+
expect(labels(log.timeline())).toEqual([
|
|
224
|
+
'user',
|
|
225
|
+
`compaction:${first.generationId}`,
|
|
226
|
+
`compaction:${second.generationId}`,
|
|
227
|
+
'Replacement',
|
|
228
|
+
])
|
|
229
|
+
expect(
|
|
230
|
+
log
|
|
231
|
+
.timeline()
|
|
232
|
+
.find((entry) => entry.id === `compaction:${first.generationId}`),
|
|
233
|
+
).toMatchObject({ outcome: 'superseded', running: false })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('keeps old checkpoints before an explicit retry without restoring discarded parts', () => {
|
|
237
|
+
const log = transcript()
|
|
238
|
+
log.user('user')
|
|
239
|
+
const first = log.start()
|
|
240
|
+
log.compact(first)
|
|
241
|
+
log.text(first, 'Failed response')
|
|
242
|
+
log.append({
|
|
243
|
+
type: 'ai.generation.failed',
|
|
244
|
+
payload: { ...first, error: 'Test failure' },
|
|
245
|
+
})
|
|
246
|
+
const retry = log.start({ requestId: 'retry', reason: 'retry' })
|
|
247
|
+
log.compact(retry)
|
|
248
|
+
log.text(retry, 'Retried response')
|
|
249
|
+
expect(labels(log.timeline())).toEqual([
|
|
250
|
+
'user',
|
|
251
|
+
`compaction:${first.generationId}`,
|
|
252
|
+
`compaction:${retry.generationId}`,
|
|
253
|
+
'Retried response',
|
|
254
|
+
])
|
|
255
|
+
expect(
|
|
256
|
+
log
|
|
257
|
+
.timeline()
|
|
258
|
+
.find((entry) => entry.id === `compaction:${first.generationId}`),
|
|
259
|
+
).toMatchObject({ outcome: 'failed' })
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('does not leave failed compaction spinning', () => {
|
|
263
|
+
const log = transcript()
|
|
264
|
+
log.user('user')
|
|
265
|
+
const generation = log.start()
|
|
266
|
+
log.compact(generation, false)
|
|
267
|
+
log.append({
|
|
268
|
+
type: 'ai.generation.failed',
|
|
269
|
+
payload: { ...generation, error: 'Compaction failed' },
|
|
270
|
+
})
|
|
271
|
+
expect(
|
|
272
|
+
log.timeline().find((entry) => entry.type === 'compaction'),
|
|
273
|
+
).toMatchObject({
|
|
274
|
+
running: false,
|
|
275
|
+
outcome: 'failed',
|
|
276
|
+
event: { type: 'ai.compaction.requested' },
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it.each([false, true])(
|
|
281
|
+
'preserves complete messages without step boundaries, delta only=%s',
|
|
282
|
+
(deltaOnly) => {
|
|
283
|
+
const log = transcript()
|
|
284
|
+
log.user('user')
|
|
285
|
+
const first = log.start()
|
|
286
|
+
log.text(first, 'Before')
|
|
287
|
+
log.append({
|
|
288
|
+
type: 'ai.generation.completed',
|
|
289
|
+
payload: { ...first, finishReason: 'tool-calls' },
|
|
290
|
+
})
|
|
291
|
+
const second = log.start({
|
|
292
|
+
requestId: `ai.generate:tools:${first.generationId}`,
|
|
293
|
+
reason: 'tool',
|
|
294
|
+
})
|
|
295
|
+
log.compact(second)
|
|
296
|
+
if (deltaOnly)
|
|
297
|
+
log.progress(second, [
|
|
298
|
+
{
|
|
299
|
+
type: 'text-delta',
|
|
300
|
+
id: 'after',
|
|
301
|
+
delta: 'After without a step boundary',
|
|
302
|
+
},
|
|
303
|
+
])
|
|
304
|
+
else log.text(second, 'After without a step boundary', false)
|
|
305
|
+
const messages = log
|
|
306
|
+
.timeline()
|
|
307
|
+
.flatMap((entry) => (entry.type === 'message' ? [entry.message] : []))
|
|
308
|
+
expect(messages).toEqual(log.state().messages)
|
|
309
|
+
expect(
|
|
310
|
+
log.timeline().filter((entry) => entry.type === 'compaction'),
|
|
311
|
+
).toHaveLength(1)
|
|
312
|
+
expect(labels(log.timeline())[1]).toBe(`compaction:${second.generationId}`)
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
it('ignores a rejected later generation when mapping canonical parts', () => {
|
|
317
|
+
const log = transcript()
|
|
318
|
+
log.user('user')
|
|
319
|
+
const first = log.start()
|
|
320
|
+
log.compact(first)
|
|
321
|
+
log.text(first, 'Kept')
|
|
322
|
+
log.append({ type: 'ai.generation.completed', payload: first })
|
|
323
|
+
log.append({
|
|
324
|
+
type: 'ai.message.completed',
|
|
325
|
+
payload: { messageId: 'assistant' },
|
|
326
|
+
})
|
|
327
|
+
const rejected = log.start({ requestId: 'stale-retry', reason: 'retry' })
|
|
328
|
+
log.text(rejected, 'Ignored')
|
|
329
|
+
expect(log.state().responseGenerationIds['assistant']).toBe(
|
|
330
|
+
first.generationId,
|
|
331
|
+
)
|
|
332
|
+
expect(labels(log.timeline())).toEqual([
|
|
333
|
+
'user',
|
|
334
|
+
`compaction:${first.generationId}`,
|
|
335
|
+
'Kept',
|
|
336
|
+
])
|
|
337
|
+
})
|