Skip to content
Merged
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
40 changes: 38 additions & 2 deletions app/src/providers/ChatRuntimeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,26 @@ function chatDoneExtraMetadata(event: ChatDoneEvent): Record<string, unknown> |
return Object.keys(meta).length > 0 ? meta : undefined;
}

/**
* Message id for a reply the CORE already persisted before announcing it.
*
* Core-initiated turns (`client_id === 'system'`: autonomous task sessions and
* background sub-agent result delivery via `run_system_turn_on_thread`) write
* their own closing message — `task_session::append_final`, keyed
* `agent:<run_id>` — and only then emit `chat_done` / `chat_error` with that
* run id as `request_id`. Reusing the same id here makes our own
* `addInferenceResponse` append collapse onto the core's row (the conversation
* store is idempotent by message id) instead of persisting a second copy that
* rendered as a duplicate reply under the answer (#5933). Interactive turns
* keep their generated ids: nothing else has persisted them.
*/
function corePersistedMessageId(event: {
client_id?: string;
request_id?: string;
}): string | undefined {
return event.client_id === 'system' && event.request_id ? `agent:${event.request_id}` : undefined;
}

/**
* Map a `chat_done` event's holistic usage onto the `recordChatTurnUsage`
* payload. Prefers the structured `usage` object (tokens + cost + context window
Expand Down Expand Up @@ -1175,6 +1195,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
addInferenceResponse({
content: event.full_response,
threadId: event.thread_id,
messageId: corePersistedMessageId(event),
extraMetadata: chatDoneExtraMetadata(event),
})
).unwrap();
Expand Down Expand Up @@ -1210,6 +1231,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
addInferenceResponse({
content: event.full_response,
threadId: event.thread_id,
messageId: corePersistedMessageId(event),
extraMetadata: chatDoneExtraMetadata(event),
})
).unwrap();
Expand Down Expand Up @@ -1333,9 +1355,23 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
// surfacing it tells the user *why* the turn failed instead of a blanket apology.
// The hardcoded constant is only a last-resort fallback for an empty/missing message.
const errorContent = event.message || USER_FACING_AGENT_ERROR_MESSAGE;
if (!(lastMsg?.sender === 'agent' && lastMsg?.content === errorContent)) {
// A core-owned failure carries a deterministic id, so dedupe on that
// rather than on the text. Two runs can fail with byte-identical
// content — the same upstream provider message, or the generic
// fallback above — and a text check would then read the previous
// run's row as this one and drop the current failure from the cache.
// Interactive turns have no pre-persisted id and keep the text check.
const errorMessageId = corePersistedMessageId(event);
const alreadyPresent = errorMessageId
? threadMessages.some(message => message.id === errorMessageId)
: lastMsg?.sender === 'agent' && lastMsg?.content === errorContent;
if (!alreadyPresent) {
void dispatch(
addInferenceResponse({ content: errorContent, threadId: event.thread_id })
addInferenceResponse({
content: errorContent,
threadId: event.thread_id,
messageId: errorMessageId,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

Expand Down
141 changes: 141 additions & 0 deletions app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,147 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
await waitFor(() => expect(mockRefetchSnapshot).toHaveBeenCalledTimes(1));
});

it('persists a core-initiated (system) turn under the id the core already wrote (#5933)', async () => {
const listeners = renderProvider();

act(() => {
listeners.onDone?.({
thread_id: 't-sys',
request_id: 'bgdeliver-1',
client_id: 'system',
full_response: 'Same two issues as before',
rounds_used: 1,
});
});

// The same `agent:<run_id>` id `task_session::append_final` used, so the
// core's idempotent store collapses this append onto its own row instead
// of keeping a second copy of the reply.
await waitFor(() =>
expect(threadApi.appendMessage).toHaveBeenCalledWith(
't-sys',
expect.objectContaining({
id: 'agent:bgdeliver-1',
sender: 'agent',
content: 'Same two issues as before',
extraMetadata: expect.objectContaining({ requestId: 'bgdeliver-1' }),
})
)
);
});

it('persists a core-initiated (system) turn failure under the same core id', async () => {
const listeners = renderProvider();

act(() => {
listeners.onError?.({
thread_id: 't-sys-err',
request_id: 'bgdeliver-2',
client_id: 'system',
message: 'Run failed: boom',
error_type: 'inference',
round: null,
});
});

await waitFor(() =>
expect(threadApi.appendMessage).toHaveBeenCalledWith(
't-sys-err',
expect.objectContaining({ id: 'agent:bgdeliver-2', sender: 'agent' })
)
);
});

it('persists a second core failure with identical text under its own id (#5933)', async () => {
const listeners = renderProvider();

act(() => {
listeners.onError?.({
thread_id: 't-sys-err-dup',
request_id: 'bgdeliver-a',
client_id: 'system',
message: 'Run failed: boom',
error_type: 'inference',
round: null,
});
});

await waitFor(() =>
expect(threadApi.appendMessage).toHaveBeenCalledWith(
't-sys-err-dup',
expect.objectContaining({ id: 'agent:bgdeliver-a' })
)
);

// Same thread, same failure text, a different run. The core persisted
// this one as `agent:bgdeliver-b`; deduping on the last row's content
// would read the previous run's row as this one and drop the new
// failure from the cache entirely.
act(() => {
listeners.onError?.({
thread_id: 't-sys-err-dup',
request_id: 'bgdeliver-b',
client_id: 'system',
message: 'Run failed: boom',
error_type: 'inference',
round: null,
});
});

await waitFor(() =>
expect(threadApi.appendMessage).toHaveBeenCalledWith(
't-sys-err-dup',
expect.objectContaining({ id: 'agent:bgdeliver-b', sender: 'agent' })
)
);
expect(threadApi.appendMessage).toHaveBeenCalledTimes(2);
});

it('still suppresses a repeat of the same core failure event', async () => {
const listeners = renderProvider();
const fire = () =>
act(() => {
listeners.onError?.({
thread_id: 't-sys-err-same',
request_id: 'bgdeliver-c',
client_id: 'system',
message: 'Run failed: boom',
error_type: 'inference',
round: null,
});
});

fire();
await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(1));

// The row is in the cache under `agent:bgdeliver-c` now, so the id check
// recognises the redelivery. Trading the content check for an id check
// must not turn a duplicate event into a duplicate append.
fire();
await act(async () => {
await Promise.resolve();
});
expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
});

it('keeps a generated id for an interactive chat_done (nothing else persisted it)', async () => {
const listeners = renderProvider();

act(() => {
listeners.onDone?.({
thread_id: 't-user',
request_id: 'r-user',
full_response: 'hi',
rounds_used: 1,
});
});

await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(1));
const [, persisted] = vi.mocked(threadApi.appendMessage).mock.calls[0];
expect(persisted.id).not.toBe('agent:r-user');
expect(persisted.sender).toBe('agent');
});

it('stores a parked plan review from the plan_review_request event', () => {
const listeners = renderProvider();
act(() => {
Expand Down
59 changes: 59 additions & 0 deletions app/src/services/api/threadApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,65 @@ describe('threadApi', () => {
expect(result).toEqual(message);
});

it('folds the legacy `assistant` sender onto `agent` when listing messages (#5933)', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
data: {
messages: [
{
id: 'user:1',
content: 'hi',
type: 'text',
extraMetadata: {},
sender: 'user',
createdAt: '2026-04-10T12:01:00Z',
},
{
// Written by an autonomous task run before the core switched its
// closing message to the `agent` vocabulary.
id: 'assistant:legacy',
content: 'done',
type: 'text',
extraMetadata: { scope: 'autonomous_task_result' },
sender: 'assistant',
createdAt: '2026-04-10T12:02:00Z',
},
],
count: 2,
},
});

const { threadApi } = await import('./threadApi');
const result = await threadApi.getThreadMessages('default-thread');

expect(result.count).toBe(2);
expect(result.messages.map(m => m.sender)).toEqual(['user', 'agent']);
// Everything else on the row is untouched.
expect(result.messages[1]).toMatchObject({ id: 'assistant:legacy', content: 'done' });
});

it('folds the legacy `assistant` sender onto `agent` on append and update results', async () => {
const stored = {
id: 'agent:run-1',
content: 'done',
type: 'text',
extraMetadata: {},
sender: 'assistant',
createdAt: '2026-04-10T12:02:00Z',
};
mockCallCoreRpc.mockResolvedValueOnce({ data: stored });
mockCallCoreRpc.mockResolvedValueOnce({ data: stored });

const { threadApi } = await import('./threadApi');
const appended = await threadApi.appendMessage('default-thread', {
...stored,
sender: 'agent',
});
const updated = await threadApi.updateMessage('default-thread', 'agent:run-1', {});

expect(appended.sender).toBe('agent');
expect(updated.sender).toBe('agent');
});

it('generates a thread title via threads RPC', async () => {
const thread = {
id: 'default-thread',
Expand Down
19 changes: 16 additions & 3 deletions app/src/services/api/threadApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ function unwrapEnvelope<T>(response: Envelope<T> | T): T {

const generateTitleLog = debug('threadApi.generateTitleIfNeeded');

/**
* The core's `sender` vocabulary is `user` | `agent`, but some core writers
* stored the assistant side as `assistant` (autonomous task sessions before
* #5933, channel-session mirrors). Fold that alias onto `agent` at the transport
* boundary so every `sender === 'agent'` check in the renderers — and the
* assistant-ui role mapping — treats such a row as the assistant instead of
* painting it as a user turn.
*/
function normalizeThreadMessage(message: ThreadMessage): ThreadMessage {
return (message.sender as string) === 'assistant' ? { ...message, sender: 'agent' } : message;
}

export const threadApi = {
createNewThread: async (labels?: string[]): Promise<Thread> => {
const response = await callCoreRpc<Envelope<Thread>>({
Expand All @@ -67,15 +79,16 @@ export const threadApi = {
method: 'openhuman.threads_messages_list',
params: { thread_id: threadId },
});
return unwrapEnvelope(response);
const data = unwrapEnvelope(response);
return { ...data, messages: data.messages.map(normalizeThreadMessage) };
},

appendMessage: async (threadId: string, message: ThreadMessage): Promise<ThreadMessage> => {
const response = await callCoreRpc<Envelope<ThreadMessage>>({
method: 'openhuman.threads_message_append',
params: { thread_id: threadId, message },
});
return unwrapEnvelope(response);
return normalizeThreadMessage(unwrapEnvelope(response));
},

generateTitleIfNeeded: async (threadId: string, assistantMessage?: string): Promise<Thread> => {
Expand Down Expand Up @@ -108,7 +121,7 @@ export const threadApi = {
method: 'openhuman.threads_message_update',
params: { thread_id: threadId, message_id: messageId, extra_metadata: extraMetadata },
});
return unwrapEnvelope(response);
return normalizeThreadMessage(unwrapEnvelope(response));
},

deleteThread: async (threadId: string): Promise<ThreadDeleteData> => {
Expand Down
16 changes: 16 additions & 0 deletions app/src/services/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ export interface TurnUsageWire {
export interface ChatDoneEvent {
thread_id: string;
request_id?: string;
/**
* Socket.IO client that owns the turn. `"system"` marks a turn the core ran
* on its own behalf (autonomous task sessions, background sub-agent result
* delivery, cron/flow agents); such turns are broadcast to every client.
* Always on the wire (`WebChannelEvent.client_id`); declared here for the
* consumers that key off it.
*/
client_id?: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
full_response: string;
Expand Down Expand Up @@ -165,6 +173,14 @@ export interface ChatInterimEvent {
export interface ChatErrorEvent {
thread_id: string;
request_id?: string;
/**
* Socket.IO client that owns the turn. `"system"` marks a turn the core ran
* on its own behalf (autonomous task sessions, background sub-agent result
* delivery, cron/flow agents); such turns are broadcast to every client.
* Always on the wire (`WebChannelEvent.client_id`); declared here for the
* consumers that key off it.
*/
client_id?: string;
message: string;
error_type:
| 'network'
Expand Down
20 changes: 20 additions & 0 deletions app/src/store/__tests__/threadSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,26 @@ describe('threadSlice addInferenceResponse thunk', () => {
expect(state.activeThreadIds).toEqual({});
});

it('replaces a cached entry that already carries the persisted id instead of duplicating it (#5933)', async () => {
// A core-initiated turn's reply is persisted by the core under
// `agent:<run_id>` and announced afterwards; a thread reload can fetch that
// row before our own same-id append resolves.
const store = createStore();
store.dispatch(setSelectedThread('t-1'));
const coreRow = makeMessage({ id: 'agent:run-1', sender: 'agent', content: 'from core' });
mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ messages: [coreRow], count: 1 });
await store.dispatch(loadThreadMessages('t-1'));

mockedThreadApi.appendMessage.mockResolvedValueOnce(coreRow);
await store.dispatch(
addInferenceResponse({ content: 'from core', threadId: 't-1', messageId: 'agent:run-1' })
);

const state = store.getState().thread;
expect(state.messagesByThreadId['t-1']).toEqual([coreRow]);
expect(state.messages).toEqual([coreRow]);
});

it('falls back to the selected thread when no threadId is supplied', async () => {
// Under parallel inference there is no single "active" thread to fall back
// to, so the legacy fallback target is now the selected thread.
Expand Down
Loading
Loading