From 321e6d0f37801ac8c6a3ae4d86179d55bb1e800c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 19 Oct 2026 20:29:53 +0200 Subject: [PATCH] fix(trace): record the skills of OpenCode 2 sessions OpenCode 2 names the skill of a skill tool call in the id input and puts its folder in the directory metadata. The provider and the plugin read only the OpenCode 1.x fields, so an OpenCode 2 session recorded no skill and no copy of it. Fixes #3660 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: be5af72c-30e7-48f4-b46c-dc7a5a754c41 --- app/cli/internal/trace/README.md | 2 +- app/cli/internal/trace/opencode/hooks.go | 23 +++++++++++++------ app/cli/internal/trace/opencode/skills.go | 17 ++++++++++---- .../internal/trace/opencode/skills_test.go | 14 +++++++++++ .../trace/opencode/testdata/plugin_full.ts | 23 +++++++++++++------ .../opencode/testdata/plugin_tracerun.ts | 23 +++++++++++++------ app/cli/internal/trace/opencode/types.go | 3 +++ 7 files changed, 79 insertions(+), 26 deletions(-) diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index cd9faccfd..86eaa1ef8 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -101,7 +101,7 @@ Why not Yes: - **Spec capture reminder, Cursor:** Cursor has no hook at each user prompt. The agent gets the session-start instruction only. - **Spec source role, title, and description, Cursor and OpenCode:** not verified yet. The end-to-end suite finds the values (spec issue-3635). - **Skills tracking, Cursor:** the provider does not track skills. The evidence has no skill entries, which means "not recorded", not "no skill used". -- **Skills tracking, OpenCode:** only the skills that the model starts with the `skill` tool are counted. Skills that the user starts, and skills used in subagents, are not. +- **Skills tracking, OpenCode:** only the skills that the model starts with the `skill` tool are counted. Skills that the user starts, and skills used in subagents, are not. A plugin installed by an earlier version does not send the skill folder for OpenCode 2: install the hooks again to update it. - **Spec source pointers, Cursor and OpenCode:** the provider has no finder for the copies in its transcript yet. For OpenCode, the rebuilt transcript holds no tool outputs, so only the write copies would apply. - **Pasted image pointers, Cursor and OpenCode:** the provider has no finder for pasted images yet. Each agent puts pasted images in a different block shape. The pasted images stay inline in the transcript. The agent is told not to capture them in the spec folder. diff --git a/app/cli/internal/trace/opencode/hooks.go b/app/cli/internal/trace/opencode/hooks.go index c373f8523..fc935b191 100644 --- a/app/cli/internal/trace/opencode/hooks.go +++ b/app/cli/internal/trace/opencode/hooks.go @@ -68,20 +68,29 @@ const commandTools = {{CommandToolsArray}} const skillTool = {{SkillTool}} // skillDirFrom returns the folder of the skill that a skill tool call loaded: -// the dir field of the result metadata, or else the base directory line of -// the result text. Each OpenCode major gives the result in its own shape, so -// each candidate is tried in turn. +// the dir (1.x) or directory (2) field of the result metadata, or else the +// base directory line of the result text. Each OpenCode major gives the +// result in its own shape, so each candidate is tried in turn. function skillDirFrom(...results: any[]): string { for (const r of results) { - const dir = r?.metadata?.dir - if (typeof dir === "string" && dir) return dir - const text = typeof r === "string" ? r : typeof r?.output === "string" ? r.output : "" - const m = /^Base directory for this skill:\s*(.+)$/m.exec(text) + for (const dir of [r?.metadata?.dir, r?.metadata?.directory]) { + if (typeof dir === "string" && dir) return dir + } + const m = /^Base directory for this skill:\s*(.+)$/m.exec(resultText(r)) if (m) return m[1].trim() } return "" } +// resultText returns the text of a tool result: the result itself, its +// output (1.x), or the text items of its content (2). +function resultText(r: any): string { + if (typeof r === "string") return r + if (typeof r?.output === "string") return r.output + if (Array.isArray(r?.content)) return r.content.map((c: any) => (typeof c?.text === "string" ? c.text : "")).join("\n") + return "" +} + function filePathsFromArgs(args: any): string[] { if (args?.filePath) return [args.filePath] if (args?.path) return [args.path] diff --git a/app/cli/internal/trace/opencode/skills.go b/app/cli/internal/trace/opencode/skills.go index 9d3c4d670..c9e1bddb2 100644 --- a/app/cli/internal/trace/opencode/skills.go +++ b/app/cli/internal/trace/opencode/skills.go @@ -16,6 +16,7 @@ package opencode import ( + "cmp" "encoding/json" "fmt" "os" @@ -75,13 +76,18 @@ func exportSkills(export *exportData) []trace.SkillUse { continue } + // OpenCode 1.x names the skill in "name", OpenCode 2 in "id". var input struct { Name string `json:"name"` + ID string `json:"id"` } - if json.Unmarshal(part.State.Input, &input) != nil || strings.TrimSpace(input.Name) == "" { + if json.Unmarshal(part.State.Input, &input) != nil { + continue + } + name := strings.TrimSpace(cmp.Or(input.Name, input.ID)) + if name == "" { continue } - name := strings.TrimSpace(input.Name) use, ok := uses[name] if !ok { @@ -101,8 +107,11 @@ func exportSkills(export *exportData) []trace.SkillUse { // toolSkillDir returns the skill folder of a skill tool call: from the tool // metadata, or else from the base directory line of the tool output. func toolSkillDir(st *toolState) string { - if st.Metadata != nil && filepath.IsAbs(st.Metadata.Dir) { - return filepath.Clean(st.Metadata.Dir) + if st.Metadata != nil { + // OpenCode 1.x puts the folder in "dir", OpenCode 2 in "directory". + if dir := cmp.Or(st.Metadata.Dir, st.Metadata.Directory); filepath.IsAbs(dir) { + return filepath.Clean(dir) + } } var output string diff --git a/app/cli/internal/trace/opencode/skills_test.go b/app/cli/internal/trace/opencode/skills_test.go index 430c1c73c..577819011 100644 --- a/app/cli/internal/trace/opencode/skills_test.go +++ b/app/cli/internal/trace/opencode/skills_test.go @@ -78,6 +78,20 @@ func TestSessionSkills(t *testing.T) { {Name: "pdf", Dir: "/repo/.opencode/skills/pdf", FirstUsedAt: "2026-10-07T10:03:00Z", ByModel: 1}, }, }, + { + // OpenCode 2 names the skill in "id" and puts the folder in + // metadata.directory. Its tool state has no output. + name: "OpenCode 2 export", + export: `{"info":{"id":"` + sessionID + `","location":{"directory":"/repo"},"time":{"created":1}},"messages":[` + + `{"type":"user","id":"msg_1","text":"use the skill","time":{"created":1791367140000}},` + + `{"type":"assistant","id":"msg_2","time":{"created":1791367200000},"content":[` + + `{"type":"tool","id":"call_1","name":"skill","state":{"status":"completed","input":{"id":"e2e-farewell"},` + + `"metadata":{"name":"e2e-farewell","directory":"/repo/.opencode/skills/e2e-farewell","truncated":false},` + + `"content":[{"type":"text","text":""}]}}]}]}`, + want: []trace.SkillUse{ + {Name: "e2e-farewell", Dir: "/repo/.opencode/skills/e2e-farewell", FirstUsedAt: "2026-10-07T10:00:00Z", ByModel: 1}, + }, + }, } for _, tc := range testCases { diff --git a/app/cli/internal/trace/opencode/testdata/plugin_full.ts b/app/cli/internal/trace/opencode/testdata/plugin_full.ts index 71f2a458e..2471f07fb 100644 --- a/app/cli/internal/trace/opencode/testdata/plugin_full.ts +++ b/app/cli/internal/trace/opencode/testdata/plugin_full.ts @@ -5,20 +5,29 @@ const commandTools = ["bash","shell"] const skillTool = "skill" // skillDirFrom returns the folder of the skill that a skill tool call loaded: -// the dir field of the result metadata, or else the base directory line of -// the result text. Each OpenCode major gives the result in its own shape, so -// each candidate is tried in turn. +// the dir (1.x) or directory (2) field of the result metadata, or else the +// base directory line of the result text. Each OpenCode major gives the +// result in its own shape, so each candidate is tried in turn. function skillDirFrom(...results: any[]): string { for (const r of results) { - const dir = r?.metadata?.dir - if (typeof dir === "string" && dir) return dir - const text = typeof r === "string" ? r : typeof r?.output === "string" ? r.output : "" - const m = /^Base directory for this skill:\s*(.+)$/m.exec(text) + for (const dir of [r?.metadata?.dir, r?.metadata?.directory]) { + if (typeof dir === "string" && dir) return dir + } + const m = /^Base directory for this skill:\s*(.+)$/m.exec(resultText(r)) if (m) return m[1].trim() } return "" } +// resultText returns the text of a tool result: the result itself, its +// output (1.x), or the text items of its content (2). +function resultText(r: any): string { + if (typeof r === "string") return r + if (typeof r?.output === "string") return r.output + if (Array.isArray(r?.content)) return r.content.map((c: any) => (typeof c?.text === "string" ? c.text : "")).join("\n") + return "" +} + function filePathsFromArgs(args: any): string[] { if (args?.filePath) return [args.filePath] if (args?.path) return [args.path] diff --git a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts index 74749d73e..a678057b9 100644 --- a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts +++ b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts @@ -5,20 +5,29 @@ const commandTools = ["bash","shell"] const skillTool = "skill" // skillDirFrom returns the folder of the skill that a skill tool call loaded: -// the dir field of the result metadata, or else the base directory line of -// the result text. Each OpenCode major gives the result in its own shape, so -// each candidate is tried in turn. +// the dir (1.x) or directory (2) field of the result metadata, or else the +// base directory line of the result text. Each OpenCode major gives the +// result in its own shape, so each candidate is tried in turn. function skillDirFrom(...results: any[]): string { for (const r of results) { - const dir = r?.metadata?.dir - if (typeof dir === "string" && dir) return dir - const text = typeof r === "string" ? r : typeof r?.output === "string" ? r.output : "" - const m = /^Base directory for this skill:\s*(.+)$/m.exec(text) + for (const dir of [r?.metadata?.dir, r?.metadata?.directory]) { + if (typeof dir === "string" && dir) return dir + } + const m = /^Base directory for this skill:\s*(.+)$/m.exec(resultText(r)) if (m) return m[1].trim() } return "" } +// resultText returns the text of a tool result: the result itself, its +// output (1.x), or the text items of its content (2). +function resultText(r: any): string { + if (typeof r === "string") return r + if (typeof r?.output === "string") return r.output + if (Array.isArray(r?.content)) return r.content.map((c: any) => (typeof c?.text === "string" ? c.text : "")).join("\n") + return "" +} + function filePathsFromArgs(args: any): string[] { if (args?.filePath) return [args.filePath] if (args?.path) return [args.path] diff --git a/app/cli/internal/trace/opencode/types.go b/app/cli/internal/trace/opencode/types.go index 638e66850..824ea10b2 100644 --- a/app/cli/internal/trace/opencode/types.go +++ b/app/cli/internal/trace/opencode/types.go @@ -122,7 +122,10 @@ type toolState struct { // toolMetadata holds the fields of the tool metadata that the provider reads. type toolMetadata struct { + // Dir is the skill folder in OpenCode 1.x. Dir string `json:"dir,omitempty"` + // Directory is the skill folder in OpenCode 2. + Directory string `json:"directory,omitempty"` } // exportDataV2 is the top-level shape of `opencode session export`, the