diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 65db6664a..743170dd4 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -47,14 +47,16 @@ What the user sees in the agent. | Feature | What it means | Claude Code | Cursor | OpenCode 1.x | OpenCode 2 | |---------|---------------|-------------|--------|--------------|------------| -| Welcome message | At session start, the user sees a message that tells that Chainloop records the session, and in which project. | Yes | No | No | No | -| Session link after push | After a `git push` that the agent runs, the user sees a link to the session in Chainloop. | Yes | No | No | No | +| Welcome message | At session start, the user sees a message that tells that Chainloop records the session, and in which project. | Yes | No | Unknown | Yes | +| Session link after push | After a `git push` that the agent runs, the user sees a link to the session in Chainloop, and the model is told to repeat it in its reply. | Yes | No | Unknown | Yes | + +How OpenCode shows them: the hook writes a JSON response to stdout, and the plugin reads it. OpenCode 1.x shows the welcome message and the link as a TUI toast, and adds the link to the output of the shell command for the model. An OpenCode 2 plugin has no toast. The welcome message is the description of the synthetic message that carries the spec capture instruction, which the TUI shows in the transcript. The link is added to the result of the shell command, which the TUI shows in the command block and the model reads. Why not Yes: -- **Welcome message, Cursor and OpenCode:** the provider gives the session-start context to the model only. It has no channel that shows a message to the user at session start, so the welcome message is dropped. +- **Welcome message, Cursor:** the provider gives the session-start context to the model only. It has no channel that shows a message to the user at session start, so the welcome message is dropped. +- **Welcome message and session link after push, OpenCode 1.x:** implemented with the TUI toast of the 1.x SDK, but not verified in a live 1.x session. - **Session link after push, Cursor:** Cursor has no hook after a shell command, so there is no point at which to show the link. -- **Session link after push, OpenCode:** the plugin hook fires after a shell command, but the response that shows a message to the user is not verified yet. The link stays pending until it expires. ## Specs and skills diff --git a/app/cli/internal/trace/claude/announce_test.go b/app/cli/internal/trace/claude/announce_test.go index bc4ccdd90..ff51f3ec9 100644 --- a/app/cli/internal/trace/claude/announce_test.go +++ b/app/cli/internal/trace/claude/announce_test.go @@ -83,6 +83,9 @@ func TestAnnounceSessionStart(t *testing.T) { const ( banner = "Chainloop Trace is recording this session." instruction = "Write the specification this session is working from into /repo/.chainloop/specs/abc-123 now." + // Claude Code prints a systemMessage flush against the transcript, so + // the provider sets the banner apart with blank lines. + framedBanner = "\n\n" + banner + "\n" ) testCases := []struct { @@ -95,7 +98,7 @@ func TestAnnounceSessionStart(t *testing.T) { { name: "both channels in one document", msg: trace.SessionStartMessage{Banner: banner, Instruction: instruction}, - wantBanner: banner, + wantBanner: framedBanner, wantInstruction: instruction, wantEmitted: true, }, @@ -104,7 +107,7 @@ func TestAnnounceSessionStart(t *testing.T) { // still gets the banner, the model is told nothing. name: "a banner with nothing to instruct", msg: trace.SessionStartMessage{Banner: banner}, - wantBanner: banner, + wantBanner: framedBanner, wantEmitted: true, }, { diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go index 9ac279b18..9e68331db 100644 --- a/app/cli/internal/trace/claude/provider.go +++ b/app/cli/internal/trace/claude/provider.go @@ -201,11 +201,20 @@ func (p *Provider) SupportsSessionStartInstruction() bool { // systemMessage must stay top-level; nested inside hookSpecificOutput it is // silently ignored. The event name is the one that fired, not the one // AnnounceToUser hardcodes for its own, different hook. +// +// The blank lines around the banner are this client's framing: Claude Code +// prints a systemMessage flush against the transcript, so without them the +// banner reads as part of whatever came before. func (p *Provider) AnnounceSessionStart(msg trace.SessionStartMessage) error { if msg.Empty() { return nil } + banner := msg.Banner + if banner != "" { + banner = "\n\n" + banner + "\n" + } + type hookSpecificOutput struct { HookEventName string `json:"hookEventName"` AdditionalContext string `json:"additionalContext,omitempty"` @@ -215,7 +224,7 @@ func (p *Provider) AnnounceSessionStart(msg trace.SessionStartMessage) error { SystemMessage string `json:"systemMessage,omitempty"` HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"` }{ - SystemMessage: msg.Banner, + SystemMessage: banner, HookSpecificOutput: hookSpecificOutput{ HookEventName: eventSessionStart, AdditionalContext: msg.Instruction, @@ -283,7 +292,7 @@ func (p *Provider) AnnounceToUser(msg string) error { SystemMessage: msg, HookSpecificOutput: hookSpecificOutput{ HookEventName: eventPostToolUse, - AdditionalContext: "Tell the user the following, including any link verbatim: " + msg, + AdditionalContext: trace.RelayToModelInstruction + msg, }, } diff --git a/app/cli/internal/trace/opencode/announce_test.go b/app/cli/internal/trace/opencode/announce_test.go index 4e4ea8c0f..6abb9ebc5 100644 --- a/app/cli/internal/trace/opencode/announce_test.go +++ b/app/cli/internal/trace/opencode/announce_test.go @@ -26,8 +26,9 @@ import ( "github.com/stretchr/testify/require" ) -// TestAnnounceSessionStart pins the session-start response: the Chainloop plugin reads instruction and posts it to the session as a context-only message. -// There is no channel to the user, so the banner is never emitted. +// TestAnnounceSessionStart pins the session-start response: the Chainloop +// plugin posts instruction to the session as a context-only message and shows +// banner to the user, each OpenCode major on its own channel. func TestAnnounceSessionStart(t *testing.T) { const ( banner = "Chainloop Trace is recording this session." @@ -38,22 +39,22 @@ func TestAnnounceSessionStart(t *testing.T) { name string msg trace.SessionStartMessage // want is the emitted document, or nil for no output at all. - want map[string]any + want *hookResponse }{ { - name: "the instruction is emitted and the banner is not", + name: "the instruction and the banner share one document", msg: trace.SessionStartMessage{Banner: banner, Instruction: instruction}, - want: map[string]any{"instruction": instruction}, + want: &hookResponse{Instruction: instruction, Banner: banner}, }, { name: "an instruction on its own", msg: trace.SessionStartMessage{Instruction: instruction}, - want: map[string]any{"instruction": instruction}, + want: &hookResponse{Instruction: instruction}, }, { - // A banner has nowhere to go, so the hook stays a no-op. - name: "a banner alone emits nothing", + name: "a banner on its own", msg: trace.SessionStartMessage{Banner: banner}, + want: &hookResponse{Banner: banner}, }, { name: "nothing to say emits nothing", @@ -72,9 +73,9 @@ func TestAnnounceSessionStart(t *testing.T) { return } - var got map[string]any + var got hookResponse require.NoError(t, json.Unmarshal([]byte(out), &got)) - assert.Equal(t, tc.want, got) + assert.Equal(t, *tc.want, got) }) } } @@ -88,9 +89,9 @@ func TestAnnouncePromptSubmit(t *testing.T) { testCases := []struct { name string reminder string - want map[string]any + want *hookResponse }{ - {name: "a reminder is emitted", reminder: reminder, want: map[string]any{"instruction": reminder}}, + {name: "a reminder is emitted", reminder: reminder, want: &hookResponse{Instruction: reminder}}, {name: "nothing to say emits nothing", reminder: ""}, } @@ -105,9 +106,46 @@ func TestAnnouncePromptSubmit(t *testing.T) { return } - var got map[string]any + var got hookResponse require.NoError(t, json.Unmarshal([]byte(out), &got)) - assert.Equal(t, tc.want, got) + assert.Equal(t, *tc.want, got) + }) + } +} + +// TestAnnounceToUser pins the response after a shell command: the plugin +// shows message to the user and adds relayToModel to the command result, so +// the model repeats the message in its reply. +func TestAnnounceToUser(t *testing.T) { + const msg = "Coding Session Available at https://app.chainloop.dev/u/acme/sessions/abc-123" + + testCases := []struct { + name string + msg string + want *hookResponse + }{ + { + name: "a message goes out on both channels", + msg: msg, + want: &hookResponse{Message: msg, RelayToModel: trace.RelayToModelInstruction + msg}, + }, + {name: "nothing to say emits nothing", msg: ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, New().AnnounceToUser(tc.msg)) + }) + + if tc.want == nil { + assert.Empty(t, out) + return + } + + var got hookResponse + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, *tc.want, got) }) } } diff --git a/app/cli/internal/trace/opencode/hooks.go b/app/cli/internal/trace/opencode/hooks.go index 772db1e5b..dc83fd351 100644 --- a/app/cli/internal/trace/opencode/hooks.go +++ b/app/cli/internal/trace/opencode/hooks.go @@ -54,7 +54,9 @@ var commandTools = []string{"bash", "shell"} // pluginTemplate is the TypeScript plugin written to .opencode/plugins/chainloop-trace.ts. // It fires chainloop trace hook subcommands on session lifecycle and tool events, // and posts to the session the instruction the session-start hook returns and -// the reminder the user-prompt-submit hook returns at each user message. +// the reminder the user-prompt-submit hook returns at each user message. It +// shows the user the banner the session-start hook returns and the session +// link the hook after a shell command returns. // The {{SessionEndBlock}} placeholder is replaced with the session.deleted handler // for full install, or removed entirely for trace-run install. // @@ -136,41 +138,57 @@ function fire(directory: string, event: string, payload: Record): P }) } -// instructionFrom fires a hook that can answer with an instruction for the -// model, and returns the instruction it wrote to stdout, if Chainloop has one. -async function instructionFrom(directory: string, event: string, sessionID: string, hookEventName: string): Promise { - const out = await fire(directory, event, { session_id: sessionID, hook_event_name: hookEventName }) - if (!out.trim()) return "" +// HookResponse is what a chainloop hook writes to stdout when it has something +// to deliver. It mirrors the Go hookResponse type: the two are one contract +// and change together. A hook with nothing to say writes nothing. +type HookResponse = { + // instruction is for the model, posted to the session as context. + instruction?: string + // banner greets the user when the session starts. + banner?: string + // message is shown to the user after a shell command. + message?: string + // relayToModel is added to the shell command result, so the model repeats + // the message in its reply. + relayToModel?: string +} + +// responseFrom fires a hook and returns the response it wrote to stdout. +async function responseFrom(directory: string, event: string, payload: Record): Promise { + const out = await fire(directory, event, payload) + if (!out.trim()) return {} try { - return JSON.parse(out).instruction ?? "" + return JSON.parse(out) ?? {} } catch (err) { console.error("chainloop-trace: could not read the " + event + " response: " + err) - return "" + return {} } } // Post adds an instruction to the session as a context-only message, which -// the model reads without replying to it. Each OpenCode major has its own API -// for it. -type Post = (sessionID: string, instruction: string) => Promise +// the model reads without replying to it, and shows the user the description, +// if any. Each OpenCode major has its own API for it. +type Post = (sessionID: string, instruction: string, description?: string) => Promise // postInstruction posts the instruction and waits until it is stored, so a // turn sent right away still finds it. A failed post costs the instruction // only, so it is logged and never fails the caller. -async function postInstruction(post: Post, sessionID: string, instruction: string) { +async function postInstruction(post: Post, sessionID: string, instruction: string, description?: string) { try { - await post(sessionID, instruction) + await post(sessionID, instruction, description) } catch (err) { console.error("chainloop-trace: could not post the session instruction: " + err) } } async function sessionCreated(directory: string, sessionID: string, parentID: string | undefined, post: Post) { - const instruction = await instructionFrom(directory, "session-start", sessionID, "session.created") + const res = await responseFrom(directory, "session-start", { session_id: sessionID, hook_event_name: "session.created" }) // A child session belongs to a subagent, whose parent already has the - // instruction. - if (!instruction || parentID) return - await postInstruction(post, sessionID, instruction) + // instruction and the banner. + if (parentID || (!res.instruction && !res.banner)) return + // The banner is the description the user sees. It never goes to the model, + // so a session with no instruction posts nothing to the model. + await postInstruction(post, sessionID, res.instruction ?? "", res.banner) } // childSessions holds the OpenCode 1.x sessions of subagents, whose parent @@ -182,8 +200,8 @@ const childSessions = new Set() // answer with a short reminder to capture a new or changed spec, which is // posted ahead of the turn. async function promptSubmitted(directory: string, sessionID: string, post: Post) { - const reminder = await instructionFrom(directory, "user-prompt-submit", sessionID, "chat.message") - if (reminder) await postInstruction(post, sessionID, reminder) + const res = await responseFrom(directory, "user-prompt-submit", { session_id: sessionID, hook_event_name: "chat.message" }) + if (res.instruction) await postInstruction(post, sessionID, res.instruction) } async function sessionEvent(directory: string, type: string, sessionID: string, parentID: string | undefined, post: Post) { @@ -193,7 +211,9 @@ async function sessionEvent(directory: string, type: string, sessionID: string, {{SessionEndBlock}} } -async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = "") { +// toolEvent fires the hook of a tool call and returns its response. Only the +// hook after a shell command can have one: the session link left by a push. +async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = ""): Promise { const payload = { session_id: sessionID, hook_event_name: hookEventName, tool_name: tool } if (tool === skillTool) { // The skill is loaded after the call, and the result names its folder. @@ -204,8 +224,7 @@ async function toolEvent(directory: string, hook: string, hookEventName: string, if (commandTools.includes(tool)) { // The call ID pairs this hook with the other hook of the same call, so // overlapping commands keep their own snapshots. - await fire(directory, hook, { ...payload, tool_use_id: callID }) - return + return responseFrom(directory, hook, { ...payload, tool_use_id: callID }) } if (!fileWritingTools.includes(tool)) return for (const fp of filePathsFromArgs(args)) { @@ -215,8 +234,19 @@ async function toolEvent(directory: string, hook: string, hookEventName: string, // server is the OpenCode 1.x entry point. async function server({ directory, client }: any) { - // noReply stores the message without asking the model for an answer. - const post: Post = async (sessionID, instruction) => { + // toast shows a message in the TUI. It is not awaited, so a slow TUI never + // holds back the session or a tool result, and a run without a TUI only + // logs the failure. + const toast = (message: string) => { + Promise.resolve() + .then(() => client.tui.showToast({ body: { message, variant: "info" } })) + .catch((err: unknown) => console.error("chainloop-trace: could not show the message: " + err)) + } + // noReply stores the message without asking the model for an answer. The + // description is shown as a toast. + const post: Post = async (sessionID, instruction, description) => { + if (description) toast(description) + if (!instruction) return await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text: instruction, synthetic: true }] }, @@ -255,7 +285,14 @@ async function server({ directory, client }: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", input.sessionID, input.tool, input.callID, output.args) }, "tool.execute.after": async (input: any, output: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") + const res = await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") + // The toast reaches the user now. The tool output reaches the model, + // whose reply stays on screen after the toast is gone. An aborted tool + // can have no output to add to. + if (res?.message) toast(res.message) + if (res?.relayToModel && typeof output?.output === "string") { + output.output = output.output + "\n\n" + res.relayToModel + } }, } } @@ -269,12 +306,20 @@ async function setup(ctx: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", event.sessionID, event.tool, event.id, event.input) }) await ctx.tool.hook("execute.after", async (event: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") + const res = await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") + // The model reads the content parts of the result, and the TUI shows them + // in the command block, so one part reaches both the user and the model. + // A failed command has no result to add to. + if (res?.relayToModel && Array.isArray(event.result?.content)) { + event.result.content.push({ type: "text", text: res.relayToModel }) + } }) // resume: false stores the message without asking the model for an answer. - const post: Post = async (sessionID, instruction) => { - await ctx.session.synthetic({ sessionID, text: instruction, resume: false }) + // The TUI shows a synthetic message by its description only, and the model + // reads its text, which is empty when there is only a banner. + const post: Post = async (sessionID, instruction, description) => { + await ctx.session.synthetic({ sessionID, text: instruction, description, resume: false }) } // A child session belongs to a subagent, whose parent session already gets diff --git a/app/cli/internal/trace/opencode/hooks_test.go b/app/cli/internal/trace/opencode/hooks_test.go index 7def22013..cfc2f51d9 100644 --- a/app/cli/internal/trace/opencode/hooks_test.go +++ b/app/cli/internal/trace/opencode/hooks_test.go @@ -101,12 +101,12 @@ func TestPluginPostsSessionStartInstruction(t *testing.T) { assert.Contains(t, content, "noReply: true") // OpenCode 2: a synthetic message, and resume: false stores it // without a model reply. - assert.Contains(t, content, "ctx.session.synthetic({ sessionID, text: instruction, resume: false })") + assert.Contains(t, content, "ctx.session.synthetic({ sessionID, text: instruction, description, resume: false })") // The handler waits until the message is stored, so a first turn // sent right away cannot reach the model without it. OpenCode 2 // delivers events asynchronously, so its prompt hook also waits for // a session start still in flight. - assert.Contains(t, content, "await post(sessionID, instruction)") + assert.Contains(t, content, "await post(sessionID, instruction, description)") assert.Contains(t, content, `ctx.session.hook("prompt"`) // A child session belongs to a subagent, whose parent already has // the instruction. @@ -115,6 +115,83 @@ func TestPluginPostsSessionStartInstruction(t *testing.T) { } } +// TestPluginShowsTraceMessages pins how the user sees the session-start banner +// and the session link left by a push. OpenCode 1.x shows both as a TUI toast, +// and adds the link to the string output of the command for the model. An +// OpenCode 2 plugin has no toast: the banner is the description of the +// synthetic message, which the TUI shows, and the link is a content part of +// the command result, which the TUI shows in the command block and the model +// reads. A failed or aborted command has no output to add to. +func TestPluginShowsTraceMessages(t *testing.T) { + content := installedPlugin(t) + + testCases := []struct { + name string + wantContains []string + wantNotContains []string + }{ + { + name: "session-start banner", + wantContains: []string{ + // A subagent's session gets no banner: its parent showed one. + "if (parentID || (!res.instruction && !res.banner)) return", + // The banner is for the user only: the model never gets it as + // text, even when there is no instruction to carry it. + `await postInstruction(post, sessionID, res.instruction ?? "", res.banner)`, + // OpenCode 1.x. + "if (description) toast(description)", + "if (!instruction) return", + }, + wantNotContains: []string{"res.instruction || res.banner"}, + }, + { + name: "toast is best effort", + wantContains: []string{ + `client.tui.showToast({ body: { message, variant: "info" } })`, + }, + // A slow or absent TUI must not hold back the session or a tool. + wantNotContains: []string{"await toast(", "await client.tui.showToast("}, + }, + { + name: "session link after a shell command", + wantContains: []string{ + "return responseFrom(directory, hook, { ...payload, tool_use_id: callID })", + // OpenCode 1.x. + "if (res?.message) toast(res.message)", + `if (res?.relayToModel && typeof output?.output === "string") {`, + // OpenCode 2. + "if (res?.relayToModel && Array.isArray(event.result?.content)) {", + `event.result.content.push({ type: "text", text: res.relayToModel })`, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, want := range tc.wantContains { + assert.Contains(t, content, want) + } + for _, unwanted := range tc.wantNotContains { + assert.NotContains(t, content, unwanted) + } + }) + } +} + +// installedPlugin installs the full plugin in a temporary repository and +// returns its content. +func installedPlugin(t *testing.T) string { + t.Helper() + + repoRoot := t.TempDir() + require.NoError(t, New().InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + return string(data) +} + // TestPluginPostsPromptReminder pins how the model receives the spec capture // reminder at each user message: the plugin fires the user-prompt-submit hook // and posts its answer the way it posts the session-start instruction. Each diff --git a/app/cli/internal/trace/opencode/provider.go b/app/cli/internal/trace/opencode/provider.go index f3d539041..d20681f52 100644 --- a/app/cli/internal/trace/opencode/provider.go +++ b/app/cli/internal/trace/opencode/provider.go @@ -174,29 +174,49 @@ func (p *Provider) CleanupAfterEdit(store *state.Store, input *trace.HookInput) store.DeleteFileSnapshot(input.SessionID, input.FilePath) } -// AnnounceSessionStart writes the session-start response that the Chainloop -// plugin reads. The plugin posts the instruction to the session as a -// context-only message (no model reply), which is how the model receives it. -// opencode has no channel that shows a message to the user, so the banner is -// dropped. -// -// A message with no instruction writes nothing, and the plugin posts nothing. -func (p *Provider) AnnounceSessionStart(msg trace.SessionStartMessage) error { - if msg.Instruction == "" { +// hookResponse is what a hook writes to stdout for the Chainloop plugin. OpenCode +// defines no hook response of its own, so the plugin is the other half of this +// type, and the two change together. The plugin picks a channel per field; a +// hook with nothing to say writes nothing. +type hookResponse struct { + // Instruction is posted to the session as a context-only message, which + // the model reads without replying to it. + Instruction string `json:"instruction,omitempty"` + + // Banner greets the user at session start: a TUI toast in OpenCode 1.x, + // the description of the synthetic message in OpenCode 2. + Banner string `json:"banner,omitempty"` + + // Message is shown to the user after a shell command, as a TUI toast in + // OpenCode 1.x. + Message string `json:"message,omitempty"` + + // RelayToModel is added to the result of the shell command, which the + // model reads. OpenCode 2 also shows it in the command block. + RelayToModel string `json:"relayToModel,omitempty"` +} + +// writeHookResponse writes resp as one JSON document on stdout, which is +// reserved for it: the hook logs to stderr and to the trace log file. +func writeHookResponse(resp hookResponse) error { + if resp == (hookResponse{}) { return nil } - resp := struct { - Instruction string `json:"instruction"` - }{Instruction: msg.Instruction} - return json.NewEncoder(os.Stdout).Encode(resp) } -// SupportsSessionStartBanner is false for opencode: the plugin has no channel -// to the user. +// AnnounceSessionStart writes the session-start response that the Chainloop +// plugin reads. The plugin posts the instruction to the session as a +// context-only message (no model reply), and shows the banner to the user. +func (p *Provider) AnnounceSessionStart(msg trace.SessionStartMessage) error { + return writeHookResponse(hookResponse{Instruction: msg.Instruction, Banner: msg.Banner}) +} + +// SupportsSessionStartBanner is true for opencode: the plugin shows the banner +// as a toast in OpenCode 1.x, and as a synthetic message in OpenCode 2. func (p *Provider) SupportsSessionStartBanner() bool { - return false + return true } // SupportsSessionStartInstruction is true for opencode: the plugin posts the @@ -215,23 +235,20 @@ func (p *Provider) SupportsPromptReminder() bool { // plugin reads, in the same shape as the session-start one. The plugin posts // the reminder to the session as a context-only message. func (p *Provider) AnnouncePromptSubmit(reminder string) error { - if reminder == "" { + return writeHookResponse(hookResponse{Instruction: reminder}) +} + +// AnnounceToUser writes the response after a shell command for the plugin to +// deliver on two channels: a toast that reaches the user without the model, +// and the command result, which the model reads so it repeats the message in +// its reply. A toast goes away after a few seconds, and the user who looked +// away is the one this message is for; the reply is still on screen after. +func (p *Provider) AnnounceToUser(msg string) error { + if msg == "" { return nil } - resp := struct { - Instruction string `json:"instruction"` - }{Instruction: reminder} - - return json.NewEncoder(os.Stdout).Encode(resp) -} - -// AnnounceToUser is unsupported for OpenCode until its plugin's response -// shape for surfacing a message is verified against a live session, the way -// Claude Code's was. The hook after a shell command already fires, so wiring -// this up later is a change to this method alone. -func (p *Provider) AnnounceToUser(_ string) error { - return trace.ErrAnnounceUnsupported + return writeHookResponse(hookResponse{Message: msg, RelayToModel: trace.RelayToModelInstruction + msg}) } // ParseSession reads the copied export JSON for sessionID and returns diff --git a/app/cli/internal/trace/opencode/testdata/plugin_full.ts b/app/cli/internal/trace/opencode/testdata/plugin_full.ts index 2eb4e23d2..a94cb2925 100644 --- a/app/cli/internal/trace/opencode/testdata/plugin_full.ts +++ b/app/cli/internal/trace/opencode/testdata/plugin_full.ts @@ -73,41 +73,57 @@ function fire(directory: string, event: string, payload: Record): P }) } -// instructionFrom fires a hook that can answer with an instruction for the -// model, and returns the instruction it wrote to stdout, if Chainloop has one. -async function instructionFrom(directory: string, event: string, sessionID: string, hookEventName: string): Promise { - const out = await fire(directory, event, { session_id: sessionID, hook_event_name: hookEventName }) - if (!out.trim()) return "" +// HookResponse is what a chainloop hook writes to stdout when it has something +// to deliver. It mirrors the Go hookResponse type: the two are one contract +// and change together. A hook with nothing to say writes nothing. +type HookResponse = { + // instruction is for the model, posted to the session as context. + instruction?: string + // banner greets the user when the session starts. + banner?: string + // message is shown to the user after a shell command. + message?: string + // relayToModel is added to the shell command result, so the model repeats + // the message in its reply. + relayToModel?: string +} + +// responseFrom fires a hook and returns the response it wrote to stdout. +async function responseFrom(directory: string, event: string, payload: Record): Promise { + const out = await fire(directory, event, payload) + if (!out.trim()) return {} try { - return JSON.parse(out).instruction ?? "" + return JSON.parse(out) ?? {} } catch (err) { console.error("chainloop-trace: could not read the " + event + " response: " + err) - return "" + return {} } } // Post adds an instruction to the session as a context-only message, which -// the model reads without replying to it. Each OpenCode major has its own API -// for it. -type Post = (sessionID: string, instruction: string) => Promise +// the model reads without replying to it, and shows the user the description, +// if any. Each OpenCode major has its own API for it. +type Post = (sessionID: string, instruction: string, description?: string) => Promise // postInstruction posts the instruction and waits until it is stored, so a // turn sent right away still finds it. A failed post costs the instruction // only, so it is logged and never fails the caller. -async function postInstruction(post: Post, sessionID: string, instruction: string) { +async function postInstruction(post: Post, sessionID: string, instruction: string, description?: string) { try { - await post(sessionID, instruction) + await post(sessionID, instruction, description) } catch (err) { console.error("chainloop-trace: could not post the session instruction: " + err) } } async function sessionCreated(directory: string, sessionID: string, parentID: string | undefined, post: Post) { - const instruction = await instructionFrom(directory, "session-start", sessionID, "session.created") + const res = await responseFrom(directory, "session-start", { session_id: sessionID, hook_event_name: "session.created" }) // A child session belongs to a subagent, whose parent already has the - // instruction. - if (!instruction || parentID) return - await postInstruction(post, sessionID, instruction) + // instruction and the banner. + if (parentID || (!res.instruction && !res.banner)) return + // The banner is the description the user sees. It never goes to the model, + // so a session with no instruction posts nothing to the model. + await postInstruction(post, sessionID, res.instruction ?? "", res.banner) } // childSessions holds the OpenCode 1.x sessions of subagents, whose parent @@ -119,8 +135,8 @@ const childSessions = new Set() // answer with a short reminder to capture a new or changed spec, which is // posted ahead of the turn. async function promptSubmitted(directory: string, sessionID: string, post: Post) { - const reminder = await instructionFrom(directory, "user-prompt-submit", sessionID, "chat.message") - if (reminder) await postInstruction(post, sessionID, reminder) + const res = await responseFrom(directory, "user-prompt-submit", { session_id: sessionID, hook_event_name: "chat.message" }) + if (res.instruction) await postInstruction(post, sessionID, res.instruction) } async function sessionEvent(directory: string, type: string, sessionID: string, parentID: string | undefined, post: Post) { @@ -133,7 +149,9 @@ async function sessionEvent(directory: string, type: string, sessionID: string, } } -async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = "") { +// toolEvent fires the hook of a tool call and returns its response. Only the +// hook after a shell command can have one: the session link left by a push. +async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = ""): Promise { const payload = { session_id: sessionID, hook_event_name: hookEventName, tool_name: tool } if (tool === skillTool) { // The skill is loaded after the call, and the result names its folder. @@ -144,8 +162,7 @@ async function toolEvent(directory: string, hook: string, hookEventName: string, if (commandTools.includes(tool)) { // The call ID pairs this hook with the other hook of the same call, so // overlapping commands keep their own snapshots. - await fire(directory, hook, { ...payload, tool_use_id: callID }) - return + return responseFrom(directory, hook, { ...payload, tool_use_id: callID }) } if (!fileWritingTools.includes(tool)) return for (const fp of filePathsFromArgs(args)) { @@ -155,8 +172,19 @@ async function toolEvent(directory: string, hook: string, hookEventName: string, // server is the OpenCode 1.x entry point. async function server({ directory, client }: any) { - // noReply stores the message without asking the model for an answer. - const post: Post = async (sessionID, instruction) => { + // toast shows a message in the TUI. It is not awaited, so a slow TUI never + // holds back the session or a tool result, and a run without a TUI only + // logs the failure. + const toast = (message: string) => { + Promise.resolve() + .then(() => client.tui.showToast({ body: { message, variant: "info" } })) + .catch((err: unknown) => console.error("chainloop-trace: could not show the message: " + err)) + } + // noReply stores the message without asking the model for an answer. The + // description is shown as a toast. + const post: Post = async (sessionID, instruction, description) => { + if (description) toast(description) + if (!instruction) return await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text: instruction, synthetic: true }] }, @@ -195,7 +223,14 @@ async function server({ directory, client }: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", input.sessionID, input.tool, input.callID, output.args) }, "tool.execute.after": async (input: any, output: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") + const res = await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") + // The toast reaches the user now. The tool output reaches the model, + // whose reply stays on screen after the toast is gone. An aborted tool + // can have no output to add to. + if (res?.message) toast(res.message) + if (res?.relayToModel && typeof output?.output === "string") { + output.output = output.output + "\n\n" + res.relayToModel + } }, } } @@ -209,12 +244,20 @@ async function setup(ctx: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", event.sessionID, event.tool, event.id, event.input) }) await ctx.tool.hook("execute.after", async (event: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") + const res = await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") + // The model reads the content parts of the result, and the TUI shows them + // in the command block, so one part reaches both the user and the model. + // A failed command has no result to add to. + if (res?.relayToModel && Array.isArray(event.result?.content)) { + event.result.content.push({ type: "text", text: res.relayToModel }) + } }) // resume: false stores the message without asking the model for an answer. - const post: Post = async (sessionID, instruction) => { - await ctx.session.synthetic({ sessionID, text: instruction, resume: false }) + // The TUI shows a synthetic message by its description only, and the model + // reads its text, which is empty when there is only a banner. + const post: Post = async (sessionID, instruction, description) => { + await ctx.session.synthetic({ sessionID, text: instruction, description, resume: false }) } // A child session belongs to a subagent, whose parent session already gets diff --git a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts index 43e717716..80aedb8fc 100644 --- a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts +++ b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts @@ -73,41 +73,57 @@ function fire(directory: string, event: string, payload: Record): P }) } -// instructionFrom fires a hook that can answer with an instruction for the -// model, and returns the instruction it wrote to stdout, if Chainloop has one. -async function instructionFrom(directory: string, event: string, sessionID: string, hookEventName: string): Promise { - const out = await fire(directory, event, { session_id: sessionID, hook_event_name: hookEventName }) - if (!out.trim()) return "" +// HookResponse is what a chainloop hook writes to stdout when it has something +// to deliver. It mirrors the Go hookResponse type: the two are one contract +// and change together. A hook with nothing to say writes nothing. +type HookResponse = { + // instruction is for the model, posted to the session as context. + instruction?: string + // banner greets the user when the session starts. + banner?: string + // message is shown to the user after a shell command. + message?: string + // relayToModel is added to the shell command result, so the model repeats + // the message in its reply. + relayToModel?: string +} + +// responseFrom fires a hook and returns the response it wrote to stdout. +async function responseFrom(directory: string, event: string, payload: Record): Promise { + const out = await fire(directory, event, payload) + if (!out.trim()) return {} try { - return JSON.parse(out).instruction ?? "" + return JSON.parse(out) ?? {} } catch (err) { console.error("chainloop-trace: could not read the " + event + " response: " + err) - return "" + return {} } } // Post adds an instruction to the session as a context-only message, which -// the model reads without replying to it. Each OpenCode major has its own API -// for it. -type Post = (sessionID: string, instruction: string) => Promise +// the model reads without replying to it, and shows the user the description, +// if any. Each OpenCode major has its own API for it. +type Post = (sessionID: string, instruction: string, description?: string) => Promise // postInstruction posts the instruction and waits until it is stored, so a // turn sent right away still finds it. A failed post costs the instruction // only, so it is logged and never fails the caller. -async function postInstruction(post: Post, sessionID: string, instruction: string) { +async function postInstruction(post: Post, sessionID: string, instruction: string, description?: string) { try { - await post(sessionID, instruction) + await post(sessionID, instruction, description) } catch (err) { console.error("chainloop-trace: could not post the session instruction: " + err) } } async function sessionCreated(directory: string, sessionID: string, parentID: string | undefined, post: Post) { - const instruction = await instructionFrom(directory, "session-start", sessionID, "session.created") + const res = await responseFrom(directory, "session-start", { session_id: sessionID, hook_event_name: "session.created" }) // A child session belongs to a subagent, whose parent already has the - // instruction. - if (!instruction || parentID) return - await postInstruction(post, sessionID, instruction) + // instruction and the banner. + if (parentID || (!res.instruction && !res.banner)) return + // The banner is the description the user sees. It never goes to the model, + // so a session with no instruction posts nothing to the model. + await postInstruction(post, sessionID, res.instruction ?? "", res.banner) } // childSessions holds the OpenCode 1.x sessions of subagents, whose parent @@ -119,8 +135,8 @@ const childSessions = new Set() // answer with a short reminder to capture a new or changed spec, which is // posted ahead of the turn. async function promptSubmitted(directory: string, sessionID: string, post: Post) { - const reminder = await instructionFrom(directory, "user-prompt-submit", sessionID, "chat.message") - if (reminder) await postInstruction(post, sessionID, reminder) + const res = await responseFrom(directory, "user-prompt-submit", { session_id: sessionID, hook_event_name: "chat.message" }) + if (res.instruction) await postInstruction(post, sessionID, res.instruction) } async function sessionEvent(directory: string, type: string, sessionID: string, parentID: string | undefined, post: Post) { @@ -129,7 +145,9 @@ async function sessionEvent(directory: string, type: string, sessionID: string, } } -async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = "") { +// toolEvent fires the hook of a tool call and returns its response. Only the +// hook after a shell command can have one: the session link left by a push. +async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = ""): Promise { const payload = { session_id: sessionID, hook_event_name: hookEventName, tool_name: tool } if (tool === skillTool) { // The skill is loaded after the call, and the result names its folder. @@ -140,8 +158,7 @@ async function toolEvent(directory: string, hook: string, hookEventName: string, if (commandTools.includes(tool)) { // The call ID pairs this hook with the other hook of the same call, so // overlapping commands keep their own snapshots. - await fire(directory, hook, { ...payload, tool_use_id: callID }) - return + return responseFrom(directory, hook, { ...payload, tool_use_id: callID }) } if (!fileWritingTools.includes(tool)) return for (const fp of filePathsFromArgs(args)) { @@ -151,8 +168,19 @@ async function toolEvent(directory: string, hook: string, hookEventName: string, // server is the OpenCode 1.x entry point. async function server({ directory, client }: any) { - // noReply stores the message without asking the model for an answer. - const post: Post = async (sessionID, instruction) => { + // toast shows a message in the TUI. It is not awaited, so a slow TUI never + // holds back the session or a tool result, and a run without a TUI only + // logs the failure. + const toast = (message: string) => { + Promise.resolve() + .then(() => client.tui.showToast({ body: { message, variant: "info" } })) + .catch((err: unknown) => console.error("chainloop-trace: could not show the message: " + err)) + } + // noReply stores the message without asking the model for an answer. The + // description is shown as a toast. + const post: Post = async (sessionID, instruction, description) => { + if (description) toast(description) + if (!instruction) return await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text: instruction, synthetic: true }] }, @@ -191,7 +219,14 @@ async function server({ directory, client }: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", input.sessionID, input.tool, input.callID, output.args) }, "tool.execute.after": async (input: any, output: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") + const res = await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") + // The toast reaches the user now. The tool output reaches the model, + // whose reply stays on screen after the toast is gone. An aborted tool + // can have no output to add to. + if (res?.message) toast(res.message) + if (res?.relayToModel && typeof output?.output === "string") { + output.output = output.output + "\n\n" + res.relayToModel + } }, } } @@ -205,12 +240,20 @@ async function setup(ctx: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", event.sessionID, event.tool, event.id, event.input) }) await ctx.tool.hook("execute.after", async (event: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") + const res = await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") + // The model reads the content parts of the result, and the TUI shows them + // in the command block, so one part reaches both the user and the model. + // A failed command has no result to add to. + if (res?.relayToModel && Array.isArray(event.result?.content)) { + event.result.content.push({ type: "text", text: res.relayToModel }) + } }) // resume: false stores the message without asking the model for an answer. - const post: Post = async (sessionID, instruction) => { - await ctx.session.synthetic({ sessionID, text: instruction, resume: false }) + // The TUI shows a synthetic message by its description only, and the model + // reads its text, which is empty when there is only a banner. + const post: Post = async (sessionID, instruction, description) => { + await ctx.session.synthetic({ sessionID, text: instruction, description, resume: false }) } // A child session belongs to a subagent, whose parent session already gets diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index 5cda563a1..c53218b8d 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -34,6 +34,13 @@ import ( // single-use content can keep it rather than throw it away unseen. var ErrAnnounceUnsupported = errors.New("agent cannot show messages to the user") +// RelayToModelInstruction prefixes a message that reaches the user through the +// model rather than being shown directly. Agents name that channel differently +// (Claude Code's additionalContext, the result of an opencode shell command), +// but the model reads it as context, not as something to pass on, so every +// provider that uses it has to say so, in the same words. +const RelayToModelInstruction = "Tell the user the following, including any link verbatim: " + // SessionStartMessage is everything the session-start hook has to say, on the // two channels an agent offers: one the user reads, one the model reads. // diff --git a/app/cli/internal/trace/providers/capabilities_test.go b/app/cli/internal/trace/providers/capabilities_test.go index 8ee20a644..4448c9a29 100644 --- a/app/cli/internal/trace/providers/capabilities_test.go +++ b/app/cli/internal/trace/providers/capabilities_test.go @@ -54,10 +54,10 @@ func TestSessionStartChannels(t *testing.T) { }, { provider: opencode.Name, - wantBanner: false, + wantBanner: true, wantInstruction: true, wantReminder: true, - why: "the opencode plugin posts the instruction and the reminder as context-only messages and has no banner", + why: "the opencode plugin posts the instruction and the reminder as context-only messages, and shows the banner as a toast in OpenCode 1.x and as the description of a synthetic message in OpenCode 2", }, } diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index ace543ffc..33a0392d2 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -166,6 +166,15 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error return nil } + // The banner costs a control-plane round trip, and an agent that discards + // it would make the developer pay the wait for nothing. Start it before + // tracking the session rather than after: tracking can shell out to the + // agent to copy its transcript, and neither call needs the other's result. + var dashboardURL <-chan string + if provider.SupportsSessionStartBanner() { + dashboardURL = fetchHookDashboardURLAsync(log) + } + ensureSessionTracked(provider, store, repoRoot, input, log) // An agent can resume a session after its end hook ran, and the record is @@ -176,22 +185,21 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error // call intact. setSessionActive(store, input.SessionID, true, log) - // Each part is composed only for an agent that can receive it. The banner - // in particular costs a control-plane round trip, and an agent that - // discards it would make the developer pay the wait for nothing. + // Each part is composed only for an agent that can receive it. var msg trace.SessionStartMessage if provider.SupportsSessionStartInstruction() { msg.Instruction = sessionSpecInstruction(repoRoot, input.SessionID, provider.SupportsPromptReminder(), log) } - if provider.SupportsSessionStartBanner() { - banner := sessionStartBanner( - hookDashboardURL(log), + if dashboardURL != nil { + // The banner goes out unframed: a transcript and a toast frame it in + // opposite ways, so that is the provider's call. + msg.Banner = sessionStartBanner( + <-dashboardURL, config.LoadOrganizationFromYML(repoRoot), repositoryconfig.LoadProjectFromYML(repoRoot), ) - msg.Banner = "\n\n" + banner + "\n" } if msg.Empty() { @@ -240,6 +248,16 @@ func HandleAgentPromptSubmit(provider trace.Provider, log zerolog.Logger) error return nil } +// fetchHookDashboardURLAsync runs hookDashboardURL on its own goroutine and +// returns the channel its one result arrives on. The channel is buffered, so +// the goroutine ends even if nobody reads the result. +func fetchHookDashboardURLAsync(log zerolog.Logger) <-chan string { + ch := make(chan string, 1) + go func() { ch <- hookDashboardURL(log) }() + + return ch +} + // hookDashboardURL asks the control plane where its web dashboard lives, so // the session banner can name the destination the evidence is bound for. // Returns an empty string when there is no dashboard, no reachable control diff --git a/app/cli/pkg/action/trace_banner_test.go b/app/cli/pkg/action/trace_banner_test.go index 2797a0a8c..70a1b35e4 100644 --- a/app/cli/pkg/action/trace_banner_test.go +++ b/app/cli/pkg/action/trace_banner_test.go @@ -18,6 +18,7 @@ package action import ( "os" "path/filepath" + "strings" "testing" "github.com/chainloop-dev/chainloop/app/cli/internal/trace" @@ -181,7 +182,8 @@ func TestSessionStartChannelGate(t *testing.T) { assert.Equal(t, tc.wantSent, p.sysCalls) if tc.wantBanner { - assert.Contains(t, p.last.Banner, "Chainloop Trace is recording this session.") + assert.True(t, strings.HasPrefix(p.last.Banner, "Chainloop Trace is recording this session."), + "the banner goes out unframed: a transcript and a toast frame it in opposite ways, so that is the provider's call") } else { assert.Empty(t, p.last.Banner) }