Skip to content

Commit 765ae64

Browse files
fix(core): Fix for incorrect time.start reset in tool call logging (anomalyco#32574) (anomalyco#32596)
1 parent c066339 commit 765ae64

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

‎packages/opencode/src/session/tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
7474
metadata: val.metadata,
7575
status: "running",
7676
input: args,
77-
time: { start: Date.now() },
77+
time: match.state.status === "running" ? match.state.time : { start: Date.now() },
7878
},
7979
}
8080
}),
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { expect } from "bun:test"
2+
import { ModelV2 } from "@opencode-ai/core/model"
3+
import { ProviderV2 } from "@opencode-ai/core/provider"
4+
import { SessionV1 } from "@opencode-ai/core/v1/session"
5+
import { Agent } from "@/agent/agent"
6+
import { MCP } from "@/mcp"
7+
import { Permission } from "@/permission"
8+
import { Provider } from "@/provider/provider"
9+
import { Session } from "@/session/session"
10+
import { MessageID, PartID, SessionID } from "@/session/schema"
11+
import { SessionProcessor } from "@/session/processor"
12+
import { SessionTools } from "@/session/tools"
13+
import { Tool } from "@/tool/tool"
14+
import { ToolRegistry } from "@/tool/registry"
15+
import { Truncate } from "@/tool/truncate"
16+
import { Plugin } from "@/plugin"
17+
import { Effect, Layer, Schema } from "effect"
18+
import { testEffect } from "../lib/effect"
19+
20+
const callID = "call-test"
21+
const sessionID = SessionID.make("ses_test")
22+
const messageID = MessageID.ascending()
23+
const partID = PartID.ascending()
24+
25+
const agent: Agent.Info = {
26+
name: "build",
27+
mode: "primary",
28+
options: {},
29+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
30+
}
31+
32+
const model = {
33+
providerID: ProviderV2.ID.make("test"),
34+
api: { id: "test-model" },
35+
} as Provider.Model
36+
37+
function fakeMcp() {
38+
return MCP.Service.of({
39+
tools: () => Effect.succeed({}),
40+
} as Partial<MCP.Interface> as MCP.Interface)
41+
}
42+
43+
const fakePlugin = Plugin.Service.of({
44+
init: () => Effect.void,
45+
list: () => Effect.succeed([]),
46+
trigger: (_name, _input, output) => Effect.succeed(output),
47+
} satisfies Plugin.Interface)
48+
49+
const fakePermission = Permission.Service.of({
50+
ask: () => Effect.void,
51+
reply: () => Effect.void,
52+
list: () => Effect.succeed([]),
53+
} satisfies Permission.Interface)
54+
55+
const fakeTruncate = Truncate.Service.of({
56+
cleanup: () => Effect.void,
57+
write: () => Effect.succeed("output.txt"),
58+
output: (text: string) => Effect.succeed({ content: text, truncated: false }),
59+
limits: () => Effect.succeed({ maxLines: 2000, maxBytes: 50 * 1024 }),
60+
} satisfies Truncate.Interface)
61+
62+
const layer = Layer.mergeAll(
63+
Layer.succeed(Plugin.Service, fakePlugin),
64+
Layer.succeed(Permission.Service, fakePermission),
65+
Layer.succeed(MCP.Service, fakeMcp()),
66+
Layer.succeed(Truncate.Service, fakeTruncate),
67+
Layer.succeed(
68+
ToolRegistry.Service,
69+
ToolRegistry.Service.of({
70+
ids: () => Effect.succeed(["timing"]),
71+
all: () => Effect.succeed([]),
72+
named: () => Effect.die("unused"),
73+
tools: () =>
74+
Effect.succeed([
75+
{
76+
id: "timing",
77+
description: "updates metadata more than once",
78+
parameters: Schema.Struct({}),
79+
jsonSchema: { type: "object", properties: {} },
80+
execute: (_args, ctx) =>
81+
Effect.gen(function* () {
82+
yield* ctx.metadata({ metadata: { output: "first" } })
83+
yield* ctx.metadata({ metadata: { output: "second" } })
84+
return { title: "timing", metadata: {}, output: "done" }
85+
}),
86+
} satisfies Tool.Def,
87+
]),
88+
}),
89+
),
90+
)
91+
92+
const it = testEffect(layer)
93+
94+
it.effect("preserves running tool start time across metadata updates", () =>
95+
Effect.gen(function* () {
96+
const state: SessionV1.ToolPart = {
97+
id: partID,
98+
sessionID,
99+
messageID,
100+
type: "tool",
101+
tool: "timing",
102+
callID,
103+
state: {
104+
status: "running",
105+
input: {},
106+
time: { start: 100 },
107+
},
108+
}
109+
const updates: number[] = []
110+
const processor = {
111+
message: {
112+
id: messageID,
113+
sessionID,
114+
role: "assistant",
115+
parentID: MessageID.ascending(),
116+
agent: "build",
117+
mode: "build",
118+
path: { cwd: "/tmp", root: "/tmp" },
119+
cost: 0,
120+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
121+
modelID: ModelV2.ID.make("test-model"),
122+
providerID: ProviderV2.ID.make("test"),
123+
time: { created: 1 },
124+
} satisfies SessionV1.Assistant,
125+
updateToolCall: (_toolCallID, update) =>
126+
Effect.sync(() => {
127+
const next = update(state)
128+
state.state = next.state
129+
if (state.state.status === "running") updates.push(state.state.time.start)
130+
return state
131+
}),
132+
completeToolCall: () => Effect.void,
133+
} satisfies Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
134+
135+
const tools = yield* SessionTools.resolve({
136+
agent,
137+
model,
138+
session: { id: sessionID, permission: [] } as Session.Info,
139+
processor,
140+
bypassAgentCheck: false,
141+
messages: [],
142+
promptOps: {} as never,
143+
})
144+
const execute = tools.timing.execute
145+
if (!execute) throw new Error("timing tool is missing execute")
146+
147+
yield* Effect.promise(() =>
148+
execute(
149+
{},
150+
{
151+
toolCallId: callID,
152+
abortSignal: new AbortController().signal,
153+
},
154+
),
155+
)
156+
157+
expect(updates).toEqual([100, 100])
158+
expect(state.state.status).toBe("running")
159+
if (state.state.status === "running") {
160+
expect(state.state.time.start).toBe(100)
161+
}
162+
}),
163+
)

0 commit comments

Comments
 (0)