Skip to content

Commit fe6e6de

Browse files
committed
Fix assistant timestamps in opencode run
1 parent 31bc0fa commit fe6e6de

File tree

6 files changed

+575
-41
lines changed

6 files changed

+575
-41
lines changed

plugin/gateway-core/dist/hooks/assistant-message-timestamp/index.js

Lines changed: 191 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
12
const TIMESTAMP_PREFIX_LABEL = "[";
3+
const TARGET_EVENT_TYPES = new Set([
4+
"message.updated",
5+
"message.part.updated",
6+
"message.part.delta",
7+
]);
28
export function formatAssistantMessageTimestamp(timestamp) {
39
const value = new Date(timestamp);
410
const year = value.getFullYear();
@@ -9,48 +15,228 @@ export function formatAssistantMessageTimestamp(timestamp) {
915
const seconds = String(value.getSeconds()).padStart(2, "0");
1016
return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`;
1117
}
18+
function debugAuditEnabled() {
19+
return process.env.MY_OPENCODE_ASSISTANT_TIMESTAMP_DEBUG === "1";
20+
}
1221
function prependTimestampToText(text, timestamp) {
1322
const trimmed = text.trim();
1423
if (!trimmed || trimmed.startsWith(TIMESTAMP_PREFIX_LABEL)) {
1524
return text;
1625
}
1726
return `${timestamp}\n${trimmed}`;
1827
}
28+
function prependTimestampToParts(parts, timestamp) {
29+
if (!Array.isArray(parts) || parts.length === 0) {
30+
return false;
31+
}
32+
const textPart = parts.find((part) => part?.type === "text" && typeof part.text === "string");
33+
if (!textPart) {
34+
return false;
35+
}
36+
const next = prependTimestampToText(textPart.text ?? "", timestamp);
37+
if (next === textPart.text) {
38+
return false;
39+
}
40+
textPart.text = next;
41+
return true;
42+
}
1943
function prependTimestampToLatestAssistantMessage(messages, timestamp) {
2044
if (!Array.isArray(messages) || messages.length === 0) {
21-
return;
45+
return false;
2246
}
2347
for (let index = messages.length - 1; index >= 0; index -= 1) {
2448
const message = messages[index];
2549
if (message?.info?.role !== "assistant") {
2650
continue;
2751
}
2852
const parts = Array.isArray(message.parts) ? message.parts : [];
29-
const firstTextPart = parts.find((part) => part?.type === "text" && typeof part.text === "string");
30-
if (firstTextPart) {
31-
firstTextPart.text = prependTimestampToText(firstTextPart.text ?? "", timestamp);
32-
return;
53+
if (prependTimestampToParts(parts, timestamp)) {
54+
return true;
3355
}
3456
parts.unshift({ type: "text", text: timestamp });
3557
message.parts = parts;
58+
return true;
59+
}
60+
return false;
61+
}
62+
function assistantRole(properties) {
63+
return String(properties?.info?.role ?? properties?.role ?? "").trim();
64+
}
65+
function resolveMessageId(properties) {
66+
return String(properties?.info?.id ??
67+
properties?.messageID ??
68+
properties?.messageId ??
69+
properties?.part?.messageID ??
70+
properties?.part?.messageId ??
71+
"").trim();
72+
}
73+
function resolvePartId(properties) {
74+
return String(properties?.partID ?? properties?.partId ?? properties?.part?.id ?? "").trim();
75+
}
76+
function prependTimestampToAssistantLifecyclePayload(properties, timestamp) {
77+
if (!properties || assistantRole(properties) !== "assistant") {
78+
return false;
79+
}
80+
if (prependTimestampToParts(properties.parts, timestamp)) {
81+
return true;
82+
}
83+
if (prependTimestampToParts(properties.messageParts, timestamp)) {
84+
return true;
85+
}
86+
if (properties.message &&
87+
typeof properties.message === "object" &&
88+
prependTimestampToParts(properties.message.parts, timestamp)) {
89+
return true;
90+
}
91+
if (properties.part?.type === "text" && typeof properties.part.text === "string") {
92+
const next = prependTimestampToText(properties.part.text, timestamp);
93+
if (next !== properties.part.text) {
94+
properties.part.text = next;
95+
return true;
96+
}
97+
}
98+
if (properties.message && typeof properties.message === "object") {
99+
const messageText = properties.message.text;
100+
if (typeof messageText === "string") {
101+
const next = prependTimestampToText(messageText, timestamp);
102+
if (next !== messageText) {
103+
properties.message.text = next;
104+
return true;
105+
}
106+
}
107+
}
108+
for (const key of ["text", "content", "delta"]) {
109+
const value = properties[key];
110+
if (typeof value !== "string") {
111+
continue;
112+
}
113+
const next = prependTimestampToText(value, timestamp);
114+
if (next !== value) {
115+
properties[key] = next;
116+
return true;
117+
}
118+
}
119+
return false;
120+
}
121+
function writeDebugAudit(directory, type, properties, applied) {
122+
if (!debugAuditEnabled() || !directory || !TARGET_EVENT_TYPES.has(type)) {
36123
return;
37124
}
125+
const messageValue = properties?.message;
126+
writeGatewayEventAudit(directory, {
127+
hook: "assistant-message-timestamp",
128+
stage: applied ? "inject" : "state",
129+
reason_code: applied
130+
? "assistant_timestamp_lifecycle_applied"
131+
: "assistant_timestamp_lifecycle_noop",
132+
event_type: type,
133+
role: assistantRole(properties),
134+
message_id: resolveMessageId(properties),
135+
part_id: resolvePartId(properties),
136+
field: String(properties?.field ?? ""),
137+
top_level_keys: properties ? Object.keys(properties).join(",") : "",
138+
info_keys: properties?.info && typeof properties.info === "object"
139+
? Object.keys(properties.info).join(",")
140+
: "",
141+
part_keys: properties?.part && typeof properties.part === "object"
142+
? Object.keys(properties.part).join(",")
143+
: "",
144+
has_part: Boolean(properties?.part),
145+
has_parts: Array.isArray(properties?.parts),
146+
has_message_parts: Boolean(messageValue) &&
147+
typeof messageValue === "object" &&
148+
Array.isArray(messageValue.parts),
149+
text_preview: typeof properties?.text === "string"
150+
? properties.text.slice(0, 80)
151+
: typeof properties?.delta === "string"
152+
? properties.delta.slice(0, 80)
153+
: typeof properties?.part?.text === "string"
154+
? properties.part.text.slice(0, 80)
155+
: Array.isArray(properties?.parts) && typeof properties.parts[0]?.text === "string"
156+
? properties.parts[0].text.slice(0, 80)
157+
: "",
158+
});
38159
}
39160
export function createAssistantMessageTimestampHook(options) {
40161
const now = options.now ?? (() => Date.now());
162+
const assistantMessageIds = new Set();
163+
const stampedPartIds = new Set();
164+
const stampedMessageIds = new Set();
41165
return {
42166
id: "assistant-message-timestamp",
43167
priority: 341,
44168
async event(type, payload) {
45169
if (!options.enabled) {
46170
return;
47171
}
172+
if (type === "session.deleted") {
173+
assistantMessageIds.clear();
174+
stampedPartIds.clear();
175+
stampedMessageIds.clear();
176+
return;
177+
}
48178
const timestamp = formatAssistantMessageTimestamp(now());
49179
if (type === "experimental.chat.messages.transform") {
50180
const eventPayload = (payload ?? {});
51181
prependTimestampToLatestAssistantMessage(eventPayload.output?.messages, timestamp);
52182
return;
53183
}
184+
if (type === "experimental.text.complete") {
185+
const eventPayload = (payload ?? {});
186+
if (typeof eventPayload.output?.text === "string") {
187+
eventPayload.output.text = prependTimestampToText(eventPayload.output.text, timestamp);
188+
}
189+
return;
190+
}
191+
if (TARGET_EVENT_TYPES.has(type)) {
192+
const eventPayload = (payload ?? {});
193+
const properties = eventPayload.properties;
194+
let applied = false;
195+
if (type === "message.updated") {
196+
const messageId = resolveMessageId(properties);
197+
if (assistantRole(properties) === "assistant" && messageId) {
198+
assistantMessageIds.add(messageId);
199+
}
200+
applied = prependTimestampToAssistantLifecyclePayload(properties, timestamp);
201+
}
202+
else if (type === "message.part.updated") {
203+
const messageId = resolveMessageId(properties);
204+
const partId = resolvePartId(properties);
205+
if (messageId &&
206+
assistantMessageIds.has(messageId) &&
207+
properties?.part?.type === "text" &&
208+
typeof properties.part.text === "string" &&
209+
!stampedPartIds.has(partId || messageId)) {
210+
const next = prependTimestampToText(properties.part.text, timestamp);
211+
if (next !== properties.part.text) {
212+
properties.part.text = next;
213+
stampedPartIds.add(partId || messageId);
214+
stampedMessageIds.add(messageId);
215+
applied = true;
216+
}
217+
}
218+
}
219+
else if (type === "message.part.delta") {
220+
const messageId = resolveMessageId(properties);
221+
const partId = resolvePartId(properties);
222+
const stampKey = partId || messageId;
223+
const deltaText = properties?.delta;
224+
if (messageId &&
225+
assistantMessageIds.has(messageId) &&
226+
typeof deltaText === "string" &&
227+
!stampedPartIds.has(stampKey)) {
228+
const next = prependTimestampToText(deltaText, timestamp);
229+
if (next !== deltaText && properties) {
230+
properties.delta = next;
231+
stampedPartIds.add(stampKey);
232+
stampedMessageIds.add(messageId);
233+
applied = true;
234+
}
235+
}
236+
}
237+
writeDebugAudit(eventPayload.directory, type, eventPayload.properties, applied);
238+
return;
239+
}
54240
if (type !== "session.idle") {
55241
return;
56242
}

plugin/gateway-core/dist/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ interface ChatMessagesTransformOutput {
140140
interface ChatSystemTransformOutput {
141141
system: string[];
142142
}
143+
interface TextCompleteInput {
144+
sessionID?: string;
145+
messageID?: string;
146+
partID?: string;
147+
}
148+
interface TextCompleteOutput {
149+
text: string;
150+
}
143151
export default function GatewayCorePlugin(ctx: GatewayContext): {
144152
event(input: GatewayEventPayload): Promise<void>;
145153
"tool.execute.before"(input: ToolBeforeInput, output: ToolBeforeOutput): Promise<void>;
@@ -157,5 +165,6 @@ export default function GatewayCorePlugin(ctx: GatewayContext): {
157165
modelID?: string;
158166
};
159167
}, output: ChatSystemTransformOutput): Promise<void>;
168+
"experimental.text.complete"(input: TextCompleteInput, output: TextCompleteOutput): Promise<void>;
160169
};
161170
export {};

plugin/gateway-core/dist/index.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,31 @@ export default function GatewayCorePlugin(ctx) {
10461046
}
10471047
}
10481048
}
1049+
async function textComplete(input, output) {
1050+
writeGatewayEventAudit(directory, {
1051+
hook: "gateway-core",
1052+
stage: "dispatch",
1053+
reason_code: "text_complete_dispatch",
1054+
event_type: "experimental.text.complete",
1055+
has_session_id: typeof input.sessionID === "string" && input.sessionID.trim().length > 0,
1056+
hook_count: hooks.length,
1057+
});
1058+
for (const hook of hooks) {
1059+
const result = await dispatchGatewayHookEvent({
1060+
hook,
1061+
eventType: "experimental.text.complete",
1062+
payload: {
1063+
input,
1064+
output,
1065+
directory,
1066+
},
1067+
directory,
1068+
});
1069+
if (!result.ok && (result.critical || result.blocked)) {
1070+
throw result.error;
1071+
}
1072+
}
1073+
}
10491074
return {
10501075
event,
10511076
"tool.execute.before": toolExecuteBefore,
@@ -1055,5 +1080,6 @@ export default function GatewayCorePlugin(ctx) {
10551080
"chat.message": chatMessage,
10561081
"experimental.chat.messages.transform": chatMessagesTransform,
10571082
"experimental.chat.system.transform": chatSystemTransform,
1083+
"experimental.text.complete": textComplete,
10581084
};
10591085
}

0 commit comments

Comments
 (0)