Skip to content

Commit 18054d0

Browse files
authored
feat: model ContentBlock as discriminated union per ACP spec (#555)
1 parent eb2f242 commit 18054d0

File tree

10 files changed

+984
-332
lines changed

10 files changed

+984
-332
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@frontman-ai/frontman-protocol": minor
3+
"@frontman-ai/client": minor
4+
"@frontman-ai/frontman-client": minor
5+
---
6+
7+
Model ContentBlock as a discriminated union per ACP spec instead of a flat record with optional fields. Adds TextContent, ImageContent, AudioContent, ResourceLink, and EmbeddedResource variants with compile-time type safety. Wire format unchanged.

libs/client/src/Client__FrontmanProvider.res

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ let textDeltaBuffer = Client__TextDeltaBuffer.make(~onFlush=(~taskId, ~text) =>
1919
})
2020
let () = Client__TextDeltaBuffer.active := Some(textDeltaBuffer)
2121

22+
// Extract text from a contentBlock (returns Some for TextContent, None for other variants)
23+
let getContentBlockText = (block: Types.contentBlock): option<string> =>
24+
switch block {
25+
| TextContent({text}) => Some(text)
26+
| ImageContent(_) | AudioContent(_) | ResourceLink(_) | EmbeddedResource(_) => None
27+
}
28+
2229
// Re-export status types for consumers
2330
type connectionState = Reducer.Selectors.connectionStatus
2431
type mcpState = Reducer.Selectors.mcpStatus
@@ -147,11 +154,11 @@ module Provider = {
147154
// Message end is signaled by session/prompt response with stopReason.
148155
// Buffer text deltas and flush once per animation frame to avoid
149156
// dozens of full state rebuilds per second during fast streaming.
150-
content->Option.flatMap(c => c.text)->Option.forEach(text => {
157+
content->Option.flatMap(getContentBlockText)->Option.forEach(text => {
151158
textDeltaBuffer.add(~taskId, ~text)
152159
})
153160
| UserMessageChunk({content, timestamp}) =>
154-
content.text->Option.forEach(text => {
161+
getContentBlockText(content)->Option.forEach(text => {
155162
let id = `user-hydrated-${WebAPI.Global.crypto->WebAPI.Crypto.randomUUID}`
156163
Client__State.Actions.userMessageReceived(~taskId, ~id, ~text, ~timestamp)
157164
})
@@ -169,7 +176,7 @@ module Provider = {
169176
spawningToolName,
170177
})
171178
| ToolCallUpdate({toolCallId, status, content}) =>
172-
let text = content->Option.flatMap(c => c->Array.get(0))->Option.flatMap(i => i.content)->Option.flatMap(c => c.text)
179+
let text = content->Option.flatMap(c => c->Array.get(0))->Option.flatMap(i => i.content)->Option.flatMap(getContentBlockText)
173180
switch status {
174181
| Some("pending") =>
175182
text->Option.flatMap(t => try { Some(JSON.parseOrThrow(t)) } catch { | _ => None })->Option.forEach(input => {

libs/client/src/state/Client__State__StateReducer.res

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -513,21 +513,19 @@ let buildAttachmentContentBlocks = (attachments: array<Client__Message.fileAttac
513513
return { "user_image": true, "filename": filename };
514514
})`)(att.filename)
515515

516-
{
517-
Client__State__Types.ACPTypes.type_: "resource",
518-
text: None,
519-
uri: None,
520-
resource: Some({
516+
Client__State__Types.ACPTypes.EmbeddedResource({
517+
resource: {
521518
_meta: Some(meta),
522519
annotations: None,
523520
resource: Client__State__Types.ACPTypes.BlobResourceContents({
524521
uri: `attachment://${att.id}/${att.filename}`,
525522
mimeType: Some(att.mediaType),
526523
blob: base64Data,
527524
}),
528-
}),
529-
content: None,
530-
}
525+
},
526+
_meta: None,
527+
annotations: None,
528+
})
531529
})
532530
}
533531

libs/client/src/state/Client__Task__Types.res

Lines changed: 39 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -732,17 +732,15 @@ let annotationToContentBlocks = (annotation: Annotation.t, ~index: int): array<A
732732
}
733733
}
734734

735-
let resourceBlock: ACPTypes.contentBlock = {
736-
type_: "resource",
737-
text: None,
738-
uri: None,
739-
resource: Some({
735+
let resourceBlock: ACPTypes.contentBlock = ACPTypes.EmbeddedResource({
736+
resource: {
740737
_meta: Some(_meta),
741738
annotations: None,
742739
resource: ACPTypes.TextResourceContents({uri, mimeType: Some("text/plain"), text}),
743-
}),
744-
content: None,
745-
}
740+
},
741+
_meta: None,
742+
annotations: None,
743+
})
746744

747745
let screenshotBlock = annotation.screenshot->Option.map(screenshotDataUrl => {
748746
let (mimeType, base64Data) = parseDataUrl(screenshotDataUrl)
@@ -756,21 +754,19 @@ let annotationToContentBlocks = (annotation: Annotation.t, ~index: int): array<A
756754
screenshotMetaSchema,
757755
)
758756

759-
let block: ACPTypes.contentBlock = {
760-
type_: "resource",
761-
text: None,
762-
uri: None,
763-
resource: Some({
757+
let block: ACPTypes.contentBlock = ACPTypes.EmbeddedResource({
758+
resource: {
764759
_meta: Some(screenshotMeta),
765760
annotations: None,
766761
resource: ACPTypes.BlobResourceContents({
767762
uri: `annotation://${annotation.id}/screenshot`,
768763
mimeType: Some(mimeType),
769764
blob: base64Data,
770765
}),
771-
}),
772-
content: None,
773-
}
766+
},
767+
_meta: None,
768+
annotations: None,
769+
})
774770
block
775771
})
776772

@@ -809,13 +805,11 @@ let figmaNodeToContentBlock = (
809805
resource: ACPTypes.TextResourceContents(textResource),
810806
}
811807

812-
{
813-
ACPTypes.type_: "resource",
814-
text: None,
815-
uri: None,
816-
resource: Some(embeddedResource),
817-
content: None,
818-
}
808+
ACPTypes.EmbeddedResource({
809+
resource: embeddedResource,
810+
_meta: None,
811+
annotations: None,
812+
})
819813
}
820814

821815
// Build an Image ContentBlock from FigmaNode image data
@@ -838,13 +832,11 @@ let figmaImageToContentBlock = (imageDataUrl: string): ACPTypes.contentBlock =>
838832
resource: ACPTypes.BlobResourceContents(blobResource),
839833
}
840834

841-
{
842-
ACPTypes.type_: "resource",
843-
text: None,
844-
uri: None,
845-
resource: Some(embeddedResource),
846-
content: None,
847-
}
835+
ACPTypes.EmbeddedResource({
836+
resource: embeddedResource,
837+
_meta: None,
838+
annotations: None,
839+
})
848840
}
849841

850842
// Helper: read document.title from a document reference
@@ -1012,13 +1004,11 @@ let currentPageToContentBlock = (previewFrame: Task.previewFrame): ACPTypes.cont
10121004
resource: ACPTypes.TextResourceContents(textResource),
10131005
}
10141006

1015-
{
1016-
ACPTypes.type_: "resource",
1017-
text: None,
1018-
uri: None,
1019-
resource: Some(embeddedResource),
1020-
content: None,
1021-
}
1007+
ACPTypes.EmbeddedResource({
1008+
resource: embeddedResource,
1009+
_meta: None,
1010+
annotations: None,
1011+
})
10221012
}
10231013

10241014
// Build ContentBlocks array from Task
@@ -1146,17 +1136,15 @@ let messageAnnotationToContentBlocks = (
11461136
}
11471137
}
11481138

1149-
let resourceBlock: ACPTypes.contentBlock = {
1150-
type_: "resource",
1151-
text: None,
1152-
uri: None,
1153-
resource: Some({
1139+
let resourceBlock: ACPTypes.contentBlock = ACPTypes.EmbeddedResource({
1140+
resource: {
11541141
_meta: Some(_meta),
11551142
annotations: None,
11561143
resource: ACPTypes.TextResourceContents({uri, mimeType: Some("text/plain"), text}),
1157-
}),
1158-
content: None,
1159-
}
1144+
},
1145+
_meta: None,
1146+
annotations: None,
1147+
})
11601148

11611149
let screenshotBlock = annotation.screenshot->Option.map(screenshotDataUrl => {
11621150
let (mimeType, base64Data) = parseDataUrl(screenshotDataUrl)
@@ -1170,21 +1158,19 @@ let messageAnnotationToContentBlocks = (
11701158
screenshotMetaSchema,
11711159
)
11721160

1173-
let block: ACPTypes.contentBlock = {
1174-
type_: "resource",
1175-
text: None,
1176-
uri: None,
1177-
resource: Some({
1161+
let block: ACPTypes.contentBlock = ACPTypes.EmbeddedResource({
1162+
resource: {
11781163
_meta: Some(screenshotMeta),
11791164
annotations: None,
11801165
resource: ACPTypes.BlobResourceContents({
11811166
uri: `annotation://${annotation.id}/screenshot`,
11821167
mimeType: Some(mimeType),
11831168
blob: base64Data,
11841169
}),
1185-
}),
1186-
content: None,
1187-
}
1170+
},
1171+
_meta: None,
1172+
annotations: None,
1173+
})
11881174
block
11891175
})
11901176

libs/client/test/Client__State__Types.test.res

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,22 @@ let makeTestAnnotation = (
4848
timestamp: 0.0,
4949
}
5050

51-
// Helper to extract _meta from a content block
51+
// Helper to extract _meta from an EmbeddedResource content block
5252
let getMeta = (block: ACPTypes.contentBlock): JSON.t => {
53-
let resource: ACPTypes.embeddedResource = block.resource->Option.getOrThrow
54-
resource._meta->Option.getOrThrow
53+
switch block {
54+
| EmbeddedResource({resource}) => resource._meta->Option.getOrThrow
55+
| TextContent(_) | ImageContent(_) | AudioContent(_) | ResourceLink(_) =>
56+
failwith("getMeta: expected EmbeddedResource content block")
57+
}
58+
}
59+
60+
// Helper to extract the embeddedResource from an EmbeddedResource content block
61+
let getEmbeddedResource = (block: ACPTypes.contentBlock): ACPTypes.embeddedResource => {
62+
switch block {
63+
| EmbeddedResource({resource}) => resource
64+
| TextContent(_) | ImageContent(_) | AudioContent(_) | ResourceLink(_) =>
65+
failwith("getEmbeddedResource: expected EmbeddedResource content block")
66+
}
5567
}
5668

5769
// Helper to get a string field from _meta JSON
@@ -164,7 +176,7 @@ describe("Client__State__Types", () => {
164176

165177
let blocks = Types.annotationToContentBlocks(annotation, ~index=0)
166178
let block = blocks->Array.getUnsafe(0)
167-
let embeddedResource = block.resource->Option.getOrThrow
179+
let embeddedResource = getEmbeddedResource(block)
168180

169181
switch embeddedResource.resource {
170182
| TextResourceContents(textResource) =>
@@ -191,7 +203,7 @@ describe("Client__State__Types", () => {
191203

192204
// Second block should be screenshot blob
193205
let screenshotBlock = blocks->Array.getUnsafe(1)
194-
let screenshotResource = screenshotBlock.resource->Option.getOrThrow
206+
let screenshotResource = getEmbeddedResource(screenshotBlock)
195207
let screenshotMeta = screenshotResource._meta->Option.getOrThrow
196208

197209
t->expect(getMetaBool(screenshotMeta, "annotation_screenshot"))->Expect.toBe(true)
@@ -234,7 +246,7 @@ describe("Client__State__Types", () => {
234246

235247
let blocks = Types.annotationToContentBlocks(annotation, ~index=0)
236248
let block = blocks->Array.getUnsafe(0)
237-
let embeddedResource = block.resource->Option.getOrThrow
249+
let embeddedResource = getEmbeddedResource(block)
238250

239251
switch embeddedResource.resource {
240252
| TextResourceContents(textResource) =>

libs/frontman-client/src/FrontmanClient__ACP.res

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -367,20 +367,11 @@ let sendPrompt = async (
367367
~metadata: option<JSON.t>=None,
368368
): result<Types.promptResult, string> => {
369369
// Build prompt array starting with the text block
370-
let textBlock = JSON.Encode.object(
371-
Dict.fromArray([("type", JSON.Encode.string("text")), ("text", JSON.Encode.string(text))]),
370+
let textBlock: Types.contentBlock = TextContent({text, _meta: None, annotations: None})
371+
let allBlocks = Array.concat([textBlock], additionalBlocks)->Array.map(block =>
372+
block->S.reverseConvertToJsonOrThrow(Types.contentBlockSchema)
372373
)
373374

374-
let allBlocks = if Array.length(additionalBlocks) > 0 {
375-
let additionalBlocksJson =
376-
additionalBlocks->Array.map(block =>
377-
block->S.reverseConvertToJsonOrThrow(Types.contentBlockSchema)
378-
)
379-
Array.concat([textBlock], additionalBlocksJson)
380-
} else {
381-
[textBlock]
382-
}
383-
384375
await Protocol.sendPrompt(
385376
~channel=session.channel,
386377
~state=session.connection.state,

0 commit comments

Comments
 (0)