Skip to content

Commit dadeaf8

Browse files
authored
Merge pull request #4 from eimexdev/t3code/fix-imported-chat-formatting
Fix Hermes imported history and native tool activity rendering
2 parents 81aab94 + c79b254 commit dadeaf8

30 files changed

Lines changed: 1617 additions & 84 deletions

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,79 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
242242
it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(
243243
"OrchestrationProjectionPipeline",
244244
(it) => {
245+
it.effect("fully replaces imported history slots on refresh", () =>
246+
Effect.gen(function* () {
247+
const projectionPipeline = yield* OrchestrationProjectionPipeline;
248+
const eventStore = yield* OrchestrationEventStore;
249+
const sql = yield* SqlClient.SqlClient;
250+
const threadId = ThreadId.make("thread-import-refresh");
251+
const messageId = MessageId.make("message-import-refresh");
252+
const originalAt = "2026-01-01T00:00:00.000Z";
253+
const refreshedAt = "2026-01-01T00:01:00.000Z";
254+
255+
yield* eventStore.append({
256+
type: "thread.message-sent",
257+
eventId: EventId.make("evt-import-original"),
258+
aggregateKind: "thread",
259+
aggregateId: threadId,
260+
occurredAt: originalAt,
261+
commandId: CommandId.make("cmd-import-original"),
262+
causationEventId: null,
263+
correlationId: CommandId.make("cmd-import-original"),
264+
metadata: {},
265+
payload: {
266+
threadId,
267+
messageId,
268+
role: "user",
269+
text: "legacy provider wrapper",
270+
turnId: null,
271+
streaming: false,
272+
createdAt: originalAt,
273+
updatedAt: originalAt,
274+
},
275+
});
276+
yield* eventStore.append({
277+
type: "thread.message-sent",
278+
eventId: EventId.make("evt-import-refresh"),
279+
aggregateKind: "thread",
280+
aggregateId: threadId,
281+
occurredAt: refreshedAt,
282+
commandId: CommandId.make("cmd-import-refresh"),
283+
causationEventId: null,
284+
correlationId: CommandId.make("cmd-import-refresh"),
285+
metadata: {},
286+
payload: {
287+
threadId,
288+
messageId,
289+
role: "assistant",
290+
text: "",
291+
replaceText: true,
292+
turnId: null,
293+
streaming: false,
294+
imported: true,
295+
createdAt: refreshedAt,
296+
updatedAt: refreshedAt,
297+
},
298+
});
299+
300+
yield* projectionPipeline.bootstrap;
301+
302+
const rows = yield* sql<{
303+
readonly role: string;
304+
readonly text: string;
305+
readonly createdAt: string;
306+
}>`
307+
SELECT
308+
role,
309+
text,
310+
created_at AS "createdAt"
311+
FROM projection_thread_messages
312+
WHERE message_id = ${messageId}
313+
`;
314+
assert.deepEqual(rows, [{ role: "assistant", text: "", createdAt: refreshedAt }]);
315+
}),
316+
);
317+
245318
it.effect("stores message attachment references without mutating payloads", () =>
246319
Effect.gen(function* () {
247320
const projectionPipeline = yield* OrchestrationProjectionPipeline;

apps/server/src/orchestration/Layers/ProjectionPipeline.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
879879
text: nextText,
880880
...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
881881
isStreaming: event.payload.streaming,
882-
createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
882+
createdAt:
883+
event.payload.imported || previousMessage === undefined
884+
? event.payload.createdAt
885+
: previousMessage.createdAt,
883886
updatedAt: event.payload.updatedAt,
884887
});
885888
return;

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2866,9 +2866,16 @@ describe("ProviderRuntimeIngestion", () => {
28662866
turnId: asTurnId("turn-9"),
28672867
payload: {
28682868
itemType: "command_execution",
2869-
status: "in_progress",
2869+
status: "inProgress",
28702870
title: "Read file",
28712871
detail: "/tmp/file.ts",
2872+
data: {
2873+
toolCallId: "call-read",
2874+
item: {
2875+
name: "read_file",
2876+
input: { path: "/tmp/file.ts" },
2877+
},
2878+
},
28722879
},
28732880
});
28742881

@@ -2883,11 +2890,22 @@ describe("ProviderRuntimeIngestion", () => {
28832890
);
28842891

28852892
expect(thread.session?.status).toBe("ready");
2886-
expect(
2887-
thread.activities.some(
2888-
(activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",
2889-
),
2890-
).toBe(true);
2893+
const toolStarted = thread.activities.find(
2894+
(activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",
2895+
);
2896+
expect(toolStarted?.payload).toEqual({
2897+
itemType: "command_execution",
2898+
status: "inProgress",
2899+
title: "Read file",
2900+
detail: "/tmp/file.ts",
2901+
data: {
2902+
toolCallId: "call-read",
2903+
item: {
2904+
name: "read_file",
2905+
input: { path: "/tmp/file.ts" },
2906+
},
2907+
},
2908+
});
28912909
});
28922910

28932911
it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,10 @@ export function runtimeEventToActivities(
693693
summary: `${event.payload.title ?? "Tool"} started`,
694694
payload: {
695695
itemType: event.payload.itemType,
696+
...(event.payload.status ? { status: event.payload.status } : {}),
697+
...(event.payload.title ? { title: event.payload.title } : {}),
696698
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
699+
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
697700
},
698701
turnId: toTurnId(event.turnId) ?? null,
699702
...maybeSequence,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
CommandId,
3+
DEFAULT_PROVIDER_INTERACTION_MODE,
4+
EventId,
5+
MessageId,
6+
ProjectId,
7+
ProviderInstanceId,
8+
ThreadId,
9+
} from "@t3tools/contracts";
10+
import * as NodeServices from "@effect/platform-node/NodeServices";
11+
import { expect, it } from "@effect/vitest";
12+
import * as Effect from "effect/Effect";
13+
14+
import { decideOrchestrationCommand } from "./decider.ts";
15+
import { createEmptyReadModel, projectEvent } from "./projector.ts";
16+
17+
it.layer(NodeServices.layer)("decider imported messages", (it) => {
18+
it.effect("replaces an existing history slot even when the imported text is empty", () =>
19+
Effect.gen(function* () {
20+
const createdAt = "2026-01-01T00:00:00.000Z";
21+
const projectId = ProjectId.make("project-import");
22+
const threadId = ThreadId.make("thread-import");
23+
const initial = createEmptyReadModel(createdAt);
24+
const withProject = yield* projectEvent(initial, {
25+
sequence: 1,
26+
eventId: EventId.make("event-project-import"),
27+
aggregateKind: "project",
28+
aggregateId: projectId,
29+
type: "project.created",
30+
occurredAt: createdAt,
31+
commandId: CommandId.make("command-project-import"),
32+
causationEventId: null,
33+
correlationId: CommandId.make("command-project-import"),
34+
metadata: {},
35+
payload: {
36+
projectId,
37+
title: "Imports",
38+
workspaceRoot: "/tmp/imports",
39+
defaultModelSelection: null,
40+
scripts: [],
41+
createdAt,
42+
updatedAt: createdAt,
43+
},
44+
});
45+
const readModel = yield* projectEvent(withProject, {
46+
sequence: 2,
47+
eventId: EventId.make("event-thread-import"),
48+
aggregateKind: "thread",
49+
aggregateId: threadId,
50+
type: "thread.created",
51+
occurredAt: createdAt,
52+
commandId: CommandId.make("command-thread-import"),
53+
causationEventId: null,
54+
correlationId: CommandId.make("command-thread-import"),
55+
metadata: {},
56+
payload: {
57+
threadId,
58+
projectId,
59+
title: "Imported thread",
60+
modelSelection: {
61+
instanceId: ProviderInstanceId.make("hermes"),
62+
model: "openai-codex::gpt-5.6-sol",
63+
},
64+
runtimeMode: "full-access",
65+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
66+
branch: null,
67+
worktreePath: null,
68+
createdAt,
69+
updatedAt: createdAt,
70+
},
71+
});
72+
73+
const event = yield* decideOrchestrationCommand({
74+
command: {
75+
type: "thread.message.import",
76+
commandId: CommandId.make("command-message-import"),
77+
threadId,
78+
messageId: MessageId.make("message-import"),
79+
role: "assistant",
80+
text: "",
81+
createdAt: "2026-01-01T00:01:00.000Z",
82+
},
83+
readModel,
84+
});
85+
86+
const importedEvent = Array.isArray(event) ? event[0] : event;
87+
expect(importedEvent?.type).toBe("thread.message-sent");
88+
if (importedEvent?.type !== "thread.message-sent") {
89+
return;
90+
}
91+
expect(importedEvent.payload.imported).toBe(true);
92+
expect(importedEvent.payload.replaceText).toBe(true);
93+
expect(importedEvent.payload.text).toBe("");
94+
}),
95+
);
96+
});

apps/server/src/orchestration/decider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -921,7 +921,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
921921
messageId: command.messageId,
922922
role: command.role,
923923
text: command.text,
924-
turnId: null,
924+
replaceText: true,
925+
turnId: command.turnId ?? null,
925926
streaming: false,
926927
imported: true,
927928
createdAt: command.createdAt,

apps/server/src/orchestration/projector.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ export function projectEvent(
443443
entry.id === message.id
444444
? {
445445
...entry,
446+
role: message.role,
446447
text: message.streaming
447448
? `${entry.text}${message.text}`
448449
: payload.replaceText
@@ -451,6 +452,7 @@ export function projectEvent(
451452
? message.text
452453
: entry.text,
453454
streaming: message.streaming,
455+
createdAt: payload.imported ? message.createdAt : entry.createdAt,
454456
updatedAt: message.updatedAt,
455457
turnId: message.turnId,
456458
...(message.attachments !== undefined

apps/server/src/provider/Layers/HermesAdapter.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,77 @@ it.layer(testLayer)("HermesAdapter", (it) => {
237237
}),
238238
);
239239

240+
it.effect("emits correlated native tool lifecycle events with full tool data", () =>
241+
Effect.gen(function* () {
242+
const { adapter } = yield* HermesAdapterTestHarness;
243+
const threadId = ThreadId.make("hermes-tool-thread");
244+
yield* adapter.startSession({
245+
provider: ProviderDriverKind.make("hermes"),
246+
threadId,
247+
runtimeMode: "full-access",
248+
});
249+
250+
const eventsFiber = yield* adapter.streamEvents.pipe(
251+
Stream.take(3),
252+
Stream.runCollect,
253+
Effect.forkChild,
254+
);
255+
yield* Effect.yieldNow;
256+
const turn = yield* adapter.sendTurn({ threadId, input: "read the skill" });
257+
const sourceMessageId = `hermes-user:${turn.turnId}`;
258+
yield* adapter.receiveCallback({
259+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
260+
requestId: "callback-tool-start-request",
261+
deliveryId: "callback-tool-start-delivery",
262+
type: "tool.started",
263+
chatId: "t3agent",
264+
threadId,
265+
sourceMessageId,
266+
toolCallId: "call-skill",
267+
name: "skill_view",
268+
input: { name: "query" },
269+
});
270+
yield* adapter.receiveCallback({
271+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
272+
requestId: "callback-tool-complete-request",
273+
deliveryId: "callback-tool-complete-delivery",
274+
type: "tool.completed",
275+
chatId: "t3agent",
276+
threadId,
277+
sourceMessageId,
278+
toolCallId: "call-skill",
279+
name: "skill_view",
280+
input: { name: "query" },
281+
result: "Skill loaded",
282+
isError: false,
283+
});
284+
285+
const events = Array.from(yield* Fiber.join(eventsFiber));
286+
NodeAssert.deepEqual(
287+
events.map((event) => event.type),
288+
["turn.started", "item.started", "item.completed"],
289+
);
290+
const started = events[1];
291+
const completed = events[2];
292+
NodeAssert.equal(started?.itemId, completed?.itemId);
293+
NodeAssert.deepEqual(completed?.payload, {
294+
itemType: "mcp_tool_call",
295+
status: "completed",
296+
title: "Read skill",
297+
detail: "Skill loaded",
298+
data: {
299+
toolCallId: "call-skill",
300+
item: {
301+
toolCallId: "call-skill",
302+
name: "skill_view",
303+
input: { name: "query" },
304+
result: { output: "Skill loaded" },
305+
},
306+
},
307+
});
308+
}),
309+
);
310+
240311
it.effect("acknowledges a repeated delivery id as a duplicate", () =>
241312
Effect.gen(function* () {
242313
const { adapter } = yield* HermesAdapterTestHarness;

0 commit comments

Comments
 (0)