fix: keep Pascal agent running in background
This commit is contained in:
parent
59c57e7cf8
commit
c6cabe5296
@ -1,5 +1,6 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import {
|
||||
consumeStream,
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
@ -18,6 +19,12 @@ import {
|
||||
sanitizeAgentMessages,
|
||||
} from '@/lib/pascal-agent-conversations'
|
||||
import { pascalAgentStepPolicy, requiresPascalMutation } from '@/lib/pascal-agent-intent'
|
||||
import {
|
||||
cancelPascalAgentJob,
|
||||
finishPascalAgentJob,
|
||||
isPascalAgentJobActive,
|
||||
startPascalAgentJob,
|
||||
} from '@/lib/pascal-agent-jobs'
|
||||
import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp'
|
||||
import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt'
|
||||
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
|
||||
@ -37,6 +44,15 @@ const requestSchema = z.object({
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
})
|
||||
|
||||
const jobSchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
sceneId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
})
|
||||
|
||||
const AGENT_ERROR_MESSAGE = 'AI design agent is temporarily unavailable. Please try again.'
|
||||
const AGENT_NO_MUTATION_MESSAGE = '模型未执行任何建模动作,请重试或换用支持工具调用的多模态模型。'
|
||||
const SCENE_ERROR_MESSAGE = 'The current scene could not be loaded.'
|
||||
@ -60,13 +76,48 @@ export function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
|
||||
}
|
||||
const config = getPascalAgentConfig()
|
||||
const url = new URL(request.url)
|
||||
const parsedJob = jobSchema.safeParse({
|
||||
conversationId: url.searchParams.get('conversationId'),
|
||||
sceneId: url.searchParams.get('sceneId'),
|
||||
})
|
||||
const ownerId = identity.userId ?? 'local'
|
||||
return NextResponse.json({
|
||||
active: parsedJob.success ? isPascalAgentJobActive({ ...parsedJob.data, ownerId }) : false,
|
||||
configured: config.configured,
|
||||
maxSteps: config.maxSteps,
|
||||
model: config.model,
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
|
||||
if (accessError) return accessError
|
||||
const identity = getPascalHostIdentity(request.headers)
|
||||
if (!identity.allowed) {
|
||||
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid_json' }, { status: 400 })
|
||||
}
|
||||
const parsed = jobSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cancelled = cancelPascalAgentJob({
|
||||
...parsed.data,
|
||||
ownerId: identity.userId ?? 'local',
|
||||
})
|
||||
return cancelled
|
||||
? NextResponse.json({ cancelled: true }, { status: 202 })
|
||||
: NextResponse.json({ cancelled: false }, { status: 404 })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
|
||||
if (accessError) return accessError
|
||||
@ -130,7 +181,22 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)])
|
||||
const job = parsed.data.conversationId
|
||||
? startPascalAgentJob({
|
||||
conversationId: parsed.data.conversationId,
|
||||
ownerId,
|
||||
sceneId: parsed.data.sceneId,
|
||||
})
|
||||
: null
|
||||
if (parsed.data.conversationId && !job) {
|
||||
return NextResponse.json({ error: 'agent_job_already_running' }, { status: 409 })
|
||||
}
|
||||
|
||||
const jobController = job?.controller ?? new AbortController()
|
||||
const abortSignal = AbortSignal.any([
|
||||
jobController.signal,
|
||||
AbortSignal.timeout(config.totalTimeoutMs),
|
||||
])
|
||||
let session: Awaited<ReturnType<typeof createPascalAgentMcpSession>>
|
||||
try {
|
||||
session = await createPascalAgentMcpSession({
|
||||
@ -139,6 +205,7 @@ export async function POST(request: Request) {
|
||||
stepTimeoutMs: config.stepTimeoutMs,
|
||||
})
|
||||
} catch (error) {
|
||||
finishPascalAgentJob(job)
|
||||
console.error('Pascal agent could not load the requested scene.', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
|
||||
@ -256,17 +323,20 @@ export async function POST(request: Request) {
|
||||
},
|
||||
onError: streamErrorText,
|
||||
onFinish: async ({ messages }) => {
|
||||
if (!parsed.data.conversationId || !conversationStore) return
|
||||
try {
|
||||
await conversationStore.saveAgentConversation({
|
||||
id: parsed.data.conversationId,
|
||||
sceneId: parsed.data.sceneId,
|
||||
ownerId,
|
||||
messages: sanitizeAgentMessages(messages),
|
||||
title: conversationTitle(messages),
|
||||
})
|
||||
if (parsed.data.conversationId && conversationStore) {
|
||||
await conversationStore.saveAgentConversation({
|
||||
id: parsed.data.conversationId,
|
||||
sceneId: parsed.data.sceneId,
|
||||
ownerId,
|
||||
messages: sanitizeAgentMessages(messages),
|
||||
title: conversationTitle(messages),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Pascal agent conversation persistence failed.', error)
|
||||
} finally {
|
||||
finishPascalAgentJob(job)
|
||||
}
|
||||
},
|
||||
originalMessages: uiMessages,
|
||||
@ -274,10 +344,18 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
return createUIMessageStreamResponse({
|
||||
consumeSseStream: ({ stream: responseStream }) =>
|
||||
consumeStream({
|
||||
stream: responseStream,
|
||||
onError: (streamError) => {
|
||||
console.error('Pascal agent background stream failed.', streamError)
|
||||
},
|
||||
}),
|
||||
stream,
|
||||
})
|
||||
} catch (error) {
|
||||
await finalize(false)
|
||||
finishPascalAgentJob(job)
|
||||
console.error('Pascal agent failed before streaming.', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'agent_failed', message: AGENT_ERROR_MESSAGE },
|
||||
|
||||
@ -14,8 +14,13 @@ import {
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { announcePascalAgentActivity } from '@/lib/pascal-agent-browser-events'
|
||||
import {
|
||||
hasCompletedPascalAgentResponse,
|
||||
type PendingPascalAgentRun,
|
||||
parsePendingPascalAgentRun,
|
||||
} from '@/lib/pascal-agent-recovery'
|
||||
import { withPascalBasePath } from '@/lib/pascal-base-path'
|
||||
|
||||
interface AiDesignAgentPanelProps {
|
||||
@ -23,6 +28,7 @@ interface AiDesignAgentPanelProps {
|
||||
}
|
||||
|
||||
interface AgentStatus {
|
||||
active?: boolean
|
||||
configured: boolean
|
||||
maxSteps: number
|
||||
model: string
|
||||
@ -60,6 +66,9 @@ const STARTER_PROMPTS = [
|
||||
'增加二层,并创建连接楼梯',
|
||||
]
|
||||
|
||||
const PENDING_RUN_GRACE_MS = 10_000
|
||||
const PENDING_RUN_MAX_AGE_MS = 12 * 60_000
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
add_door: '添加门',
|
||||
add_window: '添加窗户',
|
||||
@ -164,6 +173,7 @@ function processingStatusText(messages: UIMessage[], status: string): string {
|
||||
|
||||
export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
const activeConversationKey = `pascal-agent-active-conversation:${sceneId}`
|
||||
const pendingRunKey = `pascal-agent-pending-run:${sceneId}`
|
||||
const [input, setInput] = useState('')
|
||||
const [agentStatus, setAgentStatus] = useState<AgentStatus | null>(null)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
@ -172,6 +182,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
const [conversationError, setConversationError] = useState('')
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [historyReady, setHistoryReady] = useState(false)
|
||||
const [pendingRun, setPendingRun] = useState<PendingPascalAgentRun | null>(null)
|
||||
const conversationIdRef = useRef<string | null>(null)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const transport = useMemo(
|
||||
@ -190,15 +201,19 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
}),
|
||||
[sceneId],
|
||||
)
|
||||
const { error, messages, sendMessage, setMessages, status, stop } = useChat({
|
||||
const { clearError, error, messages, sendMessage, setMessages, status, stop } = useChat({
|
||||
id: `pascal-agent:${sceneId}`,
|
||||
experimental_throttle: 80,
|
||||
transport,
|
||||
})
|
||||
const isRunning = status === 'submitted' || status === 'streaming'
|
||||
const chatIsRunning = status === 'submitted' || status === 'streaming'
|
||||
const isRunning = chatIsRunning || pendingRun !== null
|
||||
const runningStatusText = useMemo(
|
||||
() => processingStatusText(messages, status),
|
||||
[messages, status],
|
||||
() =>
|
||||
pendingRun && !chatIsRunning
|
||||
? '任务仍在后台执行,正在同步最新结果'
|
||||
: processingStatusText(messages, status),
|
||||
[chatIsRunning, messages, pendingRun, status],
|
||||
)
|
||||
const latestMessageRenderKey = useMemo(() => {
|
||||
const latest = messages.at(-1)
|
||||
@ -218,6 +233,34 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
}
|
||||
}, [isRunning, sceneId])
|
||||
|
||||
const clearPendingRun = useCallback(
|
||||
(conversationId?: string) => {
|
||||
setPendingRun((current) => {
|
||||
if (conversationId && current?.conversationId !== conversationId) return current
|
||||
window.localStorage.removeItem(pendingRunKey)
|
||||
return null
|
||||
})
|
||||
},
|
||||
[pendingRunKey],
|
||||
)
|
||||
|
||||
const trackPendingRun = useCallback(
|
||||
(run: PendingPascalAgentRun) => {
|
||||
window.localStorage.setItem(pendingRunKey, JSON.stringify(run))
|
||||
setPendingRun(run)
|
||||
},
|
||||
[pendingRunKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = parsePendingPascalAgentRun(window.localStorage.getItem(pendingRunKey))
|
||||
if (!stored || Date.now() - stored.startedAt > PENDING_RUN_MAX_AGE_MS) {
|
||||
window.localStorage.removeItem(pendingRunKey)
|
||||
return
|
||||
}
|
||||
setPendingRun(stored)
|
||||
}, [pendingRunKey])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetch(withPascalBasePath('/api/agent/chat'))
|
||||
@ -280,6 +323,99 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
}
|
||||
}, [activeConversationKey, sceneId, setMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyReady || !pendingRun) return
|
||||
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const schedule = (delay: number) => {
|
||||
if (!active) return
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => void sync(), delay)
|
||||
}
|
||||
|
||||
const sync = async () => {
|
||||
if (!active) return
|
||||
const age = Date.now() - pendingRun.startedAt
|
||||
if (age > PENDING_RUN_MAX_AGE_MS) {
|
||||
clearPendingRun(pendingRun.conversationId)
|
||||
setConversationError('后台建模任务等待超时,请检查场景结果后重试。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
conversationId: pendingRun.conversationId,
|
||||
sceneId,
|
||||
})
|
||||
const [statusResponse, detailResponse] = await Promise.all([
|
||||
fetch(withPascalBasePath(`/api/agent/chat?${query.toString()}`), {
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(withPascalBasePath(`/api/agent/conversations/${pendingRun.conversationId}`), {
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
if (!statusResponse.ok || !detailResponse.ok) throw new Error('recovery_unavailable')
|
||||
|
||||
const statusData = (await statusResponse.json()) as AgentStatus
|
||||
const detail = (await detailResponse.json()) as { conversation: Conversation }
|
||||
if (!active) return
|
||||
|
||||
if (
|
||||
hasCompletedPascalAgentResponse(detail.conversation.messages, pendingRun.userMessageId)
|
||||
) {
|
||||
conversationIdRef.current = detail.conversation.id
|
||||
setConversationId(detail.conversation.id)
|
||||
setMessages(detail.conversation.messages)
|
||||
window.localStorage.setItem(activeConversationKey, detail.conversation.id)
|
||||
clearError()
|
||||
setConversationError('')
|
||||
clearPendingRun(pendingRun.conversationId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!statusData.active && age >= PENDING_RUN_GRACE_MS) {
|
||||
clearPendingRun(pendingRun.conversationId)
|
||||
setConversationError('上一次建模任务未能完成,请检查当前场景后重试。')
|
||||
return
|
||||
}
|
||||
setConversationError('')
|
||||
} catch {
|
||||
if (age >= PENDING_RUN_MAX_AGE_MS) {
|
||||
clearPendingRun(pendingRun.conversationId)
|
||||
setConversationError('无法恢复后台建模任务,请稍后重试。')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
schedule(document.hidden ? 8000 : 1500)
|
||||
}
|
||||
|
||||
const resumeSync = () => {
|
||||
if (document.visibilityState === 'visible') void sync()
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', resumeSync)
|
||||
window.addEventListener('focus', resumeSync)
|
||||
void sync()
|
||||
return () => {
|
||||
active = false
|
||||
if (timer) clearTimeout(timer)
|
||||
document.removeEventListener('visibilitychange', resumeSync)
|
||||
window.removeEventListener('focus', resumeSync)
|
||||
}
|
||||
}, [
|
||||
activeConversationKey,
|
||||
clearError,
|
||||
clearPendingRun,
|
||||
historyReady,
|
||||
pendingRun,
|
||||
sceneId,
|
||||
setMessages,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'ready' || !conversationIdRef.current || !historyReady) return
|
||||
fetch(withPascalBasePath(`/api/agent/conversations?sceneId=${encodeURIComponent(sceneId)}`))
|
||||
@ -395,7 +531,38 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
if (!activeId) activeId = await createConversation(text.slice(0, 32))
|
||||
if (!activeId) return
|
||||
setInput('')
|
||||
await sendMessage({ role: 'user', parts: [{ type: 'text', text }] })
|
||||
setConversationError('')
|
||||
clearError()
|
||||
const userMessageId = crypto.randomUUID()
|
||||
trackPendingRun({
|
||||
conversationId: activeId,
|
||||
startedAt: Date.now(),
|
||||
userMessageId,
|
||||
})
|
||||
try {
|
||||
await sendMessage({ id: userMessageId, role: 'user', parts: [{ type: 'text', text }] })
|
||||
} catch {
|
||||
// Recovery polling keeps the server-side task attached to the conversation.
|
||||
}
|
||||
}
|
||||
|
||||
const stopAgent = async () => {
|
||||
const run = pendingRun
|
||||
stop()
|
||||
if (!run) return
|
||||
try {
|
||||
await fetch(withPascalBasePath('/api/agent/chat'), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversationId: run.conversationId,
|
||||
sceneId,
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
clearPendingRun(run.conversationId)
|
||||
setConversationError('已停止本次建模任务。')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -586,7 +753,9 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
请先配置 PASCAL_AGENT_API_KEY、模型和接口地址。
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="mb-2 text-destructive text-xs">{error.message}</div> : null}
|
||||
{error && !pendingRun ? (
|
||||
<div className="mb-2 text-destructive text-xs">{error.message}</div>
|
||||
) : null}
|
||||
{conversationError ? (
|
||||
<div className="mb-2 text-destructive text-xs">{conversationError}</div>
|
||||
) : null}
|
||||
@ -610,7 +779,7 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
||||
<button
|
||||
aria-label="停止建模"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
onClick={() => stop()}
|
||||
onClick={() => void stopAgent()}
|
||||
title="停止建模"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
cancelPascalAgentJob,
|
||||
finishPascalAgentJob,
|
||||
isPascalAgentJobActive,
|
||||
startPascalAgentJob,
|
||||
} from './pascal-agent-jobs'
|
||||
|
||||
describe('Pascal agent background jobs', () => {
|
||||
test('keeps one active job per owner and conversation', () => {
|
||||
const input = {
|
||||
conversationId: crypto.randomUUID(),
|
||||
ownerId: 'owner-a',
|
||||
sceneId: 'scene-a',
|
||||
}
|
||||
const job = startPascalAgentJob(input)
|
||||
|
||||
expect(job).not.toBeNull()
|
||||
expect(startPascalAgentJob(input)).toBeNull()
|
||||
expect(isPascalAgentJobActive(input)).toBe(true)
|
||||
|
||||
finishPascalAgentJob(job)
|
||||
expect(isPascalAgentJobActive(input)).toBe(false)
|
||||
})
|
||||
|
||||
test('cancels only the matching owner, conversation, and scene', () => {
|
||||
const input = {
|
||||
conversationId: crypto.randomUUID(),
|
||||
ownerId: 'owner-b',
|
||||
sceneId: 'scene-b',
|
||||
}
|
||||
const job = startPascalAgentJob(input)
|
||||
|
||||
expect(
|
||||
cancelPascalAgentJob({
|
||||
...input,
|
||||
sceneId: 'other-scene',
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(cancelPascalAgentJob(input)).toBe(true)
|
||||
expect(job?.controller.signal.aborted).toBe(true)
|
||||
expect(isPascalAgentJobActive(input)).toBe(false)
|
||||
|
||||
finishPascalAgentJob(job)
|
||||
})
|
||||
})
|
||||
65
services/pascal-editor/apps/editor/lib/pascal-agent-jobs.ts
Normal file
65
services/pascal-editor/apps/editor/lib/pascal-agent-jobs.ts
Normal file
@ -0,0 +1,65 @@
|
||||
export interface PascalAgentJob {
|
||||
controller: AbortController
|
||||
conversationId: string
|
||||
ownerId: string
|
||||
sceneId: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
type PascalAgentJobRegistry = Map<string, PascalAgentJob>
|
||||
|
||||
const globalForPascalAgentJobs = globalThis as typeof globalThis & {
|
||||
__pascalAgentJobs?: PascalAgentJobRegistry
|
||||
}
|
||||
|
||||
if (!globalForPascalAgentJobs.__pascalAgentJobs) {
|
||||
globalForPascalAgentJobs.__pascalAgentJobs = new Map<string, PascalAgentJob>()
|
||||
}
|
||||
const jobs = globalForPascalAgentJobs.__pascalAgentJobs
|
||||
|
||||
function jobKey(ownerId: string, conversationId: string): string {
|
||||
return `${ownerId}:${conversationId}`
|
||||
}
|
||||
|
||||
export function startPascalAgentJob(input: {
|
||||
conversationId: string
|
||||
ownerId: string
|
||||
sceneId: string
|
||||
}): PascalAgentJob | null {
|
||||
const key = jobKey(input.ownerId, input.conversationId)
|
||||
if (jobs.has(key)) return null
|
||||
|
||||
const job: PascalAgentJob = {
|
||||
...input,
|
||||
controller: new AbortController(),
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
jobs.set(key, job)
|
||||
return job
|
||||
}
|
||||
|
||||
export function isPascalAgentJobActive(input: {
|
||||
conversationId: string
|
||||
ownerId: string
|
||||
sceneId: string
|
||||
}): boolean {
|
||||
const job = jobs.get(jobKey(input.ownerId, input.conversationId))
|
||||
return Boolean(job && job.sceneId === input.sceneId && !job.controller.signal.aborted)
|
||||
}
|
||||
|
||||
export function cancelPascalAgentJob(input: {
|
||||
conversationId: string
|
||||
ownerId: string
|
||||
sceneId: string
|
||||
}): boolean {
|
||||
const job = jobs.get(jobKey(input.ownerId, input.conversationId))
|
||||
if (!job || job.sceneId !== input.sceneId) return false
|
||||
job.controller.abort(new Error('Pascal agent job cancelled by user'))
|
||||
return true
|
||||
}
|
||||
|
||||
export function finishPascalAgentJob(job: PascalAgentJob | null): void {
|
||||
if (!job) return
|
||||
const key = jobKey(job.ownerId, job.conversationId)
|
||||
if (jobs.get(key) === job) jobs.delete(key)
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { UIMessage } from 'ai'
|
||||
import {
|
||||
hasCompletedPascalAgentResponse,
|
||||
parsePendingPascalAgentRun,
|
||||
} from './pascal-agent-recovery'
|
||||
|
||||
describe('Pascal agent conversation recovery', () => {
|
||||
test('parses valid pending runs and rejects incomplete values', () => {
|
||||
const pending = {
|
||||
conversationId: crypto.randomUUID(),
|
||||
startedAt: Date.now(),
|
||||
userMessageId: crypto.randomUUID(),
|
||||
}
|
||||
expect(parsePendingPascalAgentRun(JSON.stringify(pending))).toEqual(pending)
|
||||
expect(parsePendingPascalAgentRun('{"conversationId":"missing-fields"}')).toBeNull()
|
||||
expect(parsePendingPascalAgentRun('invalid')).toBeNull()
|
||||
})
|
||||
|
||||
test('detects a completed assistant response after the submitted message', () => {
|
||||
const userMessageId = crypto.randomUUID()
|
||||
const messages: UIMessage[] = [
|
||||
{ id: userMessageId, role: 'user', parts: [{ type: 'text', text: '调整布局' }] },
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'data-pascal-tool-activity',
|
||||
data: { state: 'output-available', toolName: 'apply_patch' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(hasCompletedPascalAgentResponse(messages, userMessageId)).toBe(true)
|
||||
expect(hasCompletedPascalAgentResponse(messages, crypto.randomUUID())).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
export interface PendingPascalAgentRun {
|
||||
conversationId: string
|
||||
startedAt: number
|
||||
userMessageId: string
|
||||
}
|
||||
|
||||
export function parsePendingPascalAgentRun(value: string | null): PendingPascalAgentRun | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<PendingPascalAgentRun>
|
||||
if (
|
||||
typeof parsed.conversationId !== 'string' ||
|
||||
typeof parsed.startedAt !== 'number' ||
|
||||
typeof parsed.userMessageId !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed as PendingPascalAgentRun
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCompletedPascalAgentResponse(
|
||||
messages: UIMessage[],
|
||||
userMessageId: string,
|
||||
): boolean {
|
||||
const userIndex = messages.findIndex(
|
||||
(message) => message.id === userMessageId && message.role === 'user',
|
||||
)
|
||||
if (userIndex < 0) return false
|
||||
|
||||
return messages.slice(userIndex + 1).some(
|
||||
(message) =>
|
||||
message.role === 'assistant' &&
|
||||
message.parts.some((part) => {
|
||||
if (part.type === 'text') return Boolean(part.text.trim())
|
||||
if (part.type === 'data-pascal-tool-activity') {
|
||||
const state = (part.data as { state?: unknown } | undefined)?.state
|
||||
return state === 'output-available' || state === 'output-error'
|
||||
}
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const state = (part as { state?: unknown }).state
|
||||
return state === 'output-available' || state === 'output-error'
|
||||
}
|
||||
return false
|
||||
}),
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user