Skip to content

Commit 05b3626

Browse files
feat: handle structured outputs properly (#16)
* feat: handle structured outputs properly * address reviews * chore: bump version to 3.0.2 and add CHANGELOG entry --------- Co-authored-by: Ben Vargas <ben@vargas.com>
1 parent c5ffd41 commit 05b3626

7 files changed

Lines changed: 702 additions & 1 deletion

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [3.0.2] - 2026-04-12
9+
10+
### Added
11+
12+
- **Structured output support** - OpenCode's `StructuredOutput` tool input is now re-emitted as text content so the AI SDK's `Output.object()` / `Output.array()` can parse `step.text` correctly. Previously this always threw `NoObjectGeneratedError`. (PR [#16](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk/pull/16) by [@abhijit-hota](https://github.com/abhijit-hota))
13+
- **Exported `STRUCTURED_OUTPUT_TOOL` constant** - Shared constant for the `"StructuredOutput"` tool name.
14+
815
## [3.0.1] - 2026-03-24
916

1017
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ai-sdk-provider-opencode-sdk",
3-
"version": "3.0.1",
3+
"version": "3.0.2",
44
"description": "AI SDK v6 provider for OpenCode via @opencode-ai/sdk",
55
"keywords": [
66
"ai-sdk",

src/convert-from-opencode-events.test.ts

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,339 @@ describe("convert-from-opencode-events", () => {
879879
});
880880
});
881881

882+
describe("StructuredOutput tool parts", () => {
883+
it("should skip empty input during pending StructuredOutput", () => {
884+
const state = createStreamState();
885+
const event: EventMessagePartUpdated = {
886+
type: "message.part.updated",
887+
properties: {
888+
part: {
889+
id: "part-1",
890+
sessionID: "session-123",
891+
messageID: "msg-1",
892+
type: "tool",
893+
callID: "call-so-1",
894+
tool: "StructuredOutput",
895+
state: {
896+
status: "pending",
897+
input: {},
898+
raw: '{}',
899+
},
900+
} as ToolPart,
901+
},
902+
};
903+
904+
const parts = convertEventToStreamParts(event, state);
905+
906+
expect(parts).toHaveLength(0);
907+
expect(state.textStarted).toBe(false);
908+
});
909+
910+
it("should emit text-start and text-delta for pending StructuredOutput with data", () => {
911+
const state = createStreamState();
912+
const event: EventMessagePartUpdated = {
913+
type: "message.part.updated",
914+
properties: {
915+
part: {
916+
id: "part-1",
917+
sessionID: "session-123",
918+
messageID: "msg-1",
919+
type: "tool",
920+
callID: "call-so-1",
921+
tool: "StructuredOutput",
922+
state: {
923+
status: "pending",
924+
input: { output: "partial", outputType: "markdown" },
925+
raw: '{"output":"partial","outputType":"markdown"}',
926+
},
927+
} as ToolPart,
928+
},
929+
};
930+
931+
const parts = convertEventToStreamParts(event, state);
932+
933+
expect(parts[0]).toMatchObject({ type: "text-start", id: "structured-output-call-so-1" });
934+
expect(parts[1]).toMatchObject({
935+
type: "text-delta",
936+
id: "structured-output-call-so-1",
937+
delta: JSON.stringify({ output: "partial", outputType: "markdown" }),
938+
});
939+
expect(parts.some((p) => p.type === "tool-input-start")).toBe(false);
940+
});
941+
942+
it("should emit incremental text-delta for running StructuredOutput", () => {
943+
const state = createStreamState();
944+
945+
const makeEvent = (input: Record<string, unknown>): EventMessagePartUpdated => ({
946+
type: "message.part.updated",
947+
properties: {
948+
part: {
949+
id: "part-1",
950+
sessionID: "session-123",
951+
messageID: "msg-1",
952+
type: "tool",
953+
callID: "call-so-1",
954+
tool: "StructuredOutput",
955+
state: {
956+
status: "running",
957+
input,
958+
time: { start: 1000 },
959+
},
960+
} as ToolPart,
961+
},
962+
});
963+
964+
// First running event
965+
const parts1 = convertEventToStreamParts(
966+
makeEvent({ output: "hel" }),
967+
state,
968+
);
969+
const deltas1 = parts1.filter((p) => p.type === "text-delta");
970+
expect(deltas1).toHaveLength(1);
971+
expect((deltas1[0] as any).delta).toBe(JSON.stringify({ output: "hel" }));
972+
973+
// Same input — no delta
974+
const parts2 = convertEventToStreamParts(
975+
makeEvent({ output: "hel" }),
976+
state,
977+
);
978+
const deltas2 = parts2.filter((p) => p.type === "text-delta");
979+
expect(deltas2).toHaveLength(0);
980+
981+
// Extended input — only the diff
982+
const parts3 = convertEventToStreamParts(
983+
makeEvent({ output: "hello world" }),
984+
state,
985+
);
986+
const deltas3 = parts3.filter((p) => p.type === "text-delta");
987+
expect(deltas3).toHaveLength(1);
988+
});
989+
990+
it("should emit text-end on completed StructuredOutput", () => {
991+
const state = createStreamState();
992+
const structuredInput = { output: "# Result", outputType: "markdown" };
993+
994+
const event: EventMessagePartUpdated = {
995+
type: "message.part.updated",
996+
properties: {
997+
part: {
998+
id: "part-1",
999+
sessionID: "session-123",
1000+
messageID: "msg-1",
1001+
type: "tool",
1002+
callID: "call-so-1",
1003+
tool: "StructuredOutput",
1004+
state: {
1005+
status: "completed",
1006+
input: structuredInput,
1007+
output: "Structured output captured successfully.",
1008+
title: "Structured Output",
1009+
time: { start: 1000, end: 2000 },
1010+
},
1011+
} as ToolPart,
1012+
},
1013+
};
1014+
1015+
const parts = convertEventToStreamParts(event, state);
1016+
1017+
expect(parts.some((p) => p.type === "text-start")).toBe(true);
1018+
expect(parts.some((p) => p.type === "text-delta")).toBe(true);
1019+
expect(parts.some((p) => p.type === "text-end")).toBe(true);
1020+
1021+
const delta = parts.find((p) => p.type === "text-delta") as { delta?: string } | undefined;
1022+
expect(JSON.parse(delta!.delta!)).toEqual(structuredInput);
1023+
1024+
// No tool-call or tool-result parts
1025+
expect(parts.some((p) => p.type === "tool-call")).toBe(false);
1026+
expect(parts.some((p) => p.type === "tool-result")).toBe(false);
1027+
expect(parts.some((p) => p.type === "tool-input-start")).toBe(false);
1028+
});
1029+
1030+
it("should stream text across pending → running → completed lifecycle", () => {
1031+
const state = createStreamState();
1032+
const callID = "call-so-lifecycle";
1033+
1034+
const makeEvent = (
1035+
status: string,
1036+
input: Record<string, unknown>,
1037+
): EventMessagePartUpdated => ({
1038+
type: "message.part.updated",
1039+
properties: {
1040+
part: {
1041+
id: "part-1",
1042+
sessionID: "session-123",
1043+
messageID: "msg-1",
1044+
type: "tool",
1045+
callID,
1046+
tool: "StructuredOutput",
1047+
state: {
1048+
status,
1049+
input,
1050+
...(status === "pending" ? { raw: JSON.stringify(input) } : {}),
1051+
...(status === "running" ? { time: { start: 1000 } } : {}),
1052+
...(status === "completed"
1053+
? {
1054+
output: "Structured output captured successfully.",
1055+
title: "Structured Output",
1056+
time: { start: 1000, end: 2000 },
1057+
}
1058+
: {}),
1059+
},
1060+
} as ToolPart,
1061+
},
1062+
});
1063+
1064+
// pending with empty input (real OpenCode behavior)
1065+
const parts0 = convertEventToStreamParts(
1066+
makeEvent("pending", {}),
1067+
state,
1068+
);
1069+
expect(parts0).toHaveLength(0);
1070+
1071+
// running with real input — text starts here
1072+
const parts1 = convertEventToStreamParts(
1073+
makeEvent("running", { output: "hello" }),
1074+
state,
1075+
);
1076+
expect(parts1[0]).toMatchObject({ type: "text-start" });
1077+
const deltas1 = parts1.filter((p) => p.type === "text-delta");
1078+
expect(deltas1).toHaveLength(1);
1079+
expect(JSON.parse((deltas1[0] as any).delta)).toEqual({ output: "hello" });
1080+
1081+
// completed with final input
1082+
const parts2 = convertEventToStreamParts(
1083+
makeEvent("completed", { output: "hello world", outputType: "markdown" }),
1084+
state,
1085+
);
1086+
expect(parts2.some((p) => p.type === "text-delta")).toBe(true);
1087+
expect(parts2.some((p) => p.type === "text-end")).toBe(true);
1088+
1089+
// State should be closed
1090+
expect(state.textStarted).toBe(false);
1091+
expect(state.textPartId).toBeUndefined();
1092+
});
1093+
1094+
it("should emit {} for completed StructuredOutput with empty object output", () => {
1095+
const state = createStreamState();
1096+
const event: EventMessagePartUpdated = {
1097+
type: "message.part.updated",
1098+
properties: {
1099+
part: {
1100+
id: "part-1",
1101+
sessionID: "session-123",
1102+
messageID: "msg-1",
1103+
type: "tool",
1104+
callID: "call-so-1",
1105+
tool: "StructuredOutput",
1106+
state: {
1107+
status: "completed",
1108+
input: {},
1109+
output: "Structured output captured successfully.",
1110+
title: "Structured Output",
1111+
time: { start: 1000, end: 2000 },
1112+
},
1113+
} as ToolPart,
1114+
},
1115+
};
1116+
1117+
const parts = convertEventToStreamParts(event, state);
1118+
1119+
const delta = parts.find((p) => p.type === "text-delta") as { delta?: string } | undefined;
1120+
expect(delta).toBeDefined();
1121+
expect(delta!.delta).toBe("{}");
1122+
});
1123+
1124+
it("should close text part on StructuredOutput error", () => {
1125+
const state = createStreamState();
1126+
1127+
// First, get a text-start via a running event with real input
1128+
const runningEvent: EventMessagePartUpdated = {
1129+
type: "message.part.updated",
1130+
properties: {
1131+
part: {
1132+
id: "part-1",
1133+
sessionID: "session-123",
1134+
messageID: "msg-1",
1135+
type: "tool",
1136+
callID: "call-so-1",
1137+
tool: "StructuredOutput",
1138+
state: {
1139+
status: "running",
1140+
input: { output: "partial" },
1141+
time: { start: 1000 },
1142+
},
1143+
} as ToolPart,
1144+
},
1145+
};
1146+
convertEventToStreamParts(runningEvent, state);
1147+
expect(state.textStarted).toBe(true);
1148+
1149+
// Now error
1150+
const errorEvent: EventMessagePartUpdated = {
1151+
type: "message.part.updated",
1152+
properties: {
1153+
part: {
1154+
id: "part-1",
1155+
sessionID: "session-123",
1156+
messageID: "msg-1",
1157+
type: "tool",
1158+
callID: "call-so-1",
1159+
tool: "StructuredOutput",
1160+
state: {
1161+
status: "error",
1162+
input: { output: "partial" },
1163+
error: "Model did not produce structured output",
1164+
time: { start: 1000, end: 2000 },
1165+
},
1166+
} as ToolPart,
1167+
},
1168+
};
1169+
1170+
const parts = convertEventToStreamParts(errorEvent, state);
1171+
1172+
expect(parts).toEqual([
1173+
{ type: "text-end", id: "structured-output-call-so-1" },
1174+
]);
1175+
expect(state.textStarted).toBe(false);
1176+
expect(state.textPartId).toBeUndefined();
1177+
});
1178+
1179+
it("should close prior text part when StructuredOutput starts", () => {
1180+
const state = createStreamState();
1181+
// Simulate an existing text part being open
1182+
state.textStarted = true;
1183+
state.textPartId = "existing-text-1";
1184+
state.lastTextContent = "some text";
1185+
1186+
const event: EventMessagePartUpdated = {
1187+
type: "message.part.updated",
1188+
properties: {
1189+
part: {
1190+
id: "part-1",
1191+
sessionID: "session-123",
1192+
messageID: "msg-1",
1193+
type: "tool",
1194+
callID: "call-so-1",
1195+
tool: "StructuredOutput",
1196+
state: {
1197+
status: "completed",
1198+
input: { result: "done" },
1199+
output: "ok",
1200+
title: "Structured Output",
1201+
time: { start: 1000, end: 2000 },
1202+
},
1203+
} as ToolPart,
1204+
},
1205+
};
1206+
1207+
const parts = convertEventToStreamParts(event, state);
1208+
1209+
// Should close the previous text part first
1210+
expect(parts[0]).toMatchObject({ type: "text-end", id: "existing-text-1" });
1211+
expect(parts[1]).toMatchObject({ type: "text-start", id: "structured-output-call-so-1" });
1212+
});
1213+
});
1214+
8821215
describe("step-finish parts", () => {
8831216
it("should accumulate usage from step-finish", () => {
8841217
const state = createStreamState();

0 commit comments

Comments
 (0)