Skip to content

Commit 9e4fe53

Browse files
feat: add full message-level streaming + structured outputs (#37)
* feat: surface structured output in streams Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * feat: simplify streaming result API Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * test: move stress examples out of structured output PR Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: tighten streaming API types Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent d6a277c commit 9e4fe53

20 files changed

Lines changed: 1394 additions & 368 deletions

examples/readme-structured-output.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { OutputFormatType, run } from '@factory/droid-sdk';
22

3+
type FavoriteNumber = { favoriteNumber: number };
4+
35
const result = await run('Pick a favorite number between 1 and 42.', {
46
cwd: process.cwd(),
57
outputFormat: {
@@ -18,4 +20,6 @@ const result = await run('Pick a favorite number between 1 and 42.', {
1820
},
1921
});
2022

21-
console.log(result.structuredOutput?.favoriteNumber);
23+
console.log(
24+
(result.structuredOutput as FavoriteNumber | undefined)?.favoriteNumber
25+
);

examples/session-stream.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Simple session streaming example.
33
*
44
* Demonstrates using `session.stream()` to send a prompt, streaming
5-
* `AssistantTextDelta` messages to stdout, and handling `TurnComplete`.
5+
* full assistant/tool messages, and handling the final `result`.
66
*
77
* Usage:
88
* npx tsx examples/session-stream.ts
@@ -20,19 +20,19 @@ async function main(): Promise<void> {
2020
try {
2121
for await (const msg of session.stream(prompt)) {
2222
switch (msg.type) {
23-
case DroidMessageType.AssistantTextDelta:
23+
case DroidMessageType.Assistant:
2424
process.stdout.write(msg.text);
2525
break;
2626

27-
case DroidMessageType.ToolUse:
28-
console.log(`\n[Tool] ${msg.toolName}`);
27+
case DroidMessageType.ToolCall:
28+
console.log(`\n[Tool] ${msg.toolUse.name}`);
2929
break;
3030

3131
case DroidMessageType.ToolResult:
3232
console.log(`[Tool Result] ${msg.isError ? 'Error' : 'OK'}`);
3333
break;
3434

35-
case DroidMessageType.TurnComplete:
35+
case DroidMessageType.Result:
3636
console.log('\n\n--- Turn complete ---');
3737
if (msg.tokenUsage) {
3838
console.log(

examples/structured-output.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import assert from 'node:assert/strict';
1313

1414
import { OutputFormatType, run } from '@factory/droid-sdk';
1515

16+
type FavoriteNumber = { favoriteNumber: number };
17+
1618
async function main(): Promise<void> {
1719
const prompt =
1820
process.argv.slice(2).join(' ') ||
@@ -40,11 +42,13 @@ async function main(): Promise<void> {
4042
outputFormat,
4143
});
4244

43-
assert.ok(result.structuredOutput, 'Expected structuredOutput to be set');
44-
assert.equal(typeof result.structuredOutput['favoriteNumber'], 'number');
45+
const structuredOutput = result.structuredOutput as FavoriteNumber | null;
46+
47+
assert.ok(structuredOutput, 'Expected structuredOutput to be set');
48+
assert.equal(typeof structuredOutput.favoriteNumber, 'number');
4549

4650
console.log('=== Structured output ===');
47-
console.log(JSON.stringify(result.structuredOutput, null, 2));
51+
console.log(JSON.stringify(structuredOutput, null, 2));
4852

4953
console.log('\nStructured output example passed');
5054
}

examples/test-compact.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ async function main(): Promise<void> {
2727
console.log(`=== Turn ${i + 1} ===`);
2828
console.log(`Prompt: "${prompts[i]}"\n`);
2929

30-
for await (const msg of session.stream(prompts[i])) {
30+
for await (const msg of session.stream(prompts[i], {
31+
includePartialMessages: true,
32+
})) {
3133
if (msg.type === DroidMessageType.AssistantTextDelta) {
3234
process.stdout.write(msg.text);
3335
}
34-
if (msg.type === DroidMessageType.TurnComplete) {
36+
if (msg.type === DroidMessageType.Result) {
3537
console.log('\n');
3638
}
3739
}

src/helpers.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { DroidMcpServerConfig } from './mcp.js';
88
import type {
99
InitializeSessionRequestParams,
1010
McpServerConfig,
11+
OutputFormat,
1112
SessionTag,
1213
} from './schemas/client.js';
1314
import type {
@@ -23,9 +24,11 @@ import type { ToolSelectionOverrides } from './schemas/shared.js';
2324
import {
2425
convertNotificationToStreamMessage,
2526
DroidMessageType,
27+
isDefaultStreamMessage,
28+
isInternalMessage,
2629
StreamStateTracker,
2730
} from './stream.js';
28-
import type { DroidMessage } from './stream.js';
31+
import type { DroidStreamEvent, InternalDroidMessage } from './stream.js';
2932
import { ProcessTransport } from './transport.js';
3033
import type { DroidClientTransport, ProcessTransportOptions } from './types.js';
3134

@@ -52,14 +55,27 @@ export function extractInnerNotification(
5255
}
5356

5457
export class MessageBridge {
55-
private readonly _queue: DroidMessage[] = [];
58+
private readonly _queue: DroidStreamEvent[] = [];
5659
private readonly _onDone: (() => void) | undefined;
5760
private _resolveWaiting: (() => void) | null = null;
5861
private _done = false;
59-
private readonly _stateTracker = new StreamStateTracker();
62+
private readonly _stateTracker: StreamStateTracker;
6063

61-
constructor(onDone?: () => void) {
64+
constructor(
65+
onDone?: () => void,
66+
private readonly _options: {
67+
includePartialMessages?: boolean;
68+
sessionId?: string;
69+
startedAt?: number;
70+
outputFormat?: OutputFormat;
71+
} = {}
72+
) {
6273
this._onDone = onDone;
74+
this._stateTracker = new StreamStateTracker({
75+
sessionId: _options.sessionId,
76+
startedAt: _options.startedAt,
77+
hasOutputFormat: _options.outputFormat !== undefined,
78+
});
6379
}
6480

6581
readonly notificationHandler = (
@@ -75,11 +91,15 @@ export class MessageBridge {
7591

7692
for (const msg of messages) {
7793
const { message, additional } = this._stateTracker.processMessage(msg);
78-
this._enqueue(message);
94+
if (message && this._shouldYield(message)) {
95+
this._enqueue(message);
96+
}
7997

8098
for (const extra of additional) {
81-
this._enqueue(extra);
82-
if (extra.type === DroidMessageType.TurnComplete) {
99+
if (this._shouldYield(extra)) {
100+
this._enqueue(extra);
101+
}
102+
if (extra.type === DroidMessageType.Result) {
83103
this._signalDone();
84104
}
85105
}
@@ -90,13 +110,13 @@ export class MessageBridge {
90110
this._signalDone();
91111
}
92112

93-
async *messages(): AsyncGenerator<DroidMessage, void, undefined> {
113+
async *messages(): AsyncGenerator<DroidStreamEvent, void, undefined> {
94114
while (true) {
95115
while (this._queue.length > 0) {
96116
const msg = this._queue.shift()!;
97117
yield msg;
98118

99-
if (msg.type === DroidMessageType.TurnComplete) {
119+
if (msg.type === DroidMessageType.Result) {
100120
return;
101121
}
102122
}
@@ -111,7 +131,18 @@ export class MessageBridge {
111131
}
112132
}
113133

114-
private _enqueue(msg: DroidMessage): void {
134+
private _shouldYield(
135+
message: InternalDroidMessage
136+
): message is DroidStreamEvent {
137+
if (isInternalMessage(message)) {
138+
return false;
139+
}
140+
return this._options.includePartialMessages
141+
? true
142+
: isDefaultStreamMessage(message);
143+
}
144+
145+
private _enqueue(msg: DroidStreamEvent): void {
115146
this._queue.push(msg);
116147
if (this._resolveWaiting) {
117148
const resolve = this._resolveWaiting;

src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,19 @@ export {
2828
StreamStateTracker,
2929
} from './stream.js';
3030
export type {
31+
DroidAssistantMessage,
32+
DroidUserMessage,
33+
DroidToolCallMessage,
34+
DroidToolResultMessage,
35+
DroidErrorMessage,
36+
DroidResultMessage,
37+
DroidStreamMessage,
38+
DroidStreamEvent,
3139
AssistantTextDelta,
40+
AssistantTextComplete,
3241
ThinkingTextDelta,
42+
ThinkingTextComplete,
43+
ToolCallDelta,
3344
ToolUse,
3445
ToolResult,
3546
ToolProgress,
@@ -48,8 +59,9 @@ export type {
4859
MissionWorkerCompleted,
4960
McpAuthRequired,
5061
McpAuthCompleted,
62+
StructuredOutput,
63+
StructuredOutputFields,
5164
ErrorEvent,
52-
TurnComplete,
5365
DroidMessage,
5466
} from './stream.js';
5567

src/run.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {
2-
aggregateMessages,
32
createSession,
43
type CreateSessionOptions,
54
type DroidResult,
65
type MessageOptions,
76
} from './session.js';
8-
import type { DroidMessage } from './stream.js';
7+
import { DroidMessageType } from './stream.js';
98

10-
export interface RunOptions extends CreateSessionOptions, MessageOptions {}
9+
export interface RunOptions
10+
extends
11+
CreateSessionOptions,
12+
Omit<MessageOptions, 'includePartialMessages'> {}
1113

1214
export async function run(
1315
prompt: string,
@@ -16,12 +18,16 @@ export async function run(
1618
const session = await createSession(options);
1719

1820
try {
19-
const startedAt = Date.now();
20-
const messages: DroidMessage[] = [];
21-
for await (const msg of session.stream(prompt, options)) {
22-
messages.push(msg);
21+
for await (const msg of session.stream(prompt, {
22+
...options,
23+
includePartialMessages: false,
24+
})) {
25+
if (msg.type === DroidMessageType.Result) {
26+
return msg;
27+
}
2328
}
24-
return aggregateMessages(session.sessionId, messages, startedAt, options);
29+
30+
throw new Error('Stream completed without a result message');
2531
} finally {
2632
await session.close();
2733
}

src/schemas/enums.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ export enum SessionNotificationType {
5656
SESSION_TITLE_UPDATED = 'session_title_updated',
5757
MCP_STATUS_CHANGED = 'mcp_status_changed',
5858
ASSISTANT_TEXT_DELTA = 'assistant_text_delta',
59+
ASSISTANT_TEXT_COMPLETE = 'assistant_text_complete',
5960
THINKING_TEXT_DELTA = 'thinking_text_delta',
61+
THINKING_TEXT_COMPLETE = 'thinking_text_complete',
62+
TOOL_CALL = 'tool_call',
6063
SESSION_TOKEN_USAGE_CHANGED = 'session_token_usage_changed',
6164
MISSION_STATE_CHANGED = 'mission_state_changed',
6265
MISSION_FEATURES_CHANGED = 'mission_features_changed',
@@ -66,6 +69,7 @@ export enum SessionNotificationType {
6669
MISSION_WORKER_COMPLETED = 'mission_worker_completed',
6770
MCP_AUTH_REQUIRED = 'mcp_auth_required',
6871
MCP_AUTH_COMPLETED = 'mcp_auth_completed',
72+
STRUCTURED_OUTPUT = 'structured_output',
6973
}
7074

7175
/** Tool confirmation outcome options (possible user responses to permission requests). */

src/schemas/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,12 @@ export {
510510
SettingsUpdatedNotificationSchema,
511511
SettingsUpdatedPayloadSchema,
512512
StartMissionRunConfirmationDetailsSchema,
513+
AssistantTextCompleteNotificationSchema,
514+
StructuredOutputErrorSchema,
515+
StructuredOutputNotificationSchema,
516+
ThinkingTextCompleteNotificationSchema,
513517
ThinkingTextDeltaNotificationSchema,
518+
ToolCallNotificationSchema,
514519
ToolConfirmationDetailsSchema,
515520
ToolConfirmationInfoSchema,
516521
ToolProgressUpdateNotificationSchema,
@@ -528,6 +533,7 @@ export type {
528533
AskUserResponse,
529534
AskUserResult,
530535
ApplyPatchToolConfirmationDetails,
536+
AssistantTextCompleteNotification,
531537
AssistantTextDeltaNotification,
532538
CliRequestOrNotification,
533539
CreateMessageNotification,
@@ -564,7 +570,11 @@ export type {
564570
SettingsUpdatedNotification,
565571
SettingsUpdatedPayload,
566572
StartMissionRunConfirmationDetails,
573+
StructuredOutputError,
574+
StructuredOutputNotification,
575+
ThinkingTextCompleteNotification,
567576
ThinkingTextDeltaNotification,
577+
ToolCallNotification,
568578
ToolConfirmationDetails,
569579
ToolConfirmationInfo,
570580
ToolProgressUpdate,

0 commit comments

Comments
 (0)