feat: improve SessionStart hooks and feedback injection (#803)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
jnjpng
2026-02-03 15:57:58 -08:00
committed by GitHub
parent 8bc50b58ee
commit e2a0545a01
3 changed files with 106 additions and 7 deletions

View File

@@ -1303,9 +1303,12 @@ export default function App({
syncTrajectoryElapsedBase,
]);
// Run SessionStart hooks when agent becomes available
// SessionStart hook feedback to prepend to first user message
const sessionStartFeedbackRef = useRef<string[]>([]);
// Run SessionStart hooks when agent becomes available (not the "loading" placeholder)
useEffect(() => {
if (agentId && !sessionHooksRanRef.current) {
if (agentId && agentId !== "loading" && !sessionHooksRanRef.current) {
sessionHooksRanRef.current = true;
// Determine if this is a new session or resumed
const isNewSession = !initialConversationId;
@@ -1314,9 +1317,16 @@ export default function App({
agentId,
agentName ?? undefined,
conversationIdRef.current ?? undefined,
).catch(() => {
// Silently ignore hook errors
});
)
.then((result) => {
// Store feedback to prepend to first user message
if (result.feedback.length > 0) {
sessionStartFeedbackRef.current = result.feedback;
}
})
.catch(() => {
// Silently ignore hook errors
});
}
}, [agentId, agentName, initialConversationId]);
@@ -5853,6 +5863,22 @@ export default function App({
// Reset turn counter for memory reminders
turnCountRef.current = 0;
// Re-run SessionStart hooks for new conversation
sessionHooksRanRef.current = false;
runSessionStartHooks(
true, // isNewSession
agentId,
agentName ?? undefined,
conversation.id,
)
.then((result) => {
if (result.feedback.length > 0) {
sessionStartFeedbackRef.current = result.feedback;
}
})
.catch(() => {});
sessionHooksRanRef.current = true;
// Update command with success
buffersRef.current.byId.set(cmdId, {
kind: "command",
@@ -5922,6 +5948,22 @@ export default function App({
// Reset turn counter for memory reminders
turnCountRef.current = 0;
// Re-run SessionStart hooks for new conversation
sessionHooksRanRef.current = false;
runSessionStartHooks(
true, // isNewSession
agentId,
agentName ?? undefined,
conversation.id,
)
.then((result) => {
if (result.feedback.length > 0) {
sessionStartFeedbackRef.current = result.feedback;
}
})
.catch(() => {});
sessionHooksRanRef.current = true;
// Update command with success
buffersRef.current.byId.set(cmdId, {
kind: "command",
@@ -7413,6 +7455,14 @@ ${SYSTEM_REMINDER_CLOSE}`;
hasSentSessionContextRef.current = true;
}
// Inject SessionStart hook feedback (stdout on exit 2) into first message only
let sessionStartHookFeedback = "";
if (sessionStartFeedbackRef.current.length > 0) {
sessionStartHookFeedback = `${SYSTEM_REMINDER_OPEN}\n[SessionStart hook context]:\n${sessionStartFeedbackRef.current.join("\n")}\n${SYSTEM_REMINDER_CLOSE}\n\n`;
// Clear after injecting so it only happens once
sessionStartFeedbackRef.current = [];
}
// Build bash command prefix if there are cached commands
let bashCommandPrefix = "";
if (bashCommandCacheRef.current.length > 0) {
@@ -7489,9 +7539,10 @@ ${SYSTEM_REMINDER_CLOSE}
lastNotifiedModeRef.current = currentMode;
}
// Combine reminders with content (session context first, then permission mode, then plan mode, then ralph mode, then skill unload, then bash commands, then hook feedback, then memory reminder, then memfs conflicts)
// Combine reminders with content (session context first, then session start hook, then permission mode, then plan mode, then ralph mode, then skill unload, then bash commands, then hook feedback, then memory reminder, then memfs conflicts)
const allReminders =
sessionContextReminder +
sessionStartHookFeedback +
permissionModeAlert +
planModeReminder +
ralphModeReminder +

View File

@@ -379,6 +379,8 @@ export async function runSetupHooks(
/**
* Run SessionStart hooks when a session begins
* Unlike other hooks, SessionStart collects stdout (not stderr) on exit 2
* to inject context into the first user message
*/
export async function runSessionStartHooks(
isNewSession: boolean,
@@ -405,7 +407,23 @@ export async function runSessionStartHooks(
conversation_id: conversationId,
};
return executeHooks(hooks, input, workingDirectory);
// Run hooks sequentially (SessionStart shouldn't block, but we collect feedback)
const result = await executeHooks(hooks, input, workingDirectory);
// For SessionStart, collect stdout from all hooks regardless of exit code
const feedback: string[] = [];
for (const hookResult of result.results) {
if (hookResult.stdout?.trim()) {
feedback.push(hookResult.stdout.trim());
}
}
return {
blocked: false, // SessionStart never blocks
errored: result.errored,
feedback,
results: result.results,
};
}
/**

View File

@@ -955,6 +955,36 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
expect(parsed.agent_id).toBe("agent-abc");
expect(parsed.agent_name).toBe("My Agent");
});
test("collects stdout as feedback regardless of exit code", async () => {
createHooksConfig({
SessionStart: [
{
matcher: "*",
hooks: [
{
type: "command",
command: "echo 'Session context for agent'",
},
],
},
],
});
const result = await runSessionStartHooks(
true,
"agent-123",
"Test Agent",
undefined,
tempDir,
);
// SessionStart collects stdout regardless of exit code
expect(result.feedback).toHaveLength(1);
expect(result.feedback[0]).toContain("Session context for agent");
// SessionStart never blocks
expect(result.blocked).toBe(false);
});
});
// ============================================================================