mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 21:28:44 +02:00
fix: classify unstreamed sub-agent messages as thinking
Sub-agent messages bypass the streaming path (turn-delta) entirely due to SDK batching behavior. They arrive only at turn-complete time via FinalizeCompletedMessages. Without classification, they appear as separate chat bubbles cluttering the transcript. Two-pronged fix: Sidecar: FinalizeCompletedMessages now tags messages from _reclassifiedMessageIds with MessageKind='thinking'. This covers messages that WERE streamed and reclassified during the turn. Added MessageKind property to ChatMessageDto. Main process: finalizeTurn detects unstreamed assistant messages (not in existing session.messages) and classifies them as thinking when a visible response was already streamed. Emits message-reclassified events so the renderer can update incrementally, though the primary path is the workspace:updated broadcast which already includes the correct messageKind. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -85,6 +85,7 @@ public sealed class ChatMessageDto
|
|||||||
public string AuthorName { get; init; } = string.Empty;
|
public string AuthorName { get; init; } = string.Empty;
|
||||||
public string Content { get; init; } = string.Empty;
|
public string Content { get; init; } = string.Empty;
|
||||||
public string CreatedAt { get; init; } = string.Empty;
|
public string CreatedAt { get; init; } = string.Empty;
|
||||||
|
public string? MessageKind { get; set; }
|
||||||
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,14 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
ActiveAgent);
|
ActiveAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (ChatMessageDto message in CompletedMessages)
|
||||||
|
{
|
||||||
|
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||||
|
{
|
||||||
|
message.MessageKind = "thinking";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return CompletedMessages;
|
return CompletedMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using Aryx.AgentHost.Services;
|
using Aryx.AgentHost.Services;
|
||||||
using GitHub.Copilot.SDK;
|
using GitHub.Copilot.SDK;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Tests;
|
namespace Aryx.AgentHost.Tests;
|
||||||
|
|
||||||
@@ -601,6 +602,56 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FinalizeCompletedMessages_TagsReclassifiedMessagesAsThinking()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
// Simulate assistant message with tool requests → triggers reclassification
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.message",
|
||||||
|
"data": {
|
||||||
|
"messageId": "msg-intermediate",
|
||||||
|
"content": "Let me search...",
|
||||||
|
"toolRequests": [
|
||||||
|
{
|
||||||
|
"toolCallId": "tool-call-1",
|
||||||
|
"name": "grep",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
state.DrainPendingEvents();
|
||||||
|
|
||||||
|
// Build completed messages with a reclassified and a non-reclassified message
|
||||||
|
ChatMessage intermediateMsg = new(ChatRole.Assistant, "Let me search...");
|
||||||
|
intermediateMsg.MessageId = "msg-intermediate";
|
||||||
|
intermediateMsg.AuthorName = "Primary";
|
||||||
|
|
||||||
|
ChatMessage finalMsg = new(ChatRole.Assistant, "Here are the results.");
|
||||||
|
finalMsg.MessageId = "msg-final";
|
||||||
|
finalMsg.AuthorName = "Primary";
|
||||||
|
|
||||||
|
state.UpdateCompletedMessages([intermediateMsg, finalMsg], []);
|
||||||
|
IReadOnlyList<ChatMessageDto> messages = state.FinalizeCompletedMessages();
|
||||||
|
|
||||||
|
ChatMessageDto intermediate = Assert.Single(messages, m => m.Id == "msg-intermediate");
|
||||||
|
Assert.Equal("thinking", intermediate.MessageKind);
|
||||||
|
|
||||||
|
ChatMessageDto final_ = Assert.Single(messages, m => m.Id == "msg-final");
|
||||||
|
Assert.Null(final_.MessageKind);
|
||||||
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateCommand()
|
private static RunTurnCommandDto CreateCommand()
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
|
|||||||
@@ -1824,6 +1824,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
const incomingIds = new Set(messages.map((message) => message.id));
|
const incomingIds = new Set(messages.map((message) => message.id));
|
||||||
|
|
||||||
|
// Messages that were streamed during the turn already exist in session.messages
|
||||||
|
// (possibly with messageKind: 'thinking' from reclassification). Unstreamed messages
|
||||||
|
// (e.g. from sub-agents) only appear now. Classify them as thinking when a visible
|
||||||
|
// response was already streamed, since they are intermediate tool-driving steps.
|
||||||
|
const existingIds = new Set(session.messages.map((m) => m.id));
|
||||||
|
const hasVisibleResponse = session.messages.some(
|
||||||
|
(m) => m.role === 'assistant' && m.messageKind !== 'thinking',
|
||||||
|
);
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const occurredAt = nowIso();
|
const occurredAt = nowIso();
|
||||||
const existing = session.messages.find((current) => current.id === message.id);
|
const existing = session.messages.find((current) => current.id === message.id);
|
||||||
@@ -1832,9 +1841,22 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
existing.content = message.content;
|
existing.content = message.content;
|
||||||
existing.pending = false;
|
existing.pending = false;
|
||||||
} else {
|
} else {
|
||||||
session.messages.push({ ...message, pending: false });
|
const isUnstreamedIntermediate =
|
||||||
|
message.role === 'assistant'
|
||||||
|
&& hasVisibleResponse
|
||||||
|
&& !message.messageKind;
|
||||||
|
session.messages.push({
|
||||||
|
...message,
|
||||||
|
pending: false,
|
||||||
|
messageKind: message.messageKind ?? (isUnstreamedIntermediate ? 'thinking' : undefined),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reclassifiedAsThinking =
|
||||||
|
!existingIds.has(message.id)
|
||||||
|
&& (message.messageKind === 'thinking'
|
||||||
|
|| (message.role === 'assistant' && hasVisibleResponse && !message.messageKind));
|
||||||
|
|
||||||
const nextRun = this.updateSessionRun(session, requestId, (run) =>
|
const nextRun = this.updateSessionRun(session, requestId, (run) =>
|
||||||
upsertRunMessageEvent(run, {
|
upsertRunMessageEvent(run, {
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
@@ -1851,6 +1873,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
authorName: message.authorName,
|
authorName: message.authorName,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
});
|
});
|
||||||
|
if (reclassifiedAsThinking) {
|
||||||
|
this.emitSessionEvent({
|
||||||
|
sessionId,
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt,
|
||||||
|
messageId: message.id,
|
||||||
|
messageKind: 'thinking',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (nextRun) {
|
if (nextRun) {
|
||||||
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user