Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createUserMessageEvent,
} from "test-utils";
import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event";
import {
ActionEvent,
ObservationEvent,
Expand Down Expand Up @@ -96,6 +97,46 @@ describe("shouldRenderEvent - ACPToolCallEvent", () => {
});
});

describe("shouldRenderEvent - StreamingDeltaEvent", () => {
const makeStreamingDelta = (
overrides: Partial<StreamingDeltaEvent> = {},
): StreamingDeltaEvent => ({
id: "delta-1",
kind: "StreamingDeltaEvent",
timestamp: "2024-01-01T00:00:00Z",
source: "agent",
content: "I'll start working on that.",
reasoning_content: null,
...overrides,
});

it("renders text deltas", () => {
expect(shouldRenderEvent(makeStreamingDelta())).toBe(true);
});

it("renders reasoning-only deltas", () => {
expect(
shouldRenderEvent(
makeStreamingDelta({
content: null,
reasoning_content: "thinking",
}),
),
).toBe(true);
});

it("hides empty deltas", () => {
expect(
shouldRenderEvent(
makeStreamingDelta({
content: null,
reasoning_content: null,
}),
),
).toBe(false);
});
});

describe("shouldRenderEvent - SwitchLLM", () => {
const switchAction: ActionEvent<SwitchLLMAction> = {
id: "switch-action",
Expand Down
198 changes: 198 additions & 0 deletions __tests__/utils/handle-event-for-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
OpenHandsEvent,
} from "#/types/agent-server/core";
import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event";
import { handleEventForUI } from "#/utils/handle-event-for-ui";

describe("handleEventForUI", () => {
Expand Down Expand Up @@ -76,6 +77,56 @@ describe("handleEventForUI", () => {
extended_content: [],
};

const mockFinishActionEvent: ActionEvent = {
id: "test-finish-action-1",
timestamp: Date.now().toString(),
source: "agent",
thought: [],
thinking_blocks: [],
action: {
kind: "FinishAction",
message: "I'll start working on that. Done.",
},
tool_name: "finish",
tool_call_id: "call_finish_1",
tool_call: {
id: "call_finish_1",
type: "function",
function: {
name: "finish",
arguments: JSON.stringify({
message: "I'll start working on that. Done.",
}),
},
},
llm_response_id: "response_finish",
security_risk: SecurityRisk.UNKNOWN,
};

const mockAgentMessageEvent: MessageEvent = {
id: "test-agent-message-1",
timestamp: Date.now().toString(),
source: "agent",
llm_message: {
role: "assistant",
content: [{ type: "text", text: "I'll start working on that. Done." }],
},
activated_microagents: [],
extended_content: [],
};

const makeStreamingDelta = (
id: string,
content: string | null,
): StreamingDeltaEvent => ({
id,
kind: "StreamingDeltaEvent",
timestamp: Date.now().toString(),
source: "agent",
content,
reasoning_content: null,
});

it("should add non-observation events to the end of uiEvents", () => {
const initialUiEvents = [mockMessageEvent];
const result = handleEventForUI(mockActionEvent, initialUiEvents);
Expand Down Expand Up @@ -241,6 +292,153 @@ describe("handleEventForUI", () => {
});
});

describe("StreamingDeltaEvent", () => {
it("merges consecutive deltas into a single provisional assistant event", () => {
const first = makeStreamingDelta("delta-1", "I'll start ");
const second = makeStreamingDelta("delta-2", "working on that.");

const afterFirst = handleEventForUI(first, [mockMessageEvent]);
const afterSecond = handleEventForUI(second, afterFirst);

expect(afterSecond).toEqual([
mockMessageEvent,
{
...second,
content: "I'll start working on that.",
reasoning_content: null,
},
]);
});

it("finalizes streamed deltas in place when finish arrives", () => {
const first = makeStreamingDelta("delta-1", "I'll start ");
const second = makeStreamingDelta("delta-2", "working on that.");
const streamedDelta = handleEventForUI(
second,
handleEventForUI(first, []),
).at(-1)!;
const uiEvents = [mockMessageEvent, streamedDelta];

const result = handleEventForUI(mockFinishActionEvent, uiEvents);

expect(result).toEqual([
mockMessageEvent,
{
...streamedDelta,
content: "I'll start working on that. Done.",
},
]);
});

it("finalizes streamed deltas in place when an agent message arrives", () => {
const first = makeStreamingDelta("delta-1", "I'll start ");
const second = makeStreamingDelta("delta-2", "working on that.");
const streamedDelta = handleEventForUI(
second,
handleEventForUI(first, []),
).at(-1)!;
const uiEvents = [mockMessageEvent, streamedDelta];

const result = handleEventForUI(mockAgentMessageEvent, uiEvents);

expect(result).toEqual([
mockMessageEvent,
{
...streamedDelta,
content: "I'll start working on that. Done.",
},
]);
});

it("keeps streamed deltas in their original locations when the final message aggregates them", () => {
const first = makeStreamingDelta(
"delta-1",
"I'll start working on that.",
);
const second = makeStreamingDelta("delta-2", "I found the issue.");
const aggregateAgentMessage: MessageEvent = {
...mockAgentMessageEvent,
llm_message: {
role: "assistant",
content: [
{
type: "text",
text: "I'll start working on that.I found the issue.",
},
],
},
};

const afterFirst = handleEventForUI(first, [mockMessageEvent]);
const afterObservation = handleEventForUI(mockObservationEvent, afterFirst);
const afterSecond = handleEventForUI(second, afterObservation);
const result = handleEventForUI(aggregateAgentMessage, afterSecond);

expect(result).toEqual([
mockMessageEvent,
first,
mockObservationEvent,
second,
]);
});

it("appends a distinct final message that does not match streamed text", () => {
const streamedDelta = makeStreamingDelta(
"delta-1",
"I'll start working on that.",
);
const finalMessage: MessageEvent = {
...mockAgentMessageEvent,
llm_message: {
role: "assistant",
content: [{ type: "text", text: "Done." }],
},
};

const result = handleEventForUI(finalMessage, [
mockMessageEvent,
streamedDelta,
]);

expect(result).toEqual([mockMessageEvent, streamedDelta, finalMessage]);
});

it("keeps deltas from older turns when a later turn finishes", () => {
const oldUserMessage: MessageEvent = {
...mockMessageEvent,
id: "old-user-message",
};
const nextUserMessage: MessageEvent = {
...mockMessageEvent,
id: "next-user-message",
llm_message: {
role: "user",
content: [{ type: "text", text: "Next task" }],
},
};
const oldDelta = makeStreamingDelta("old-delta", "Old live text");
const currentDelta = makeStreamingDelta(
"current-delta",
"Current live text",
);

const result = handleEventForUI(mockFinishActionEvent, [
oldUserMessage,
oldDelta,
nextUserMessage,
currentDelta,
]);

expect(result).toEqual([
oldUserMessage,
oldDelta,
nextUserMessage,
currentDelta,
mockFinishActionEvent,
]);
});
});

it("should NOT add ThinkObservation even when ThinkAction is not found", () => {
const mockThinkObservation: ObservationEvent = {
id: "test-think-observation-1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isConversationStateUpdateEvent,
isHookExecutionEvent,
isACPToolCallEvent,
isStreamingDeltaEvent,
} from "#/types/agent-server/type-guards";

export const shouldRenderEvent = (event: OpenHandsEvent) => {
Expand Down Expand Up @@ -89,6 +90,10 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
return event.status === "completed" || event.status === "failed";
}

if (isStreamingDeltaEvent(event)) {
return event.content !== null || event.reasoning_content !== null;
}

// Don't render any other event types (system events, etc.)
return false;
};
Expand Down
19 changes: 19 additions & 0 deletions src/components/conversation-events/chat/event-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import {
isPlanningFileEditorObservationEvent,
isHookExecutionEvent,
isACPToolCallEvent,
isStreamingDeltaEvent,
} from "#/types/agent-server/type-guards";
import { useConfig } from "#/hooks/query/use-config";
import { useConversationStore } from "#/stores/conversation-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
import { ChatMessage } from "#/components/features/chat/chat-message";
import { PlanPreview } from "../../features/chat/plan-preview";
import { ErrorEventMessage } from "./event-message-components/error-event-message";
import { UserAssistantEventMessage } from "./event-message-components/user-assistant-event-message";
Expand Down Expand Up @@ -176,6 +178,23 @@ export function EventMessage({
);
}

if (isStreamingDeltaEvent(event)) {
const content = event.content ?? "";
const reasoningContent = event.reasoning_content ?? "";
return (
<>
{reasoningContent && <CollapsibleThinking content={reasoningContent} />}
{content && (
<ChatMessage
type="agent"
message={content}
isFromPlanningAgent={isFromPlanningAgent}
/>
)}
</>
);
}

// Finish actions
if (isActionEvent(event) && event.action.kind === "FinishAction") {
return (
Expand Down
1 change: 1 addition & 0 deletions src/types/agent-server/core/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export * from "./hook-execution-event";
export * from "./message-event";
export * from "./observation-event";
export * from "./pause-event";
export * from "./streaming-delta-event";
export * from "./system-event";
8 changes: 8 additions & 0 deletions src/types/agent-server/core/events/streaming-delta-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { BaseEvent } from "../base/event";

export interface StreamingDeltaEvent extends BaseEvent {
kind: "StreamingDeltaEvent";
source: "agent";
content: string | null;
reasoning_content: string | null;
}
4 changes: 3 additions & 1 deletion src/types/agent-server/core/openhands-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
HookExecutionEvent,
PauseEvent,
ServerErrorEvent,
StreamingDeltaEvent,
} from "./events/index";

/**
Expand All @@ -41,4 +42,5 @@ export type OpenHandsEvent =
| ConversationErrorEvent
// Control events
| PauseEvent
| ServerErrorEvent;
| ServerErrorEvent
| StreamingDeltaEvent;
6 changes: 6 additions & 0 deletions src/types/agent-server/type-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./core/events/conversation-state-event";
import { HookExecutionEvent } from "./core/events/hook-execution-event";
import { ACPToolCallEvent } from "./core/events/acp-tool-call-event";
import { StreamingDeltaEvent } from "./core/events/streaming-delta-event";
import { SystemPromptEvent } from "./core/events/system-event";

/**
Expand Down Expand Up @@ -249,6 +250,11 @@ export const isACPToolCallEvent = (
): event is ACPToolCallEvent =>
"kind" in event && event.kind === "ACPToolCallEvent";

export const isStreamingDeltaEvent = (
event: OpenHandsEvent,
): event is StreamingDeltaEvent =>
"kind" in event && event.kind === "StreamingDeltaEvent";

// =============================================================================
// COMPATIBILITY TYPE GUARDS
// =============================================================================
Expand Down
Loading
Loading