feat(coding-agent): add messageId and timestamp to markdown transformer context

* feat(coding-agent): add messageId and timestamp to markdown transformer context

* fix(coding-agent): update compaction render test for messageMeta argument

---------

Co-authored-by: Petr Houška <houskape@gmail.com>
This commit is contained in:
Ville Penttinen 2026-10-22 21:49:38 +03:00 • committed by GitHub
parent 9359adf0c6
commit 64627838b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 90 additions and 7 deletions

View file

@ -1542,6 +1542,10 @@ export interface MarkdownTransformContext {
messageType: "user" | "assistant" | "assistant-thinking";
isStreaming: boolean;
availableWidth: number;
/** Persisted session-entry id. Undefined for live messages or live rendering; available when rendering from persisted session entries. */
messageId?: string;
/** Persisted session-entry ISO timestamp. Undefined for live messages or live rendering; available when rendering from persisted session entries. */
timestamp?: string;
}
export type MarkdownTransformer = (markdown: string, context: MarkdownTransformContext) => string;

View file

@ -21,6 +21,8 @@ export class AssistantMessageComponent extends Container {
private lastMessage?: AssistantMessage;
private hasToolCalls = false;
private isStreaming = false;
private messageId?: string;
private timestamp?: string;
private thinkingVisibilityOverrides = new Map<number, boolean>();
constructor(
@ -30,6 +32,8 @@ export class AssistantMessageComponent extends Container {
hiddenThinkingLabel = "Thinking...",
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
messageId?: string,
timestamp?: string,
) {
super();
@ -38,6 +42,8 @@ export class AssistantMessageComponent extends Container {
this.hiddenThinkingLabel = hiddenThinkingLabel;
this.outputPad = outputPad;
this.markdownTransformers = markdownTransformers;
this.messageId = messageId;
this.timestamp = timestamp;
// Container for text/thinking content
this.contentContainer = new Container();
@ -112,7 +118,13 @@ export class AssistantMessageComponent extends Container {
// Set paddingY=0 to avoid extra spacing before tool executions
this.contentContainer.addChild(
new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme, undefined, {
transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers),
transform: createMarkdownTransform(
"assistant",
this.isStreaming,
this.markdownTransformers,
this.messageId,
this.timestamp,
),
}),
);
} else if (content.type === "thinking") {
@ -157,6 +169,8 @@ export class AssistantMessageComponent extends Container {
"assistant-thinking",
this.isStreaming,
this.markdownTransformers,
this.messageId,
this.timestamp,
),
},
);

View file

@ -4,9 +4,15 @@ export function createMarkdownTransform(
messageType: MarkdownTransformContext["messageType"],
isStreaming: boolean,
transformers: readonly MarkdownTransformer[],
messageId?: string,
timestamp?: string,
): (markdown: string, availableWidth: number) => string {
return (markdown, availableWidth) =>
applyMarkdownTransformers(markdown, { messageType, isStreaming, availableWidth }, transformers);
applyMarkdownTransformers(
markdown,
{ messageType, isStreaming, availableWidth, messageId, timestamp },
transformers,
);
}
function applyMarkdownTransformers(

View file

@ -15,18 +15,24 @@ export class UserMessageComponent extends Container {
private markdownTheme: MarkdownTheme;
private outputPad: number;
private markdownTransformers: readonly MarkdownTransformer[];
private messageId?: string;
private timestamp?: string;
constructor(
text: string,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
messageId?: string,
timestamp?: string,
) {
super();
this.text = text;
this.markdownTheme = markdownTheme;
this.outputPad = outputPad;
this.markdownTransformers = markdownTransformers;
this.messageId = messageId;
this.timestamp = timestamp;
this.rebuild();
}
@ -52,7 +58,13 @@ export class UserMessageComponent extends Container {
{
preserveOrderedListMarkers: true,
preserveBackslashEscapes: true,
transform: createMarkdownTransform("user", false, this.markdownTransformers),
transform: createMarkdownTransform(
"user",
false,
this.markdownTransformers,
this.messageId,
this.timestamp,
),
},
),
);

View file

@ -3834,7 +3834,10 @@ export class InteractiveMode {
this.chatContainer.addChild(component);
}
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
private addMessageToChat(
message: AgentMessage,
options?: { populateHistory?: boolean; messageId?: string; timestamp?: string },
): void {
switch (message.role) {
case "bashExecution": {
const component = new BashExecutionComponent(
@ -3917,6 +3920,8 @@ export class InteractiveMode {
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
this.chatContainer.addChild(userComponent);
}
@ -3926,6 +3931,8 @@ export class InteractiveMode {
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
this.chatContainer.addChild(userComponent);
}
@ -3943,6 +3950,8 @@ export class InteractiveMode {
this.hiddenThinkingLabel,
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
this.chatContainer.addChild(assistantComponent);
break;
@ -3959,6 +3968,7 @@ export class InteractiveMode {
private renderSessionItems(
items: readonly RenderSessionItem[],
messageMeta?: Map<AgentMessage, { id: string; timestamp: string }>,
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
this.pendingTools.clear();
@ -3991,7 +4001,11 @@ export class InteractiveMode {
const message = item;
// Assistant messages need special handling for tool calls
if (message.role === "assistant") {
this.addMessageToChat(message);
const meta = messageMeta?.get(message);
this.addMessageToChat(message, {
messageId: meta?.id,
timestamp: meta?.timestamp,
});
// Render tool call components
for (const content of message.content) {
if (content.type === "toolCall") {
@ -4041,7 +4055,12 @@ export class InteractiveMode {
}
} else {
// All other messages use standard rendering
this.addMessageToChat(message, options);
const meta = messageMeta?.get(message);
this.addMessageToChat(message, {
...options,
messageId: meta?.id,
timestamp: meta?.timestamp,
});
}
}
@ -4063,17 +4082,21 @@ export class InteractiveMode {
): void {
// Selection coordinates point into the transcript being replaced (#9311).
if (this.renderer instanceof TuiAltScreen) this.renderer.resetTextSelection();
const messageMeta = new Map<AgentMessage, { id: string; timestamp: string }>();
const items = entries.flatMap((entry): RenderSessionItem[] => {
if (entry.type === "custom" || (entry.type === "usage" && entry.kind === "cache_warm")) {
return [entry];
}
const messages = sessionEntryToContextMessages(entry);
for (const msg of messages) {
messageMeta.set(msg, { id: entry.id, timestamp: entry.timestamp });
}
if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) {
return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }];
}
return messages;
});
this.renderSessionItems(items, options);
this.renderSessionItems(items, messageMeta, options);
}
private addCacheWarmingUsage(entry: UsageEntry): void {

View file

@ -102,8 +102,15 @@ describe("InteractiveMode compaction events", () => {
expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }),
{ type: "compaction_cost", kind: "compaction", usage: previousUsage },
],
expect.any(Map),
{},
);
const [items, messageMeta] = fakeThis.renderSessionItems.mock.calls[0] as [
unknown[],
Map<unknown, { id: string; timestamp: string }>,
];
expect(messageMeta.get(items[0])).toEqual({ id: "current", timestamp: "2025-01-02T00:00:00Z" });
expect(messageMeta.get(items[2])).toEqual({ id: "previous", timestamp: "2025-01-01T00:00:00Z" });
});
test("renders retained entries and appends the latest summary cost at the bottom", async () => {

View file

@ -55,4 +55,21 @@ describe("UserMessageComponent", () => {
expect(stripAnsi(component.render(80).join("\n"))).toContain("Message after");
});
test("passes persisted entry identity in transformer context; live leaves it undefined", () => {
initTheme("dark");
const captured: Array<{ messageId?: string; timestamp?: string }> = [];
const capture = (_markdown: string, context: { messageId?: string; timestamp?: string }) => {
captured.push({ messageId: context.messageId, timestamp: context.timestamp });
return _markdown;
};
new UserMessageComponent("restored", undefined, 1, [capture], "entry-abc", "2025-01-15T10:30:00.000Z").render(80);
new UserMessageComponent("live", undefined, 1, [capture]).render(80);
expect(captured).toEqual([
{ messageId: "entry-abc", timestamp: "2025-01-15T10:30:00.000Z" },
{ messageId: undefined, timestamp: undefined },
]);
});
});