Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8bcf140
fix: Claude Stop-hook continuations no longer hide the answer
AppLoidx Oct 30, 2026
9cbbe0f
test(server): replay a live Claude Stop hook additionalContext contin…
ElliotDrel Nov 4, 2026
a080c77
fix(server): show Claude Stop hook continuations that only add context
ElliotDrel Nov 4, 2026
731ba91
fix(server): validate the Stop hook output discriminator
ElliotDrel Nov 4, 2026
7cc33d2
Merge pull request #1 from ElliotDrel/t3/claude-stop-hook-include-hoo…
AppLoidx Nov 4, 2026
801d19e
Merge pingdotgg/t3code main into fix/claude-stop-hook-answer-visible-2
ElliotDrel Nov 4, 2026
c1e9a60
fix(server): use SecureRandom for the Stop-hook fallback id on Effect…
ElliotDrel Nov 4, 2026
c489ee4
fix(server): keep Claude hook output out of native logs
ElliotDrel Nov 4, 2026
3003733
Merge pingdotgg/t3code main into fix/claude-stop-hook-answer-visible-2
AppLoidx Nov 4, 2026
decb426
Merge pingdotgg/t3code main into fix/claude-stop-hook-answer-visible-2
AppLoidx Nov 6, 2026
49898db
fix(claude): merge main while preserving Stop hook events
ElliotDrel Nov 9, 2026
9aa351b
Merge remote-tracking branch 'origin/main' into t3/resolve-stop-hook-…
ElliotDrel Nov 9, 2026
ae45985
Merge pingdotgg/t3code main into fix/claude-stop-hook-answer-visible-2
AppLoidx Nov 9, 2026
217c63f
Merge pingdotgg/t3code main into fix/claude-stop-hook-answer-visible-2
maria-rcks Nov 10, 2026
22cd035
Merge remote-tracking branch 'upstream/main' into fix/claude-stop-hoo…
AppLoidx Nov 11, 2026
8ae8634
Merge remote-tracking branch 'upstream/main' into fix/claude-stop-hoo…
AppLoidx Nov 12, 2026
c0a4217
Merge remote-tracking branch 'origin/main' into fix/claude-stop-hook-…
juliusmarminge Nov 12, 2026
5e8e285
test(server): drop frame-mutation assertions from the hook log test
juliusmarminge Nov 12, 2026
4cb13b2
Merge remote-tracking branch 'origin/main' into fix/claude-stop-hook-…
juliusmarminge Nov 12, 2026
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
39 changes: 39 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,45 @@ describe("buildThreadFeed", () => {
label: "Worked for 2s",
});

const hook: OrchestrationV2TurnItem = {
...base("stop-hook", "2026-06-20T00:00:02.800Z", 4),
type: "notification",
source: { kind: "system" },
outcome: "updated",
summary: "Stop hook asked Claude to continue",
detail: "[./check.sh]: Run the formatter before finishing.",
};
const secondAnswer = {
...assistantMessage("2026-06-20T00:00:02.900Z"),
id: TurnItemId.make("item-second"),
messageId: MessageId.make("message-second"),
text: "The formatter has run.",
};
const secondHook = {
...hook,
...base("stop-hook-2", "2026-06-20T00:00:02.950Z", 6),
};
const hookFeed = buildThreadFeed([
projected(userMessage(), 0),
projected(opening, 1),
projected(middle, 2),
projected(command("2026-06-20T00:00:02.600Z"), 3),
projected(hook, 4),
projected(secondAnswer, 5),
projected(secondHook, 6),
projected(assistantMessage(), 7),
]);
const hookRows = deriveThreadFeedPresentation(hookFeed, latestRun, new Set());
expect(hookRows.map((entry) => entry.id)).toEqual([
"message-user",
"run-fold:run-1",
"message-middle",
"local:thread-1:stop-hook",
"message-second",
"local:thread-1:stop-hook-2",
"message-assistant",
]);

const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
expect(expanded.map((entry) => entry.type)).toEqual([
"message",
Expand Down
19 changes: 18 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ import type {
ScheduledTaskId,
TurnItemId,
} from "@t3tools/contracts";
import { idleCompactionOf, isCompactionCommandMessage, RunId, ThreadId } from "@t3tools/contracts";
import {
CLAUDE_STOP_HOOK_CONTINUATION_SUMMARY,
idleCompactionOf,
isCompactionCommandMessage,
RunId,
ThreadId,
} from "@t3tools/contracts";
import {
classifyToolActivity,
collectToolFilePaths,
Expand Down Expand Up @@ -1129,6 +1135,7 @@ function deriveThreadFeedRunFolds(
feedFailedRunIds: ReadonlySet<RunId>,
): ReadonlyMap<string, ThreadFeedRunFold> {
const terminalAssistantMessageIdByRun = new Map<RunId, string>();
const stopHookAssistantMessageIds = new Set<string>();
const retainedAnswerIds = answersBeforeSteers(feed);
const interruptedRunIds = new Set<RunId>();
const failedRunIds = new Set(feedFailedRunIds);
Expand Down Expand Up @@ -1195,6 +1202,15 @@ function deriveThreadFeedRunFolds(
if (entry.type !== "activity-group") continue;
for (const activity of entry.activities) {
const item = activity.projectedItem.item;
if (
item.type === "notification" &&
item.source.kind === "system" &&
item.summary === CLAUDE_STOP_HOOK_CONTINUATION_SUMMARY &&
item.runId === runId
) {
const answerId = terminalAssistantMessageIdByRun.get(runId);
if (answerId !== undefined) stopHookAssistantMessageIds.add(answerId);
}
if (item.type === "run_interrupt_result") interruptedRunIds.add(runId);
if (
runId === runlessKey &&
Expand Down Expand Up @@ -1229,6 +1245,7 @@ function deriveThreadFeedRunFolds(
.filter(
(entry) =>
entry.id !== terminalAssistantId &&
!stopHookAssistantMessageIds.has(entry.id) &&
!(entry.type === "message" && retainedAnswerIds.has(entry.message.id)) &&
entry.type !== "html-render" &&
entry.type !== "mcp-app" &&
Expand Down
9 changes: 9 additions & 0 deletions apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT } from "../src/orchestration-v2/t
import { CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/input.ts";
import { CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/input.ts";
import { CLAUDE_MCP_TOOL_PRESENTATION_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts";
import { CLAUDE_STOP_HOOK_CONTINUATION_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_stop_hook_continuation/input.ts";
import { CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/input.ts";
import { CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts";
import {
Expand Down Expand Up @@ -252,6 +253,14 @@ const CLAUDE_RECORDINGS = {
queryMode: "streaming",
enableTools: true,
},
// Record with T3_CLAUDE_REPLAY_CWD at a scratch project whose .claude/settings.json has the
// Stop hook described in the fixture's input.ts.
claude_stop_hook_continuation: {
prompts: [CLAUDE_STOP_HOOK_CONTINUATION_PROMPT],
defaultTranscriptFile: "fixtures/claude_stop_hook_continuation/claude_transcript.ndjson",
queryMode: "streaming",
enableTools: true,
},
subagent: {
prompts: [SUBAGENT_PROMPT],
defaultTranscriptFile: "fixtures/subagent/claude_transcript.ndjson",
Expand Down
148 changes: 147 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21978,7 +21978,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
uuid,
session_id: WAKE_NATIVE_SESSION,
});
const stopHookFeedback = (uuid: string, condition: string, reason: string) =>
const stopHookFeedback = (uuid: string | undefined, condition: string, reason: string) =>
claudeSdkFrame({
type: "user",
message: {
Expand All @@ -21997,6 +21997,147 @@ describe("ClaudeAdapterV2 background wake turns", () => {
: [],
);

it.effect.each([
{ activeGoal: false, uuid: "stop-hook" },
{ activeGoal: true, uuid: "stop-hook" },
{ activeGoal: false, uuid: undefined },
])(
"shows non-goal Stop hook feedback in the current run, active goal=$activeGoal, uuid=$uuid",
({ activeGoal, uuid }) =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const input = makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("stop-hook-attempt"),
text: "Check the changes.",
attachments: [],
});
yield* harness.runtime.startTurn(input);
if (activeGoal) {
yield* Queue.offer(
harness.sdkMessages,
syntheticFrame("goal-set", "Goal set: all tests pass"),
);
}
for (const frame of [
makeAssistantTextFrame({ uuid: "answer", text: "Here is the answer." }),
stopHookFeedback(uuid, "./check.sh", "Run the formatter before finishing."),
makeAssistantTextFrame({ uuid: "reply", text: "The formatter has run." }),
makeResultFrame({ uuid: "result", result: "The formatter has run." }),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
const notices = harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "notification"
? [event.turnItem]
: [],
);
assert.lengthOf(notices, 1);
assert.deepInclude(notices[0], {
type: "notification",
runId: input.runId,
status: "completed",
source: { kind: "system" },
summary: "Stop hook asked Claude to continue",
detail: "[./check.sh]: Run the formatter before finishing.",
});
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
);

it.effect("shows only Stop hook responses that continue with additionalContext", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const input = makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("stop-hook-response-attempt"),
text: "Check the changes.",
attachments: [],
});
yield* harness.runtime.startTurn(input);
const hookResponse = (
uuid: string,
hookEvent: string,
outcome: "success" | "error",
output: object | "",
) => {
const stdout = output === "" ? "" : JSON.stringify(output);
return claudeSdkFrame({
type: "system",
subtype: "hook_response",
hook_id: uuid,
hook_name: hookEvent,
hook_event: hookEvent,
output: stdout,
stdout,
stderr: "",
exit_code: 0,
outcome,
uuid,
session_id: WAKE_NATIVE_SESSION,
});
};
const context = (text: string) => ({
hookSpecificOutput: { hookEventName: "Stop", additionalContext: text },
});
for (const frame of [
makeAssistantTextFrame({ uuid: "answer", text: "Here is the answer." }),
hookResponse("post-tool", "PostToolUse", "success", context("Not a stop.")),
hookResponse("failed", "Stop", "error", context("The hook failed.")),
hookResponse("missing-event", "Stop", "success", {
hookSpecificOutput: { additionalContext: "Missing event name." },
}),
hookResponse("wrong-event", "Stop", "success", {
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: "Wrong event name.",
},
}),
hookResponse("halt", "Stop", "success", { ...context("Halted."), continue: false }),
hookResponse("continue", "Stop", "success", context("Run the formatter.")),
makeAssistantTextFrame({ uuid: "reply", text: "The formatter has run." }),
hookResponse("done", "Stop", "success", ""),
makeResultFrame({ uuid: "result", result: "The formatter has run." }),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
const notices = harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "notification"
? [event.turnItem]
: [],
);
assert.deepEqual(
notices.map((notice) => [notice.runId, notice.detail]),
[[input.runId, "Run the formatter."]],
);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
);

it.effect("tracks a /goal through unmet checks until Claude stops on its own", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
Expand All @@ -22022,6 +22163,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
}
const terminal = yield* Queue.take(harness.terminalReceipts);
assert.equal(terminal.status, "completed");
assert.isFalse(
harness.events.some(
(event) => event.type === "turn_item.updated" && event.turnItem.type === "notification",
),
);
assert.deepEqual(goalStatuses(harness.events), [
{ objective: condition, status: "active", checks: 0 },
{
Expand Down
Loading
Loading