Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 16 additions & 7 deletions app/cli/internal/trace/opencode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
jiparis marked this conversation as resolved.
return ""
}

function filePathsFromArgs(args: any): string[] {
if (args?.filePath) return [args.filePath]
if (args?.path) return [args.path]
Expand Down
17 changes: 13 additions & 4 deletions app/cli/internal/trace/opencode/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package opencode

import (
"cmp"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/cli/internal/trace/opencode/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<skill_content name=\"e2e-farewell\">"}]}}]}]}`,
want: []trace.SkillUse{
{Name: "e2e-farewell", Dir: "/repo/.opencode/skills/e2e-farewell", FirstUsedAt: "2026-10-07T10:00:00Z", ByModel: 1},
},
},
}

for _, tc := range testCases {
Expand Down
23 changes: 16 additions & 7 deletions app/cli/internal/trace/opencode/testdata/plugin_full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
23 changes: 16 additions & 7 deletions app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions app/cli/internal/trace/opencode/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading