Skip to content

Commit c078b6e

Browse files
committed
feat: add plot studio timing observability
1 parent 5053831 commit c078b6e

12 files changed

Lines changed: 1578 additions & 1517 deletions

File tree

frontend/src/studio/hooks/use-studio-session.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import type { ReactNode } from 'react'
12
import { act, renderHook } from '@testing-library/react'
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { I18nProvider } from '../../i18n'
35
import { useStudioSession } from './use-studio-session'
46
import { createStudioSession, getPendingStudioPermissions, getStudioSessionSnapshot } from '../api/studio-agent-api'
57
import type { StudioPermissionRequest, StudioSession, StudioSessionSnapshot } from '../protocol/studio-agent-types'
@@ -28,6 +30,10 @@ const mockedCreateStudioSession = vi.mocked(createStudioSession)
2830
const mockedGetPendingStudioPermissions = vi.mocked(getPendingStudioPermissions)
2931
const mockedGetStudioSessionSnapshot = vi.mocked(getStudioSessionSnapshot)
3032

33+
function wrapper({ children }: { children: ReactNode }) {
34+
return <I18nProvider>{children}</I18nProvider>
35+
}
36+
3137
function createSession(id = 'session-1'): StudioSession {
3238
const now = '2026-03-22T00:00:00.000Z'
3339
return {
@@ -90,7 +96,7 @@ describe('useStudioSession', () => {
9096
mockedGetPendingStudioPermissions.mockResolvedValue([createPermission(session.id)])
9197
mockedGetStudioSessionSnapshot.mockResolvedValue(createSnapshot(session, 'running'))
9298

93-
const { result } = renderHook(() => useStudioSession())
99+
const { result } = renderHook(() => useStudioSession(), { wrapper })
94100

95101
await act(async () => {
96102
await Promise.resolve()
@@ -114,7 +120,7 @@ describe('useStudioSession', () => {
114120
mockedCreateStudioSession.mockResolvedValue(session)
115121
mockedGetStudioSessionSnapshot.mockResolvedValue(createSnapshot(session))
116122

117-
const { result } = renderHook(() => useStudioSession())
123+
const { result } = renderHook(() => useStudioSession(), { wrapper })
118124

119125
await act(async () => {
120126
await Promise.resolve()

src/routes/studio-agent.route.ts

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from './helpers/studio-agent-run-request'
1616
import { ensureDefaultStudioWorkspaceExists } from '../studio-agent/workspace/default-studio-workspace'
1717
import { createLogger } from '../utils/logger'
18+
import { logPlotStudioTiming, readElapsedMs } from '../studio-agent/observability/plot-studio-timing'
1819

1920
const router = express.Router()
2021
const logger = createLogger('StudioAgentRoute')
@@ -119,25 +120,13 @@ router.get('/studio-agent/works/:sessionId', authMiddleware, asyncHandler(async
119120
}))
120121

121122
router.get('/studio-agent/events', authMiddleware, asyncHandler(async (req, res) => {
122-
logger.info('Studio SSE client connected', {
123-
ip: req.ip,
124-
userAgent: req.get('user-agent') ?? '',
125-
})
126123
res.setHeader('Content-Type', 'text/event-stream')
127124
res.setHeader('Cache-Control', 'no-cache, no-transform')
128125
res.setHeader('Connection', 'keep-alive')
129126
res.flushHeaders?.()
130127

131128
const backlog = studioRuntime.listExternalEvents()
132129
for (const event of backlog) {
133-
logger.info('Writing backlog SSE event', {
134-
type: event.type,
135-
sessionId: (event.properties as { sessionId?: string; sessionID?: string; run?: { sessionId?: string } })?.sessionId
136-
?? (event.properties as { sessionID?: string })?.sessionID
137-
?? (event.properties as { run?: { sessionId?: string } })?.run?.sessionId
138-
?? null,
139-
runId: (event.properties as { runId?: string })?.runId ?? null,
140-
})
141130
res.write(`event: ${event.type}\n`)
142131
res.write(`data: ${JSON.stringify(event)}\n\n`)
143132
}
@@ -148,14 +137,6 @@ router.get('/studio-agent/events', authMiddleware, asyncHandler(async (req, res)
148137
}, 15000)
149138

150139
const unsubscribe = studioRuntime.subscribeExternalEvents((event) => {
151-
logger.info('Writing live SSE event', {
152-
type: event.type,
153-
sessionId: (event.properties as { sessionId?: string; sessionID?: string; run?: { sessionId?: string } })?.sessionId
154-
?? (event.properties as { sessionID?: string })?.sessionID
155-
?? (event.properties as { run?: { sessionId?: string } })?.run?.sessionId
156-
?? null,
157-
runId: (event.properties as { runId?: string })?.runId ?? null,
158-
})
159140
res.write(`event: ${event.type}\n`)
160141
res.write(`data: ${JSON.stringify(event)}\n\n`)
161142
})
@@ -164,17 +145,14 @@ router.get('/studio-agent/events', authMiddleware, asyncHandler(async (req, res)
164145
res.write(`data: ${JSON.stringify({ type: 'studio.connected', properties: { timestamp: Date.now() } })}\n\n`)
165146

166147
req.on('close', () => {
167-
logger.info('Studio SSE client disconnected', {
168-
ip: req.ip,
169-
userAgent: req.get('user-agent') ?? '',
170-
})
171148
clearInterval(heartbeat)
172149
unsubscribe()
173150
res.end()
174151
})
175152
}))
176153

177154
router.post('/studio-agent/runs', authMiddleware, asyncHandler(async (req, res) => {
155+
const requestStartedAt = Date.now()
178156
const parsed = parseStudioCreateRunRequest(req.body)
179157
const sessionId = parsed.sessionId
180158
const inputText = parsed.inputText
@@ -189,19 +167,15 @@ router.post('/studio-agent/runs', authMiddleware, asyncHandler(async (req, res)
189167
return sendStudioError(res, 404, 'NOT_FOUND', 'Session not found', { sessionId })
190168
}
191169

192-
logger.info('Studio run requested', {
170+
logPlotStudioTiming(session.studioKind, 'http.run.requested', {
193171
sessionId,
194172
projectId,
195-
agent: session.agentType,
196-
studioKind: session.studioKind,
197-
inputPreview: summarizeInput(inputText),
198173
inputLength: inputText.length,
199174
hasCustomApiConfig: Boolean(
200175
parsed.customApiConfig?.apiUrl?.trim()
201176
&& parsed.customApiConfig?.apiKey?.trim()
202177
&& parsed.customApiConfig?.model?.trim()
203178
),
204-
toolChoice: parsed.toolChoice ?? null,
205179
})
206180

207181
const started = await studioRuntime.startRun({
@@ -221,10 +195,11 @@ router.post('/studio-agent/runs', authMiddleware, asyncHandler(async (req, res)
221195
})
222196
}
223197

224-
logger.info('Studio run started', {
198+
logPlotStudioTiming(session.studioKind, 'http.run.accepted', {
225199
sessionId,
226200
runId: started.run.id,
227201
assistantMessageId: started.assistantMessage.id,
202+
durationMs: readElapsedMs(requestStartedAt),
228203
})
229204

230205
await studioRuntime.syncSession(session.id)
@@ -352,8 +327,3 @@ router.post('/studio-agent/permissions/reply', authMiddleware, replyPermissionHa
352327
router.post('/studio-agent/permissions/:requestID/reply', authMiddleware, replyPermissionHandler)
353328

354329
export default router
355-
356-
function summarizeInput(inputText: string): string {
357-
const normalized = inputText.replace(/\s+/g, ' ').trim()
358-
return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized
359-
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createLogger } from '../../utils/logger'
2+
3+
const logger = createLogger('PlotStudioTiming')
4+
5+
export function isPlotStudioKind(studioKind?: string | null): boolean {
6+
return studioKind === 'plot'
7+
}
8+
9+
export function logPlotStudioTiming(
10+
studioKind: string | null | undefined,
11+
event: string,
12+
data: Record<string, unknown>,
13+
level: 'info' | 'warn' = 'info'
14+
): void {
15+
if (!isPlotStudioKind(studioKind)) {
16+
return
17+
}
18+
19+
logger[level](event, data)
20+
}
21+
22+
export function readElapsedMs(startedAt: number): number {
23+
return Math.max(0, Date.now() - startedAt)
24+
}
25+
26+
export function readRunElapsedMs(run: { createdAt?: string }): number | null {
27+
if (!run.createdAt) {
28+
return null
29+
}
30+
31+
const createdAt = new Date(run.createdAt).getTime()
32+
if (!Number.isFinite(createdAt)) {
33+
return null
34+
}
35+
36+
return Math.max(0, Date.now() - createdAt)
37+
}

src/studio-agent/orchestration/studio-execution-policy.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const MANIM_POLICY: StudioExecutionPolicy = {
2626
'Before rendering, make sure the target Manim code exists in the workspace or is fully prepared in the render request.',
2727
'If the requested scene flow, assets, render mode, or target file is ambiguous, ask before rendering.'
2828
],
29-
builderContinueText: 'I will continue the current Manim Studio subagent work first.',
29+
builderContinueText: '延续当前正在进行的子代理工作。',
3030
builderTaskIntentText: (subagentType, skillName) => {
3131
const skillSegment = skillName ? ` using skill "${skillName}"` : ''
3232
return `I will hand this off to the ${subagentType} subagent${skillSegment}.`
@@ -38,8 +38,8 @@ const MANIM_POLICY: StudioExecutionPolicy = {
3838
: 'This input did not trigger an automatic planning path in Manim Studio.'
3939
),
4040
builderReminderTexts: {
41-
runningWork: (title) => `There is still in-progress work in this session: ${title}`,
42-
failedRender: 'The most recent render failed. Check the failure details before attempting another render.',
41+
runningWork: (title) => `当前会话存在进行中的 Work:${title}`,
42+
failedRender: '最近一次 render 结果失败,请先确认失败原因再尝试。',
4343
unsupportedTools: (toolNames) => `Automatic planning does not cover these requested tools yet: ${toolNames.join(', ')}.`,
4444
pendingEvents: (summaries) => `Pending backend updates: ${summaries.join(' | ')}`
4545
},
@@ -58,7 +58,7 @@ const PLOT_POLICY: StudioExecutionPolicy = {
5858
'Before rendering, make sure the target matplotlib code exists in the workspace or is fully prepared in the render request.',
5959
'If the chart type, data source, subplot layout, axes, labels, or output target is ambiguous, ask before rendering.'
6060
],
61-
builderContinueText: 'I will continue the current Plot Studio subagent work first.',
61+
builderContinueText: '延续当前正在进行的子代理工作。',
6262
builderTaskIntentText: (subagentType, skillName) => {
6363
const skillSegment = skillName ? ` using skill "${skillName}"` : ''
6464
return `I will hand this off to the ${subagentType} subagent${skillSegment}.`
@@ -70,8 +70,8 @@ const PLOT_POLICY: StudioExecutionPolicy = {
7070
: 'This input did not trigger an automatic planning path in Plot Studio.'
7171
),
7272
builderReminderTexts: {
73-
runningWork: (title) => `There is still in-progress work in this session: ${title}`,
74-
failedRender: 'The most recent plot render failed. Check the failure details before attempting another render.',
73+
runningWork: (title) => `当前会话存在进行中的 Work:${title}`,
74+
failedRender: '最近一次 render 结果失败,请先确认失败原因再尝试。',
7575
unsupportedTools: (toolNames) => `Automatic planning does not cover these requested tools yet: ${toolNames.join(', ')}.`,
7676
pendingEvents: (summaries) => `Pending backend updates: ${summaries.join(' | ')}`
7777
},
@@ -84,3 +84,6 @@ const PLOT_POLICY: StudioExecutionPolicy = {
8484
export function getStudioExecutionPolicy(studioKind: StudioKind = 'manim'): StudioExecutionPolicy {
8585
return studioKind === 'plot' ? PLOT_POLICY : MANIM_POLICY
8686
}
87+
88+
89+

src/studio-agent/orchestration/studio-message-history.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import type OpenAI from 'openai'
22
import type { StudioAssistantMessage, StudioMessage, StudioToolPart } from '../domain/types'
3-
import { createLogger } from '../../utils/logger'
43
import {
5-
summarizeConversationMessageForDebug,
64
type StudioStoredAssistantPayload,
75
type StudioStoredAssistantToolCall,
86
} from './studio-provider-message'
9-
const logger = createLogger('StudioMessageHistory')
107

118
export function buildStudioConversationMessages(input: {
129
messages: StudioMessage[]
@@ -37,12 +34,6 @@ function toConversationMessages(message: StudioMessage): OpenAI.Chat.Completions
3734
...toolMessages
3835
]
3936

40-
logger.info('Rehydrating stored assistant provider payload', {
41-
messageId: message.id,
42-
sessionId: message.sessionId,
43-
conversationMessages: conversationMessages.map((item) => summarizeConversationMessageForDebug(item)),
44-
})
45-
4637
return conversationMessages
4738
}
4839

0 commit comments

Comments
 (0)