Skip to content

Commit 8764064

Browse files
authored
Fix: session_handoff tool not triggering (bristena-op#9)
* feat: add chat.message hook to reliably trigger session_handoff tool * fix: add Zod schema for session_handoff tool args
1 parent a31a253 commit 8764064

2 files changed

Lines changed: 141 additions & 9 deletions

File tree

index.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { buildHandoffPrompt, type HandoffArgs } from "./prompt.ts";
3+
import { isHandoffTrigger, extractGoalFromHandoff } from "./index.ts";
34

45
const baseArgs: HandoffArgs = {
56
previousSessionId: "ses_123",
@@ -95,3 +96,68 @@ describe("buildHandoffPrompt - next steps", () => {
9596
expect(build({ next_steps: [] })).not.toContain("**Next:**");
9697
});
9798
});
99+
100+
describe("isHandoffTrigger", () => {
101+
it("returns true for 'handoff'", () => {
102+
expect(isHandoffTrigger("handoff")).toBe(true);
103+
expect(isHandoffTrigger("Handoff")).toBe(true);
104+
expect(isHandoffTrigger("HANDOFF")).toBe(true);
105+
expect(isHandoffTrigger(" handoff ")).toBe(true);
106+
});
107+
108+
it("returns true for '/handoff'", () => {
109+
expect(isHandoffTrigger("/handoff")).toBe(true);
110+
expect(isHandoffTrigger("/Handoff")).toBe(true);
111+
expect(isHandoffTrigger(" /handoff ")).toBe(true);
112+
});
113+
114+
it("returns true for 'session handoff'", () => {
115+
expect(isHandoffTrigger("session handoff")).toBe(true);
116+
expect(isHandoffTrigger("Session Handoff")).toBe(true);
117+
});
118+
119+
it("returns true for 'handoff <goal>'", () => {
120+
expect(isHandoffTrigger("handoff implement login")).toBe(true);
121+
expect(isHandoffTrigger("handoff fix the bug")).toBe(true);
122+
});
123+
124+
it("returns true for '/handoff <goal>'", () => {
125+
expect(isHandoffTrigger("/handoff implement login")).toBe(true);
126+
expect(isHandoffTrigger("/handoff fix tests")).toBe(true);
127+
});
128+
129+
it("returns false for non-handoff messages", () => {
130+
expect(isHandoffTrigger("implement handoff feature")).toBe(false);
131+
expect(isHandoffTrigger("hello world")).toBe(false);
132+
expect(isHandoffTrigger("hand off the work")).toBe(false);
133+
});
134+
});
135+
136+
describe("extractGoalFromHandoff", () => {
137+
it("extracts goal from 'handoff <goal>'", () => {
138+
expect(extractGoalFromHandoff("handoff implement login")).toBe("implement login");
139+
expect(extractGoalFromHandoff("handoff fix the failing tests")).toBe("fix the failing tests");
140+
expect(extractGoalFromHandoff("Handoff Create PR")).toBe("Create PR");
141+
});
142+
143+
it("extracts goal from '/handoff <goal>'", () => {
144+
expect(extractGoalFromHandoff("/handoff implement login")).toBe("implement login");
145+
expect(extractGoalFromHandoff("/handoff fix tests")).toBe("fix tests");
146+
});
147+
148+
it("returns null for standalone handoff", () => {
149+
expect(extractGoalFromHandoff("handoff")).toBe(null);
150+
expect(extractGoalFromHandoff("/handoff")).toBe(null);
151+
expect(extractGoalFromHandoff(" handoff ")).toBe(null);
152+
});
153+
154+
it("returns null for 'handoff ' with only whitespace after", () => {
155+
expect(extractGoalFromHandoff("handoff ")).toBe(null);
156+
expect(extractGoalFromHandoff("/handoff ")).toBe(null);
157+
});
158+
159+
it("returns null for non-handoff messages", () => {
160+
expect(extractGoalFromHandoff("hello world")).toBe(null);
161+
expect(extractGoalFromHandoff("session handoff")).toBe(null);
162+
});
163+
});

index.ts

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
22
import type { Message, Part } from "@opencode-ai/sdk";
3+
import { z } from "zod";
34
import { buildHandoffPrompt } from "./prompt.ts";
45
import { createAutoUpdateHook } from "./auto-update.ts";
56

@@ -210,6 +211,15 @@ async function executeHandoff(
210211
return `✓ Session "${newTitle}" created (${context.agent || "default"} · ${modelDisplay}). Select it from the picker.`;
211212
}
212213

214+
const handoffArgsSchema = {
215+
summary: z.string().describe("1-3 sentence summary of current state (required)"),
216+
goal: z.string().optional().describe("Goal for the next session if user specified one"),
217+
next_steps: z.array(z.string()).optional().describe("Array of remaining tasks"),
218+
blocked: z.string().optional().describe("Current blocker if any"),
219+
key_decisions: z.array(z.string()).optional().describe("Important decisions made"),
220+
files_modified: z.array(z.string()).optional().describe("Key files that were changed"),
221+
};
222+
213223
function createHandoffTool(pluginCtx: PluginContext) {
214224
return {
215225
description: `Generate a compact continuation prompt and start a new session with it.
@@ -222,16 +232,8 @@ When called, this tool:
222232
223233
IMPORTANT: You MUST provide a concise summary. Do not dump the entire conversation - distill it to essential context only.
224234
225-
Arguments (pass as JSON object):
226-
- summary (required): 1-3 sentence summary of current state
227-
- goal (optional): If the user said "handoff <something>" or "session_handoff <something>", extract what comes after as the goal for the next session
228-
- next_steps (optional): Array of remaining tasks
229-
- blocked (optional): Current blocker if any
230-
- key_decisions (optional): Array of important decisions made
231-
- files_modified (optional): Array of key files changed
232-
233235
The new session will have access to \`read_session\` tool if more context is needed later.`,
234-
args: {},
236+
args: handoffArgsSchema,
235237
async execute(args: Record<string, unknown>, ctx: { sessionID: string }) {
236238
return executeHandoff(pluginCtx, args as unknown as HandoffToolArgs, ctx.sessionID);
237239
},
@@ -290,6 +292,30 @@ This tool fetches the last 20 messages which uses significant tokens. The handof
290292
};
291293
}
292294

295+
export function isHandoffTrigger(text: string): boolean {
296+
const trimmed = text.trim().toLowerCase();
297+
return (
298+
trimmed === "handoff" ||
299+
trimmed === "/handoff" ||
300+
trimmed === "session handoff" ||
301+
trimmed.startsWith("handoff ") ||
302+
trimmed.startsWith("/handoff ")
303+
);
304+
}
305+
306+
export function extractGoalFromHandoff(text: string): string | null {
307+
const trimmed = text.trim();
308+
const lower = trimmed.toLowerCase();
309+
310+
if (lower.startsWith("/handoff ")) {
311+
return trimmed.slice(9).trim() || null;
312+
}
313+
if (lower.startsWith("handoff ")) {
314+
return trimmed.slice(8).trim() || null;
315+
}
316+
return null;
317+
}
318+
293319
const HandoffPlugin: Plugin = async (ctx) => {
294320
const autoUpdateHook = createAutoUpdateHook({
295321
directory: ctx.directory,
@@ -309,6 +335,46 @@ const HandoffPlugin: Plugin = async (ctx) => {
309335
client: ctx.client,
310336
}),
311337
},
338+
"chat.message": async (
339+
_input: {
340+
sessionID: string;
341+
agent?: string;
342+
model?: { providerID: string; modelID: string };
343+
messageID?: string;
344+
variant?: string;
345+
},
346+
output: { message: unknown; parts: Part[] },
347+
) => {
348+
const textParts = output.parts.filter(
349+
(p): p is Part & { type: "text"; text: string } =>
350+
p.type === "text" && typeof (p as { text?: string }).text === "string",
351+
);
352+
353+
for (const part of textParts) {
354+
if (isHandoffTrigger(part.text)) {
355+
const goal = extractGoalFromHandoff(part.text);
356+
357+
part.text = [
358+
'<system-instruction priority="critical">',
359+
"The user has triggered a session handoff. You MUST invoke the `session_handoff` tool immediately.",
360+
"",
361+
"DO NOT interpret this as a regular task request.",
362+
"DO NOT continue working on any previous tasks.",
363+
"DO NOT ask clarifying questions.",
364+
"",
365+
"IMMEDIATELY call the session_handoff tool with:",
366+
"- summary: A brief summary of what was accomplished in this session",
367+
goal ? `- goal: "${goal}"` : "- goal: (not specified)",
368+
"- next_steps: Any remaining tasks from the todo list",
369+
"- key_decisions: Important decisions made during the session",
370+
"- files_modified: Key files that were changed",
371+
"</system-instruction>",
372+
].join("\n");
373+
374+
break;
375+
}
376+
}
377+
},
312378
};
313379
};
314380

0 commit comments

Comments
 (0)