ManimCat-show / src /studio-agent /tests /run-tests.ts
Bin29's picture
Sync from main: 68df783 feat: support multimodal studio reference images
d47b053
import { getDefaultStudioWorkspacePath } from '../workspace/default-studio-workspace'
import path from 'node:path'
import os from 'node:os'
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import assert from 'node:assert/strict'
import {
buildStudioAgentSystemPrompt,
buildStudioSubagentPrompt,
buildReviewerStructuredReport,
createRenderStatusSessionEvent,
createStudioAssistantMessage,
createStudioSession,
createStudioTask,
createStudioWork,
createLocalStudioSkillResolver,
createPlaceholderStudioTools,
createStudioDefaultTurnPlanResolver,
enqueueSessionEvent,
extractStudioWorkflowInput,
flushTerminalSessionEventsToAssistant,
getStudioSessionAgentConfig,
InMemoryStudioEventBus,
InMemoryStudioMessageStore,
InMemoryStudioPartStore,
InMemoryStudioRunStore,
InMemoryStudioSessionEventStore,
InMemoryStudioSessionStore,
InMemoryStudioTaskStore,
InMemoryStudioWorkResultStore,
InMemoryStudioWorkStore,
publishRenderFailureFeedback,
StudioBuilderRuntime,
StudioPermissionService,
StudioRunProcessor,
StudioToolRegistry,
defaultRulesForLevel,
determineStudioAgentLoopAction,
syncRenderWorkFromTask,
type StudioAssistantMessage,
type StudioPermissionDecision,
type StudioPermissionRequest,
type StudioRuntimeBackedToolContext,
type StudioTurnPlanResolver
} from '../index'
import { createStudioError, createStudioSuccess, isStudioPermissionDecision } from '../../routes/helpers/studio-agent-responses'
import { parseStudioTurnIntent } from '../runtime/turn-plan-intent'
function createTestRuntime(options?: {
permissionService?: StudioPermissionService
askForConfirmation?: (request: StudioPermissionRequest) => Promise<StudioPermissionDecision>
resolveTurnPlan?: StudioTurnPlanResolver
eventBus?: InMemoryStudioEventBus
}) {
const registry = new StudioToolRegistry()
for (const tool of createPlaceholderStudioTools()) {
registry.register(tool)
}
const messageStore = new InMemoryStudioMessageStore()
const partStore = new InMemoryStudioPartStore()
const runStore = new InMemoryStudioRunStore()
const sessionStore = new InMemoryStudioSessionStore()
const taskStore = new InMemoryStudioTaskStore()
const sessionEventStore = new InMemoryStudioSessionEventStore()
const workStore = new InMemoryStudioWorkStore()
const workResultStore = new InMemoryStudioWorkResultStore()
const resolveSkill = createLocalStudioSkillResolver()
const resolveTurnPlan = options?.resolveTurnPlan ?? createStudioDefaultTurnPlanResolver({ registry })
const runtime = new StudioBuilderRuntime({
registry,
messageStore,
partStore,
runStore,
sessionStore,
sessionEventStore,
taskStore,
workStore,
workResultStore,
resolveSkill,
resolveTurnPlan,
permissionService: options?.permissionService,
askForConfirmation: options?.askForConfirmation,
eventBus: options?.eventBus
})
return {
registry,
runtime,
messageStore,
partStore,
runStore,
sessionStore,
sessionEventStore,
taskStore,
workStore,
workResultStore,
resolveTurnPlan
}
}
async function createWorkspace(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'manimcat-studio-agent-'))
}
async function run(name: string, fn: () => Promise<void>) {
try {
await fn()
console.log(`PASS ${name}`)
} catch (error) {
console.error(`FAIL ${name}`)
throw error
}
}
async function findLastAssistantMessageWithTool(messageStore: InMemoryStudioMessageStore, sessionId: string): Promise<StudioAssistantMessage | undefined> {
const messages = await messageStore.listBySessionId(sessionId)
return [...messages]
.reverse()
.find((message): message is StudioAssistantMessage => message.role === 'assistant' && message.parts.some((part) => part.type === 'tool'))
}
async function main() {
await run('studio route helpers build stable envelopes', async () => {
assert.deepEqual(createStudioSuccess({ foo: 'bar' }), {
ok: true,
data: { foo: 'bar' }
})
assert.deepEqual(createStudioError('INVALID_INPUT', 'bad request'), {
ok: false,
error: {
code: 'INVALID_INPUT',
message: 'bad request'
}
})
assert.equal(isStudioPermissionDecision('once'), true)
assert.equal(isStudioPermissionDecision('always'), true)
assert.equal(isStudioPermissionDecision('reject'), true)
assert.equal(isStudioPermissionDecision('maybe'), false)
})
await run('default studio workspace uses dedicated hidden directory', async () => {
assert.equal(getDefaultStudioWorkspacePath(), path.join(process.cwd(), '.studio-workspace'))
})
await run('builder prompt requires code, checks, and confirmation before render', async () => {
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Prompt Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
})
const prompt = buildStudioAgentSystemPrompt({
session
})
assert.match(prompt, /Workspace root:/)
assert.match(prompt, /Do not call render until the target code has been written or updated in the workspace and checked with static-check/)
assert.match(prompt, /use the question tool to ask for confirmation first/)
assert.match(prompt, /prefer the smallest local edit or apply_patch change/)
assert.match(prompt, /If the task is not finished, do not end the turn without a tool call\./)
assert.match(prompt, /When any error happens, you must either call another tool to investigate or repair it, or call the question tool to ask the user how to proceed\./)
})
await run('plot builder prompt does not require static-check by default', async () => {
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Plot Prompt Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4'),
studioKind: 'plot'
})
const prompt = buildStudioAgentSystemPrompt({
session
})
assert.match(prompt, /Do not call render until the target code has been written or updated in the workspace\./)
assert.match(prompt, /Use static-check only when the code is unusually complex, the risk is high, or repeated failures suggest it is needed\./)
assert.match(prompt, /prefer the smallest local edit or apply_patch change/)
assert.match(prompt, /If the task is not finished, do not end the turn without a tool call\./)
assert.doesNotMatch(prompt, /checked with static-check/)
})
await run('loop policy finishes when the assistant stops calling tools', async () => {
const decision = determineStudioAgentLoopAction({
finishReason: 'stop',
toolCallCount: 0,
step: 0,
maxSteps: 8
})
assert.deepEqual(decision, { type: 'finish' })
})
await run('loop policy continues when tool calls are returned with budget left', async () => {
const decision = determineStudioAgentLoopAction({
finishReason: 'tool_calls',
toolCallCount: 2,
step: 2,
maxSteps: 8
})
assert.deepEqual(decision, { type: 'continue' })
})
await run('loop policy aborts when tool calls would exceed the safety step limit', async () => {
const decision = determineStudioAgentLoopAction({
finishReason: 'tool_calls',
toolCallCount: 1,
step: 7,
maxSteps: 8
})
assert.deepEqual(decision, {
type: 'abort',
message: 'Stopped after reaching the Studio agent step limit (8).'
})
})
await run('loop policy surfaces provider stop reasons without leaking loop internals', async () => {
const decision = determineStudioAgentLoopAction({
finishReason: 'length',
toolCallCount: 0,
step: 0,
maxSteps: 8
})
assert.deepEqual(decision, {
type: 'abort',
message: 'Studio agent response hit the model output limit before finishing.'
})
})
await run('terminal session events flush into assistant updates', async () => {
const sessionId = 'sess_terminal_event'
const messageStore = new InMemoryStudioMessageStore()
const partStore = new InMemoryStudioPartStore()
const sessionEventStore = new InMemoryStudioSessionEventStore()
await enqueueSessionEvent({
store: sessionEventStore,
eventBus: new InMemoryStudioEventBus(),
event: createRenderStatusSessionEvent({
task: createStudioTask({
sessionId,
runId: 'run_terminal_event',
type: 'render',
status: 'completed',
title: 'Render parabola',
metadata: {
jobId: 'job_terminal_event',
result: {
status: 'completed',
data: {
outputMode: 'video',
videoUrl: '/tmp/parabola.mp4'
},
timestamp: Date.now()
}
}
}),
status: 'completed',
summary: 'Render completed: Render parabola (output: /tmp/parabola.mp4, render_job_id: job_terminal_event)'
})
})
const flushed = await flushTerminalSessionEventsToAssistant({
sessionId,
sessionEventStore,
messageStore,
partStore
})
const messages = await messageStore.listBySessionId(sessionId)
const events = await sessionEventStore.listBySessionId(sessionId)
const assistant = messages.find((message): message is StudioAssistantMessage => message.role === 'assistant')
assert.equal(flushed.length, 1)
assert.ok(assistant)
assert.match(assistant?.parts[0] && assistant.parts[0].type === 'text' ? assistant.parts[0].text : '', /System Update/)
assert.equal(events[0]?.status, 'consumed')
})
await run('session agent config reads tool choice from metadata', async () => {
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Config Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4'),
metadata: {
agentConfig: {
toolChoice: 'required'
}
}
})
assert.deepEqual(getStudioSessionAgentConfig(session), { toolChoice: 'required' })
})
await run('turn intent maps list requests to ls at workspace root', async () => {
const intent = parseStudioTurnIntent('please list the current workspace')
assert.equal(intent.directTool?.toolName, 'ls')
assert.deepEqual(intent.directTool?.input, { path: '.' })
assert.ok(intent.requestedToolNames.includes('ls'))
})
await run('turn intent keeps explicit ls path intact', async () => {
const intent = parseStudioTurnIntent('/ls src/studio-agent')
assert.equal(intent.directTool?.toolName, 'ls')
assert.deepEqual(intent.directTool?.input, { path: 'src/studio-agent' })
assert.equal(intent.explicitCommand, true)
})
await run('registry filters tools by agent', async () => {
const { registry } = createTestRuntime()
const builderTools = registry.listForAgent('builder').map((tool) => tool.name)
const reviewerTools = registry.listForAgent('reviewer').map((tool) => tool.name)
assert.ok(builderTools.includes('task'))
assert.ok(builderTools.includes('render'))
assert.ok(!reviewerTools.includes('task'))
assert.ok(reviewerTools.includes('skill'))
})
await run('resolver continues current running review work', async () => {
const { resolveTurnPlan } = createTestRuntime()
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Plan Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
})
const plan = await resolveTurnPlan({
projectId: 'project-1',
session,
run: {
id: 'run_test',
sessionId: session.id,
status: 'pending',
inputText: '继续补全审查结论',
activeAgent: 'builder',
createdAt: new Date().toISOString()
},
assistantMessage: {
id: 'msg_test',
sessionId: session.id,
role: 'assistant',
agent: 'builder',
parts: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
inputText: '继续补全审查结论',
workContext: {
sessionId: session.id,
agent: 'builder',
currentWork: {
id: 'work_review',
type: 'review',
status: 'running',
title: 'Architecture review'
}
}
})
assert.equal(plan.toolCalls?.length, 1)
assert.equal(plan.toolCalls?.[0]?.toolName, 'task')
assert.match(plan.assistantText ?? '', /延续当前正在进行的子代理工作/)
assert.match(plan.assistantText ?? '', /当前会话存在进行中的 Work:Architecture review/)
})
await run('resolver injects failed render reminder', async () => {
const { resolveTurnPlan } = createTestRuntime()
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Plan Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
})
const plan = await resolveTurnPlan({
projectId: 'project-1',
session,
run: {
id: 'run_test',
sessionId: session.id,
status: 'pending',
inputText: '帮我继续处理',
activeAgent: 'builder',
createdAt: new Date().toISOString()
},
assistantMessage: {
id: 'msg_test',
sessionId: session.id,
role: 'assistant',
agent: 'builder',
parts: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
inputText: '帮我继续处理',
workContext: {
sessionId: session.id,
agent: 'builder',
lastRender: {
status: 'failed',
timestamp: Date.now(),
error: 'LaTeX compile failed'
}
}
})
assert.match(plan.assistantText ?? '', /最近一次 render 结果失败/)
})
await run('runtime emits commentary before tool events when plan text is absent', async () => {
const eventBus = new InMemoryStudioEventBus()
const workspace = await createWorkspace()
const { runtime, sessionStore } = createTestRuntime({
eventBus,
resolveTurnPlan: async () => ({
toolCalls: [
{
toolName: 'ls',
callId: 'call_ls_commentary',
input: { path: 'src' }
}
]
})
})
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Commentary Session',
directory: workspace,
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
}))
const result = await runtime.run({
projectId: 'project-1',
session,
inputText: '看看 src 目录'
})
const eventTypes = eventBus.list().map((event) => event.type)
const assistantTextEvent = eventBus.list().find((event) => event.type === 'assistant_text')
const toolStartIndex = eventTypes.indexOf('tool_input_start')
const assistantTextIndex = eventTypes.indexOf('assistant_text')
assert.ok(assistantTextEvent && assistantTextEvent.type === 'assistant_text')
assert.match(assistantTextEvent && assistantTextEvent.type === 'assistant_text' ? assistantTextEvent.text : '', /我先看一下 src 的目录结构/)
assert.ok(assistantTextIndex >= 0)
assert.ok(toolStartIndex >= 0)
assert.ok(assistantTextIndex < toolStartIndex)
})
await run('run processor switches assistant messages between provider turns', async () => {
const messageStore = new InMemoryStudioMessageStore()
const partStore = new InMemoryStudioPartStore()
const processor = new StudioRunProcessor({
messageStore,
partStore,
})
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Processor Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
})
const firstAssistantMessage = await messageStore.createAssistantMessage(createStudioAssistantMessage({
sessionId: session.id,
agent: 'builder'
}))
const secondAssistantMessage = await messageStore.createAssistantMessage(createStudioAssistantMessage({
sessionId: session.id,
agent: 'builder'
}))
async function* events() {
yield { type: 'text-start' } as const
yield { type: 'text-delta', text: 'first turn' } as const
yield { type: 'text-end' } as const
yield { type: 'assistant-message-start', message: secondAssistantMessage } as const
yield { type: 'text-start' } as const
yield { type: 'text-delta', text: 'second turn' } as const
yield { type: 'text-end' } as const
yield { type: 'finish-step' } as const
}
const outcome = await processor.processStream({
session,
run: {
id: 'run_processor_switch',
sessionId: session.id,
status: 'running',
inputText: 'test',
activeAgent: 'builder',
createdAt: new Date().toISOString()
},
assistantMessage: firstAssistantMessage,
events: events()
})
const refreshedFirst = await messageStore.getById(firstAssistantMessage.id)
const refreshedSecond = await messageStore.getById(secondAssistantMessage.id)
assert.equal(outcome, 'continue')
assert.ok(refreshedFirst && refreshedFirst.role === 'assistant')
assert.ok(refreshedSecond && refreshedSecond.role === 'assistant')
assert.equal(refreshedFirst && refreshedFirst.role === 'assistant' && refreshedFirst.parts[0]?.type === 'text' ? refreshedFirst.parts[0].text : '', 'first turn')
assert.equal(refreshedSecond && refreshedSecond.role === 'assistant' && refreshedSecond.parts[0]?.type === 'text' ? refreshedSecond.parts[0].text : '', 'second turn')
})
await run('resolver does not auto-call render for plain render requests', async () => {
const { registry } = createTestRuntime()
const resolveTurnPlan = createStudioDefaultTurnPlanResolver({
registry,
enabledToolNames: ['skill', 'task', 'read', 'glob', 'grep', 'ls', 'render']
})
const session = createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Render Guard Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
})
const plan = await resolveTurnPlan({
projectId: 'project-1',
session,
run: {
id: 'run_render_guard',
sessionId: session.id,
status: 'pending',
inputText: '请直接渲染当前内容',
activeAgent: 'builder',
createdAt: new Date().toISOString()
},
assistantMessage: {
id: 'msg_render_guard',
sessionId: session.id,
role: 'assistant',
agent: 'builder',
parts: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
inputText: '请直接渲染当前内容',
workContext: {
sessionId: session.id,
agent: 'builder'
}
})
assert.equal(plan.toolCalls?.length ?? 0, 0)
})
await run('subagent prompt assembly keeps workflow separate from agent prompt', async () => {
const prompt = buildStudioSubagentPrompt({
agentType: 'reviewer',
workflowInput: '请审查 @src/foo.ts 的边界条件',
requestedSkillName: 'manim-style',
files: ['src/foo.ts']
})
assert.match(prompt, /<agent_prompt role="reviewer">/)
assert.match(prompt, /<workflow_input>/)
assert.match(prompt, /<skill_request name="manim-style">/)
assert.equal(extractStudioWorkflowInput(prompt), '请审查 @src/foo.ts 的边界条件')
})
await run('reviewer report exposes structured findings', async () => {
const report = buildReviewerStructuredReport([
'Review the file "sample.py".',
'<review_target>',
'from manim import *',
'except Exception:',
' print("debug")',
'</review_target>'
].join('\n'))
assert.ok(report)
assert.equal(report?.summary, '发现 3 个需要关注的问题')
assert.equal(report?.findings.length, 3)
assert.equal(report?.findings[0]?.severity, 'medium')
assert.equal(report?.findings[0]?.code, 'manim.wildcard-import')
assert.equal(report?.findings[0]?.path, 'sample.py')
assert.equal(report?.findings[0]?.line, 1)
assert.deepEqual(report?.findings[0]?.range, { start: 1, end: 1 })
})
await run('ai-review tool accepts change-set input and persists diff context', async () => {
const workspace = await createWorkspace()
const { runtime, registry, sessionStore, taskStore, workStore, workResultStore } = createTestRuntime({
askForConfirmation: async () => 'once'
})
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'AI Review Change Set',
directory: workspace,
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
}))
const assistantMessage = await runtime.createAssistantMessage(session)
const runState = runtime.createRun(session, 'review changes in sample.py')
const tool = registry.get('ai-review')
assert.ok(tool)
const toolContext: StudioRuntimeBackedToolContext = {
projectId: 'project-1',
session,
run: runState,
assistantMessage,
eventBus: new InMemoryStudioEventBus(),
taskStore,
workStore,
workResultStore,
sessionStore,
askForConfirmation: async () => 'once',
ask: async () => 'once',
runSubagent: (input: Parameters<typeof runtime.runSubagent>[0]) => runtime.runSubagent(input)
}
const result = await tool!.execute({
path: 'sample.py',
before: 'from manim import Scene',
after: 'from manim import *\nprint("debug")',
diff: '@@ -1 +1,2 @@\n-from manim import Scene\n+from manim import *\n+print("debug")'
}, toolContext)
const works = await workStore.listBySessionId(session.id)
const results = await workResultStore.listByWorkId(works[0].id)
const metadata = results[0].metadata as Record<string, unknown>
const changeSet = metadata.changeSet as Record<string, unknown>
assert.equal(result.metadata?.reviewSourceKind, 'change-set')
assert.equal(metadata.sourceKind, 'change-set')
assert.equal(changeSet.before, 'from manim import Scene')
assert.equal(changeSet.after, 'from manim import *\nprint("debug")')
assert.match(String(changeSet.diff), /@@ -1 \+1,2 @@/)
})
await run('ai-review tool creates reviewer session and review report result', async () => {
const workspace = await createWorkspace()
await writeFile(path.join(workspace, 'sample.py'), 'from manim import *\nprint("debug")\n', 'utf8')
const { runtime, registry, sessionStore, taskStore, workStore, workResultStore } = createTestRuntime({
askForConfirmation: async () => 'once'
})
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'AI Review Parent',
directory: workspace,
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
}))
const assistantMessage = await runtime.createAssistantMessage(session)
const runState = runtime.createRun(session, 'review sample.py')
const tool = registry.get('ai-review')
assert.ok(tool)
const toolContext: StudioRuntimeBackedToolContext = {
projectId: 'project-1',
session,
run: runState,
assistantMessage,
eventBus: new InMemoryStudioEventBus(),
taskStore,
workStore,
workResultStore,
sessionStore,
askForConfirmation: async () => 'once',
ask: async () => 'once',
runSubagent: (input: Parameters<typeof runtime.runSubagent>[0]) => runtime.runSubagent(input)
}
const result = await tool!.execute({ path: 'sample.py' }, toolContext)
const children = await sessionStore.listChildren(session.id)
const tasks = await taskStore.listBySessionId(session.id)
const works = await workStore.listBySessionId(session.id)
const results = await workResultStore.listByWorkId(works[0].id)
const findings = Array.isArray(results[0].metadata?.findings) ? results[0].metadata?.findings as Array<{ title?: string, path?: string, line?: number, code?: string }> : []
assert.equal(children.length, 1)
assert.equal(children[0].agentType, 'reviewer')
assert.equal(tasks.length, 1)
assert.equal(tasks[0].type, 'ai-review')
assert.equal(tasks[0].status, 'completed')
assert.equal(works.length, 1)
assert.equal(works[0].type, 'review')
assert.equal(works[0].status, 'completed')
assert.equal(results.length, 1)
assert.equal(results[0].kind, 'review-report')
assert.equal(works[0].currentResultId, results[0].id)
assert.match(result.output, /<review_result>/)
assert.ok(findings.length >= 2)
assert.equal(findings[0]?.title, '使用了通配符 Manim 导入')
assert.equal(findings[0]?.code, 'manim.wildcard-import')
assert.equal(findings[0]?.path, 'sample.py')
assert.equal(findings[0]?.line, 1)
})
await run('task tool spawns child session and creates linked work', async () => {
const workspace = await createWorkspace()
const { runtime, sessionStore, taskStore, messageStore, workStore } = createTestRuntime({
askForConfirmation: async () => 'once'
})
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Parent',
directory: workspace,
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
}))
await runtime.run({
projectId: 'project-1',
session,
inputText: '/review architecture review :: please review this structure'
})
const children = await sessionStore.listChildren(session.id)
const tasks = await taskStore.listBySessionId(session.id)
const works = await workStore.listBySessionId(session.id)
const assistantMessage = await findLastAssistantMessageWithTool(messageStore, session.id)
const toolPart = assistantMessage?.parts.find((part) => part.type === 'tool')
assert.equal(children.length, 1)
assert.equal(tasks.length, 1)
assert.equal(works.length, 1)
assert.equal(tasks[0].status, 'completed')
assert.equal(tasks[0].workId, works[0].id)
assert.equal(works[0].type, 'review')
assert.equal(works[0].status, 'completed')
assert.equal(works[0].latestTaskId, tasks[0].id)
assert.ok(toolPart && toolPart.type === 'tool' && toolPart.state.status === 'completed')
assert.match(toolPart && toolPart.type === 'tool' ? toolPart.state.output : '', /<task_result>/)
assert.match(toolPart && toolPart.type === 'tool' ? toolPart.state.output : '', /task_id:/)
})
await run('render work sync maps success into render-output result', async () => {
const { workStore, workResultStore } = createTestRuntime()
const work = await workStore.create(createStudioWork({
sessionId: 'sess_test',
runId: 'run_test',
type: 'video',
title: 'Render algebra',
status: 'running'
}))
const task = createStudioTask({
sessionId: 'sess_test',
runId: 'run_test',
workId: work.id,
type: 'render',
status: 'completed',
title: 'Render algebra',
metadata: {
jobId: 'job_123',
result: {
status: 'completed',
data: {
outputMode: 'video',
videoUrl: '/tmp/output.mp4',
code: 'from manim import *',
usedAI: true,
quality: 'medium',
generationType: 'ai'
},
timestamp: Date.now()
}
}
})
const synced = await syncRenderWorkFromTask({ workStore, workResultStore }, task)
const results = await workResultStore.listByWorkId(work.id)
assert.ok(synced)
assert.equal(synced?.work.status, 'completed')
assert.equal(synced?.work.currentResultId, results[0].id)
assert.equal(results.length, 1)
assert.equal(results[0].kind, 'render-output')
assert.match(results[0].summary, /Render completed/)
assert.equal(results[0].attachments?.[0]?.path, '/tmp/output.mp4')
})
await run('render work sync maps failure into failure-report result', async () => {
const { workStore, workResultStore } = createTestRuntime()
const work = await workStore.create(createStudioWork({
sessionId: 'sess_test',
runId: 'run_test',
type: 'video',
title: 'Render algebra',
status: 'running'
}))
const task = createStudioTask({
sessionId: 'sess_test',
runId: 'run_test',
workId: work.id,
type: 'render',
status: 'failed',
title: 'Render algebra',
metadata: {
jobId: 'job_456',
stage: 'rendering',
result: {
status: 'failed',
data: {
error: 'LaTeX compile failed',
details: 'Missing package',
outputMode: 'video'
},
timestamp: Date.now()
}
}
})
const synced = await syncRenderWorkFromTask({ workStore, workResultStore }, task)
const results = await workResultStore.listByWorkId(work.id)
assert.ok(synced)
assert.equal(synced?.work.status, 'failed')
assert.equal(results.length, 1)
assert.equal(results[0].kind, 'failure-report')
assert.equal(results[0].summary, 'LaTeX compile failed')
assert.equal(results[0].metadata?.stage, 'rendering')
})
await run('render failure feedback writes assistant message', async () => {
const { sessionStore, messageStore, partStore } = createTestRuntime()
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Render Session',
directory: await createWorkspace(),
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
}))
await publishRenderFailureFeedback({
task: createStudioTask({
sessionId: session.id,
runId: 'run_test',
type: 'render',
status: 'failed',
title: 'Render algebra',
metadata: {
jobId: 'job_789',
result: {
status: 'failed',
data: {
error: 'FFmpeg missing',
details: 'Binary not found',
outputMode: 'video'
},
timestamp: Date.now()
}
}
}),
sessionStore,
messageStore,
partStore
})
const messages = await messageStore.listBySessionId(session.id)
const assistantMessage = messages.find((message): message is StudioAssistantMessage => message.role === 'assistant')
const textPart = assistantMessage?.parts[0]
assert.ok(assistantMessage)
assert.ok(textPart && textPart.type === 'text')
assert.match(textPart && textPart.type === 'text' ? textPart.text : '', /Render task failed: Render algebra/)
assert.match(textPart && textPart.type === 'text' ? textPart.text : '', /render_job_id: job_789/)
assert.match(textPart && textPart.type === 'text' ? textPart.text : '', /error: FFmpeg missing/)
})
await run('skill tool loads local skill envelope', async () => {
const workspace = await createWorkspace()
const skillDir = path.join(workspace, '.manimcat', 'skills', 'demo-skill')
await mkdir(skillDir, { recursive: true })
await writeFile(path.join(skillDir, 'SKILL.md'), '# Demo Skill\n\nYou are a local test skill.', 'utf8')
const { runtime, sessionStore, messageStore } = createTestRuntime({
askForConfirmation: async () => 'once'
})
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Skill Session',
directory: workspace,
permissionLevel: 'L4',
permissionRules: defaultRulesForLevel('L4')
}))
await runtime.run({
projectId: 'project-1',
session,
inputText: '/skill demo-skill'
})
const assistantMessage = await findLastAssistantMessageWithTool(messageStore, session.id)
const toolPart = assistantMessage?.parts.find((part) => part.type === 'tool')
assert.ok(toolPart && toolPart.type === 'tool' && toolPart.state.status === 'completed')
const output = toolPart && toolPart.type === 'tool' && toolPart.state.status === 'completed' ? toolPart.state.output : ''
assert.match(output, /<skill_content name="demo-skill">/)
assert.match(output, /<skill_files>/)
})
await run('permission gating blocks until reply and reject stops run', async () => {
const workspace = await createWorkspace()
const skillDir = path.join(workspace, '.manimcat', 'skills', 'blocked-skill')
await mkdir(skillDir, { recursive: true })
await writeFile(path.join(skillDir, 'SKILL.md'), '# Blocked Skill\n\nShould require permission.', 'utf8')
const permissionService = new StudioPermissionService()
const { runtime, sessionStore } = createTestRuntime({
permissionService
})
const session = await sessionStore.create(createStudioSession({
projectId: 'project-1',
agentType: 'builder',
title: 'Permission Session',
directory: workspace,
permissionLevel: 'L1',
permissionRules: defaultRulesForLevel('L1')
}))
const runPromise = runtime.run({
projectId: 'project-1',
session,
inputText: '/skill blocked-skill'
})
await new Promise((resolve) => setTimeout(resolve, 10))
const pending = permissionService.listPending()
assert.equal(pending.length, 1)
assert.equal(pending[0].permission, 'skill')
const replied = permissionService.reply({
requestID: pending[0].id,
reply: 'reject'
})
assert.equal(replied, true)
const result = await runPromise
assert.equal(result.run.status, 'failed')
assert.equal(permissionService.listPending().length, 0)
})
console.log('All studio-agent tests passed')
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})