Skip to content

Commit 6ee2efb

Browse files
committed
fix(appkit): address pkosiec feedback
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
1 parent 92ee354 commit 6ee2efb

2 files changed

Lines changed: 181 additions & 47 deletions

File tree

packages/appkit/src/agents/supervisor-api.ts

Lines changed: 73 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,15 @@ type SupervisorErrorCode =
3030
* never sees raw upstream text.
3131
*/
3232
function emitError(code: SupervisorErrorCode, detail: unknown): AgentEvent {
33-
logger.warn("supervisor-api error code=%s detail=%O", code, detail);
33+
// Summarise at `warn` (CWE-532: never dump the full upstream payload to
34+
// the default log level); the verbose object is only available via
35+
// `DEBUG=appkit:agents:supervisor-api`.
36+
logger.warn(
37+
"supervisor-api error code=%s detail=%s",
38+
code,
39+
summariseErrorPayload(detail),
40+
);
41+
logger.debug("supervisor-api error code=%s detail=%O", code, detail);
3442
return {
3543
type: "status",
3644
status: "error",
@@ -353,51 +361,64 @@ export class SupervisorApiAdapter implements AgentAdapter {
353361
}
354362
| undefined;
355363

356-
for await (const { event, data } of readSseEvents(stream, signal)) {
357-
if (data === "[DONE]") continue;
358-
359-
let parsed: Record<string, unknown>;
360-
try {
361-
parsed = JSON.parse(data);
362-
} catch (err) {
363-
logger.debug(
364-
"Failed to parse SSE data line: %s (%O)",
365-
data.slice(0, 200),
366-
err,
367-
);
368-
continue;
369-
}
364+
// `readSseEvents` throws on transport errors and on the DoS caps
365+
// (maxLineChars / maxBufferChars). Without this guard the rejection
366+
// propagates out of `run()` and tears down the request. Treat a
367+
// consumer-initiated abort as a clean stop; everything else becomes a
368+
// sanitised terminal `transport` error.
369+
try {
370+
for await (const { event, data } of readSseEvents(stream, signal)) {
371+
if (data === "[DONE]") continue;
372+
373+
let parsed: Record<string, unknown>;
374+
try {
375+
parsed = JSON.parse(data);
376+
} catch (err) {
377+
logger.debug(
378+
"Failed to parse SSE data line: %s (%O)",
379+
data.slice(0, 200),
380+
err,
381+
);
382+
continue;
383+
}
370384

371-
const eventType = event || (parsed.type as string) || "";
372-
eventCounts.set(eventType, (eventCounts.get(eventType) ?? 0) + 1);
373-
374-
// `response.completed` is held back until after the loop so we can
375-
// synthesise a `message_delta` from `response.output[]` when the
376-
// stream produced no incremental deltas (intermittent SA behaviour).
377-
// Emitting `complete` first would let UIs finalise the turn before the
378-
// recovered text arrives.
379-
if (eventType === "response.completed") {
380-
lastCompleted = parsed.response as typeof lastCompleted;
381-
continue;
382-
}
385+
const eventType =
386+
event || (typeof parsed.type === "string" ? parsed.type : "");
387+
eventCounts.set(eventType, (eventCounts.get(eventType) ?? 0) + 1);
388+
389+
// `response.completed` is held back until after the loop so we can
390+
// synthesise a `message_delta` from `response.output[]` when the
391+
// stream produced no incremental deltas (intermittent SA behaviour).
392+
// Emitting `complete` first would let UIs finalise the turn before the
393+
// recovered text arrives.
394+
if (eventType === "response.completed") {
395+
lastCompleted = parsed.response as typeof lastCompleted;
396+
continue;
397+
}
383398

384-
const out = mapEvent(eventType, parsed, streamedItemIds);
385-
if (out) {
386-
if (out.type === "message_delta") receivedAnyDelta = true;
387-
yield out;
388-
if (out.type === "status" && out.status === "error") {
389-
terminated = true;
390-
break;
399+
const out = mapEvent(eventType, parsed, streamedItemIds);
400+
if (out) {
401+
if (out.type === "message_delta") receivedAnyDelta = true;
402+
yield out;
403+
if (out.type === "status" && out.status === "error") {
404+
terminated = true;
405+
break;
406+
}
391407
}
392408
}
409+
} catch (err) {
410+
if (signal?.aborted) return;
411+
yield emitError("transport", err);
412+
return;
393413
}
394414

395415
if (signal?.aborted) return;
396416

397417
if (eventCounts.size === 0) {
398-
logger.warn(
399-
"Supervisor API stream closed without emitting any SSE events.",
400-
);
418+
// A stream that closes without a single event leaves the consumer
419+
// stuck in `running`. Surface a terminal `transport` error so the
420+
// turn ends.
421+
yield emitError("transport", "stream closed without events");
401422
return;
402423
}
403424

@@ -419,14 +440,15 @@ export class SupervisorApiAdapter implements AgentAdapter {
419440

420441
if (eventCounts.has("response.completed")) {
421442
// SA sometimes signals a failed turn via `response.completed` with a
422-
// nested `status: "failed"` (or a populated `error`/`incomplete_details`)
423-
// rather than emitting `response.failed`. Without this gate the
424-
// adapter would silently yield `complete` on a server-side failure.
425-
if (
426-
lastCompleted?.status === "failed" ||
427-
lastCompleted?.error != null ||
428-
lastCompleted?.incomplete_details != null
429-
) {
443+
// nested `status: "failed"` (or a populated `error`) rather than
444+
// emitting `response.failed`. Without this gate the adapter would
445+
// silently yield `complete` on a server-side failure.
446+
//
447+
// `incomplete_details` on its own is NOT fatal: a benign
448+
// `max_output_tokens` truncation populates it while still producing
449+
// usable partial output. In that case we fall through to `complete`
450+
// and let the recovered text above stand as the turn result.
451+
if (lastCompleted?.status === "failed" || lastCompleted?.error != null) {
430452
yield emitError("upstream_failed", {
431453
status: lastCompleted?.status,
432454
error: lastCompleted?.error,
@@ -539,9 +561,13 @@ function mapEvent(
539561
// than we care to map.
540562
switch (eventType as ResponseStreamEvent["type"]) {
541563
case "response.output_text.delta": {
542-
const itemId = data.item_id as string | undefined;
564+
const itemId =
565+
typeof data.item_id === "string" ? data.item_id : undefined;
543566
if (itemId) streamedItemIds.add(itemId);
544-
return { type: "message_delta", content: (data.delta as string) ?? "" };
567+
return {
568+
type: "message_delta",
569+
content: typeof data.delta === "string" ? data.delta : "",
570+
};
545571
}
546572

547573
// `response.completed` is intentionally absent: `streamResponse` holds

packages/appkit/src/agents/tests/supervisor-api.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,114 @@ describe("SupervisorApiAdapter", () => {
770770
});
771771
expect(events).toContainEqual({ type: "status", status: "complete" });
772772
});
773+
774+
test("yields a sanitised transport error when readSseEvents throws mid-stream", async () => {
775+
// `readSseEvents` enforces a DoS cap (maxLineChars) and throws when an
776+
// SSE block exceeds it. Without a try/catch around the consuming loop
777+
// that rejection would propagate out of run() and tear down the request.
778+
// The adapter must catch it and surface a terminal `transport` error.
779+
const oversizedBlock = `data: ${"x".repeat(1024 * 1024 + 100)}\n\n`;
780+
const { streamBody } = makeStreamBody([oversizedBlock]);
781+
const adapter = new SupervisorApiAdapter({
782+
streamBody,
783+
model: "databricks-claude-sonnet-4",
784+
});
785+
const events = await collect(
786+
adapter.run(createInput(), { executeTool: vi.fn() }),
787+
);
788+
expect(events).toEqual([
789+
{ type: "status", status: "running" },
790+
{
791+
type: "status",
792+
status: "error",
793+
error: "Supervisor API error (transport)",
794+
},
795+
]);
796+
// The raw error text (which echoes the cap detail) never reaches the client.
797+
for (const e of events) {
798+
if (e.type === "status" && "error" in e) {
799+
expect(e.error).not.toContain("maxLineChars");
800+
}
801+
}
802+
});
803+
804+
test("emits a terminal transport error when the stream closes without any events", async () => {
805+
// A stream that closes with zero SSE events would otherwise leave the
806+
// consumer stuck in `running`. The adapter must end the turn with a
807+
// terminal `transport` error.
808+
const { streamBody } = makeStreamBody([]);
809+
const adapter = new SupervisorApiAdapter({
810+
streamBody,
811+
model: "databricks-claude-sonnet-4",
812+
});
813+
const events = await collect(
814+
adapter.run(createInput(), { executeTool: vi.fn() }),
815+
);
816+
expect(events).toEqual([
817+
{ type: "status", status: "running" },
818+
{
819+
type: "status",
820+
status: "error",
821+
error: "Supervisor API error (transport)",
822+
},
823+
]);
824+
});
825+
826+
test("treats incomplete_details alone as benign truncation: recovers text and completes", async () => {
827+
// A `max_output_tokens` truncation populates `incomplete_details` while
828+
// still producing usable partial output. The adapter must NOT treat this
829+
// as a failure — it recovers the partial text and yields `complete`.
830+
const { streamBody } = makeStreamBody([
831+
sseEvent("response.completed", {
832+
response: {
833+
status: "incomplete",
834+
incomplete_details: { reason: "max_output_tokens" },
835+
output: [
836+
{
837+
type: "message",
838+
id: "msg-trunc",
839+
role: "assistant",
840+
content: [{ type: "output_text", text: "Partial answer" }],
841+
},
842+
],
843+
},
844+
}),
845+
]);
846+
const adapter = new SupervisorApiAdapter({
847+
streamBody,
848+
model: "databricks-claude-sonnet-4",
849+
});
850+
const events = await collect(
851+
adapter.run(createInput(), { executeTool: vi.fn() }),
852+
);
853+
expect(events).toEqual([
854+
{ type: "status", status: "running" },
855+
{ type: "message_delta", content: "Partial answer" },
856+
{ type: "status", status: "complete" },
857+
]);
858+
});
859+
860+
test("coerces a non-string output_text delta to an empty string", async () => {
861+
// Hardening for the previous `(data.delta as string) ?? ""` cast: a
862+
// non-string `delta` (or `item_id`) must not leak through as a non-string
863+
// or `"undefined"` — it is coerced to "".
864+
const { streamBody } = makeStreamBody([
865+
sseEvent("response.output_text.delta", { item_id: 42, delta: 123 }),
866+
sseEvent("response.completed", {}),
867+
]);
868+
const adapter = new SupervisorApiAdapter({
869+
streamBody,
870+
model: "databricks-claude-sonnet-4",
871+
});
872+
const events = await collect(
873+
adapter.run(createInput(), { executeTool: vi.fn() }),
874+
);
875+
expect(events).toEqual([
876+
{ type: "status", status: "running" },
877+
{ type: "message_delta", content: "" },
878+
{ type: "status", status: "complete" },
879+
]);
880+
});
773881
});
774882

775883
describe("fromSupervisorApi", () => {

0 commit comments

Comments
 (0)