refactor: unify structured generation pipeline
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
resolveStructuredResponseMessage,
|
||||
type StructuredResponseTimelineClient,
|
||||
} from '../src/commands/agent/run.ts'
|
||||
|
||||
type TimelineEntry = {
|
||||
item: {
|
||||
type: string
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(options: {
|
||||
entries?: TimelineEntry[]
|
||||
throwOnFetch?: boolean
|
||||
onFetch?: () => void
|
||||
}): StructuredResponseTimelineClient {
|
||||
return {
|
||||
fetchAgentTimeline: async () => {
|
||||
options.onFetch?.()
|
||||
if (options.throwOnFetch) {
|
||||
throw new Error('timeline unavailable')
|
||||
}
|
||||
return {
|
||||
entries: options.entries ?? [],
|
||||
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Run Output Schema Helper Tests ===\n')
|
||||
|
||||
// Test 1: Direct lastMessage is returned without timeline fetch.
|
||||
{
|
||||
let fetchCount = 0
|
||||
const client = createClient({ onFetch: () => fetchCount += 1 })
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-1',
|
||||
lastMessage: ' {"summary":"ok"} ',
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"ok"}')
|
||||
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
|
||||
console.log('✓ returns direct lastMessage when present')
|
||||
}
|
||||
|
||||
// Test 2: Falls back to latest assistant message from timeline.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'assistant_message', text: '' } },
|
||||
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
|
||||
],
|
||||
})
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-2',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"from timeline"}')
|
||||
console.log('✓ falls back to latest assistant timeline entry')
|
||||
}
|
||||
|
||||
// Test 3: Returns null when timeline has no assistant messages.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'reasoning', text: 'thinking' } },
|
||||
],
|
||||
})
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-3',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when no assistant messages exist')
|
||||
}
|
||||
|
||||
// Test 4: Returns null if timeline fetch fails.
|
||||
{
|
||||
const client = createClient({ throwOnFetch: true })
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-4',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when timeline fetch throws')
|
||||
}
|
||||
|
||||
console.log('\n=== All helper tests passed ===')
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
|
||||
const schema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const impossibleSchema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['summary'],
|
||||
allOf: [
|
||||
{ properties: { summary: { type: 'string' } } },
|
||||
{ properties: { summary: { type: 'number' } } },
|
||||
],
|
||||
})
|
||||
|
||||
let ctx: E2EContext
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
ctx = await createE2ETestContext({ timeout: 180000 })
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderCase(input: {
|
||||
provider: 'claude' | 'codex' | 'opencode'
|
||||
mode: string
|
||||
model: string
|
||||
}): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
input.provider,
|
||||
'--mode',
|
||||
input.mode,
|
||||
'--model',
|
||||
input.model,
|
||||
'--output-schema',
|
||||
schema,
|
||||
`Return valid JSON with a short summary for provider ${input.provider}.`,
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
|
||||
assert.strictEqual(
|
||||
result.exitCode,
|
||||
0,
|
||||
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
|
||||
assert.strictEqual(
|
||||
typeof parsed.summary,
|
||||
'string',
|
||||
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
|
||||
)
|
||||
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
|
||||
}
|
||||
|
||||
async function test_all_providers_return_structured_output(): Promise<void> {
|
||||
await runProviderCase({
|
||||
provider: 'claude',
|
||||
mode: 'bypassPermissions',
|
||||
model: 'haiku',
|
||||
})
|
||||
await runProviderCase({
|
||||
provider: 'codex',
|
||||
mode: 'full-access',
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
await runProviderCase({
|
||||
provider: 'opencode',
|
||||
mode: 'default',
|
||||
model: 'opencode/kimi-k2.5-free',
|
||||
})
|
||||
}
|
||||
|
||||
async function test_schema_validation_is_enforced(): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--mode',
|
||||
'bypassPermissions',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--output-schema',
|
||||
impossibleSchema,
|
||||
'Return exactly {"summary":"ok"} and nothing else.',
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
|
||||
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
assert(
|
||||
output.includes('OUTPUT_SCHEMA_FAILED'),
|
||||
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await setup()
|
||||
await test_all_providers_return_structured_output()
|
||||
await test_schema_validation_is_enforced()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user