Skip to content

Commit c6a36e1

Browse files
Contentrainclaude
andcommitted
fix(webhooks): complete all 8 webhook event dispatches and fix test coverage
- Add cdn.build_complete dispatch to CDN trigger endpoint - Add 5 webhook dispatches to conversation engine (content.saved, content.deleted, model.saved, branch.merged, branch.rejected) - Move webhook emission from chat.post.ts into conversation-engine.ts to cover both Studio chat and Conversation API paths - Add 7 missing ConversationKeysPanel UI strings to dictionary - Fix conversation_keys.description accuracy (remove plan requirement) - Add emitWebhookEvent stubs to branch, content, and CDN tests - Add conversation engine webhook dispatch assertions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d2be160 commit c6a36e1

8 files changed

Lines changed: 150 additions & 11 deletions

File tree

.contentrain/content/system/ui-strings/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,18 +244,25 @@
244244
"content.vocabulary_empty_description": "Ask the chat agent to add terms — e.g. \"Add vocabulary term 'CTA' meaning 'Call to Action'.\" Terms keep translations consistent across locales.",
245245
"content.vocabulary_empty_title": "No vocabulary terms",
246246
"conversation_keys.copy": "Copy API Key",
247+
"conversation_keys.copy_warning": "Copy this key now — it will not be shown again.",
247248
"conversation_keys.create": "Create API Key",
249+
"conversation_keys.create_error": "Failed to create API key. Please try again.",
248250
"conversation_keys.created": "API key created — copy it now, it won't be shown again.",
251+
"conversation_keys.creating": "Creating...",
249252
"conversation_keys.custom_instructions": "Custom Instructions",
250253
"conversation_keys.delete_confirm": "Revoke this API key? External services using it will lose access.",
251-
"conversation_keys.description": "API keys for external services to access the conversation engine. Business plan required.",
254+
"conversation_keys.description": "API keys for external services to access the conversation engine.",
255+
"conversation_keys.last_used": "Last used",
252256
"conversation_keys.model": "AI Model",
253257
"conversation_keys.monthly_limit": "Monthly Limit",
254258
"conversation_keys.name": "Key Name",
259+
"conversation_keys.name_placeholder": "e.g. Production API, Staging Bot",
260+
"conversation_keys.never_used": "Never used",
255261
"conversation_keys.no_keys": "No API keys",
256262
"conversation_keys.no_keys_description": "Create an API key to enable external services to chat with your content.",
257263
"conversation_keys.rate_limit": "Rate Limit (per minute)",
258264
"conversation_keys.revoke": "Revoke Key",
265+
"conversation_keys.revoke_error": "Failed to revoke API key. Please try again.",
259266
"conversation_keys.revoked": "Key revoked",
260267
"conversation_keys.role": "Role",
261268
"conversation_keys.title": "Conversation API",

server/api/workspaces/[workspaceId]/projects/[projectId]/cdn/builds/trigger.post.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ export default defineEventHandler(async (event) => {
8686
completed_at: new Date().toISOString(),
8787
})
8888

89+
// Emit webhook event (fire-and-forget)
90+
emitWebhookEvent(projectId, workspaceId, 'cdn.build_complete', {
91+
buildId: build.id,
92+
status: result.error ? 'failed' : 'success',
93+
filesUploaded: result.filesUploaded,
94+
durationMs: result.durationMs,
95+
error: result.error ?? null,
96+
}).catch(() => {})
97+
8998
await eventStream.push(JSON.stringify({
9099
phase: 'complete',
91100
message: result.error ? `Build failed: ${result.error}` : `Build complete — ${result.filesUploaded} files in ${result.durationMs}ms`,

server/api/workspaces/[workspaceId]/projects/[projectId]/chat.post.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,6 @@ export default defineEventHandler(async (event) => {
202202
let totalInputTokens = 0
203203
let totalOutputTokens = 0
204204
let lastAssistantContent: AIContentBlock[] = []
205-
let lastAffected: Record<string, unknown> = {}
206205

207206
try {
208207
for await (const evt of runConversationLoop(
@@ -218,7 +217,6 @@ export default defineEventHandler(async (event) => {
218217
totalInputTokens = (evt.usage as { inputTokens: number })?.inputTokens ?? 0
219218
totalOutputTokens = (evt.usage as { outputTokens: number })?.outputTokens ?? 0
220219
lastAssistantContent = (evt.lastContent as AIContentBlock[]) ?? []
221-
lastAffected = (evt.affected as Record<string, unknown>) ?? {}
222220

223221
// Forward the done event without lastContent (not needed by client)
224222
await eventStream.push(JSON.stringify({
@@ -244,14 +242,7 @@ export default defineEventHandler(async (event) => {
244242
workspaceId, session.user.id, usageSource, usageMonth,
245243
)
246244

247-
// Emit webhook events for content changes (fire-and-forget)
248-
if (lastAffected.snapshotChanged) {
249-
emitWebhookEvent(projectId, workspaceId, 'content.saved', {
250-
models: (lastAffected.models as string[]) ?? [],
251-
source: 'studio',
252-
conversationId,
253-
}).catch(() => {})
254-
}
245+
// Webhook events are now emitted from conversation-engine.ts per tool execution
255246
}
256247
catch (e: unknown) {
257248
const msg = e instanceof Error ? e.message : 'Chat error'

server/utils/conversation-engine.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,13 @@ export async function executeToolWithAutoMerge(
313313
else {
314314
result = { ...summarizeWriteResult(writeResult), merged: false, workflow }
315315
}
316+
317+
// Emit webhook event (fire-and-forget)
318+
emitWebhookEvent(projectId, workspaceId, 'content.saved', {
319+
models: [modelId],
320+
locale,
321+
source: 'conversation',
322+
}).catch(() => {})
316323
break
317324
}
318325

@@ -340,6 +347,14 @@ export async function executeToolWithAutoMerge(
340347
else {
341348
result = { ...summarizeWriteResult(writeResult), merged: false, reviewBranch: writeResult.branch }
342349
}
350+
351+
// Emit webhook event (fire-and-forget)
352+
emitWebhookEvent(projectId, workspaceId, 'content.deleted', {
353+
models: [modelId],
354+
locale,
355+
entryIds: params.entryIds as string[],
356+
source: 'conversation',
357+
}).catch(() => {})
343358
break
344359
}
345360

@@ -356,6 +371,12 @@ export async function executeToolWithAutoMerge(
356371
else {
357372
result = { ...summarizeWriteResult(writeResult), merged: false, reviewBranch: writeResult.branch }
358373
}
374+
375+
// Emit webhook event (fire-and-forget)
376+
emitWebhookEvent(projectId, workspaceId, 'model.saved', {
377+
modelId: (params as Record<string, unknown>).id as string,
378+
source: 'conversation',
379+
}).catch(() => {})
359380
break
360381
}
361382

@@ -444,6 +465,12 @@ export async function executeToolWithAutoMerge(
444465
affected.snapshotChanged = true
445466
affected.branchesChanged = true
446467
result = mergeResult
468+
469+
// Emit webhook event (fire-and-forget)
470+
emitWebhookEvent(projectId, workspaceId, 'branch.merged', {
471+
branch: branchToMerge,
472+
source: 'conversation',
473+
}).catch(() => {})
447474
break
448475
}
449476

@@ -460,6 +487,12 @@ export async function executeToolWithAutoMerge(
460487
await engine.rejectBranch(branchToReject)
461488
affected.branchesChanged = true
462489
result = { rejected: true }
490+
491+
// Emit webhook event (fire-and-forget)
492+
emitWebhookEvent(projectId, workspaceId, 'branch.rejected', {
493+
branch: branchToReject,
494+
source: 'conversation',
495+
}).catch(() => {})
463496
break
464497
}
465498

tests/integration/cdn-routes.integration.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ describe('CDN route integration', () => {
303303
contentRoot: '.',
304304
}))
305305
vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue({}))
306+
vi.stubGlobal('emitWebhookEvent', vi.fn().mockResolvedValue(undefined))
306307
vi.stubGlobal('executeCDNBuild', vi.fn().mockImplementation(async ({ onProgress }) => {
307308
onProgress?.({ phase: 'upload', message: 'Uploading files', current: 1, total: 2 })
308309
return {
@@ -337,6 +338,13 @@ describe('CDN route integration', () => {
337338
expect(eventStreamState.stream.push).toHaveBeenCalledWith(expect.stringContaining('"phase":"complete"'))
338339
expect(eventStreamState.stream.close).toHaveBeenCalledOnce()
339340
expect(eventStreamState.stream.onClosed).toHaveBeenCalledOnce()
341+
342+
const emitMock = vi.mocked(globalThis.emitWebhookEvent as ReturnType<typeof vi.fn>)
343+
expect(emitMock).toHaveBeenCalledWith('project-1', 'workspace-1', 'cdn.build_complete', expect.objectContaining({
344+
buildId: 'build-1',
345+
status: 'success',
346+
filesUploaded: 2,
347+
}))
340348
})
341349

342350
it('returns 404 for CDN build history requested through the wrong workspace path', async () => {

tests/integration/content-routes.integration.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('content route integration', () => {
5252
vi.stubGlobal('invalidateBrainCache', vi.fn())
5353
vi.stubGlobal('createContentEngine', vi.fn().mockReturnValue({ saveContent, mergeBranch }))
5454
vi.stubGlobal('useMediaProvider', vi.fn().mockReturnValue({ listAssets }))
55+
vi.stubGlobal('emitWebhookEvent', vi.fn().mockResolvedValue(undefined))
5556
vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
5657
trackMediaUsage,
5758
}))

tests/unit/branch-moderation-routes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ function stubRouteGlobals(branch: string) {
1717
if (key === 'branch') return branch
1818
return undefined
1919
}))
20+
vi.stubGlobal('emitWebhookEvent', vi.fn().mockResolvedValue(undefined))
2021
vi.stubGlobal('useDatabaseProvider', vi.fn(() => ({
2122
getUserClient: vi.fn((accessToken: string) => {
2223
const userClient = (globalThis as typeof globalThis & {

tests/unit/conversation-engine-regression.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,95 @@ describe('conversation engine regression', () => {
1616
vi.unstubAllGlobals()
1717
})
1818

19+
it('emits webhook events for content-mutating tools', async () => {
20+
const { emptyAffected } = await import('../../server/utils/agent-types')
21+
const git = {} as GitProvider
22+
const permissions: AgentPermissions = {
23+
workspaceRole: 'owner',
24+
projectRole: null,
25+
specificModels: false,
26+
allowedModels: [],
27+
allowedLocales: [],
28+
availableTools: ['save_content', 'delete_content', 'save_model', 'merge_branch', 'reject_branch'],
29+
}
30+
const uiContext: ChatUIContext = {
31+
activeModelId: null,
32+
activeLocale: 'en',
33+
activeEntryId: null,
34+
panelState: 'overview',
35+
activeBranch: null,
36+
}
37+
38+
vi.stubGlobal('emptyAffected', emptyAffected)
39+
vi.stubGlobal('hasFeature', vi.fn().mockReturnValue(true))
40+
41+
const mockEmit = vi.fn().mockResolvedValue(undefined)
42+
vi.stubGlobal('emitWebhookEvent', mockEmit)
43+
vi.stubGlobal('invalidateBrainCache', vi.fn())
44+
vi.stubGlobal('getOrBuildBrainCache', vi.fn().mockResolvedValue({
45+
models: new Map([['posts', { id: 'posts', kind: 'collection' }]]),
46+
}))
47+
48+
const writeResult = {
49+
branch: 'cr/content-posts-en',
50+
commit: { sha: 'abc123' },
51+
diff: [],
52+
validation: { valid: true, errors: [] },
53+
}
54+
const mockEngine = {
55+
saveContent: vi.fn().mockResolvedValue(writeResult),
56+
deleteContent: vi.fn().mockResolvedValue(writeResult),
57+
saveModel: vi.fn().mockResolvedValue(writeResult),
58+
mergeBranch: vi.fn().mockResolvedValue({ merged: true }),
59+
rejectBranch: vi.fn().mockResolvedValue(undefined),
60+
}
61+
62+
const { executeToolWithAutoMerge } = await loadConversationEngineModule()
63+
64+
// Test save_content emits content.saved
65+
await executeToolWithAutoMerge(
66+
'save_content', { model: 'posts', locale: 'en', data: { e1: { title: 'Hello' } } },
67+
mockEngine as never, git, 'user@test.com', 'user-1', 'content', 'auto-merge',
68+
permissions, 'pro', 'project-1', 'workspace-1', uiContext,
69+
)
70+
expect(mockEmit).toHaveBeenCalledWith('project-1', 'workspace-1', 'content.saved', expect.objectContaining({
71+
models: ['posts'], source: 'conversation',
72+
}))
73+
74+
// Test delete_content emits content.deleted
75+
mockEmit.mockClear()
76+
await executeToolWithAutoMerge(
77+
'delete_content', { model: 'posts', locale: 'en', entryIds: ['e1'] },
78+
mockEngine as never, git, 'user@test.com', 'user-1', 'content', 'auto-merge',
79+
permissions, 'pro', 'project-1', 'workspace-1', uiContext,
80+
)
81+
expect(mockEmit).toHaveBeenCalledWith('project-1', 'workspace-1', 'content.deleted', expect.objectContaining({
82+
models: ['posts'], entryIds: ['e1'], source: 'conversation',
83+
}))
84+
85+
// Test merge_branch emits branch.merged
86+
mockEmit.mockClear()
87+
await executeToolWithAutoMerge(
88+
'merge_branch', { branch: 'cr/test' },
89+
mockEngine as never, git, 'user@test.com', 'user-1', 'content', 'auto-merge',
90+
permissions, 'pro', 'project-1', 'workspace-1', uiContext,
91+
)
92+
expect(mockEmit).toHaveBeenCalledWith('project-1', 'workspace-1', 'branch.merged', expect.objectContaining({
93+
branch: 'cr/test', source: 'conversation',
94+
}))
95+
96+
// Test reject_branch emits branch.rejected
97+
mockEmit.mockClear()
98+
await executeToolWithAutoMerge(
99+
'reject_branch', { branch: 'cr/test' },
100+
mockEngine as never, git, 'user@test.com', 'user-1', 'content', 'auto-merge',
101+
permissions, 'pro', 'project-1', 'workspace-1', uiContext,
102+
)
103+
expect(mockEmit).toHaveBeenCalledWith('project-1', 'workspace-1', 'branch.rejected', expect.objectContaining({
104+
branch: 'cr/test', source: 'conversation',
105+
}))
106+
})
107+
19108
it('returns unavailable schema validation instead of a fake 100 score', async () => {
20109
const { emptyAffected } = await import('../../server/utils/agent-types')
21110
const git = {} as GitProvider

0 commit comments

Comments
 (0)