From 422a1b5aef8b35545ca011a4328b9910db2764a0 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Mon, 26 Oct 2026 17:02:37 +0000 Subject: [PATCH 01/14] feat(server): remind the agent of open chat comments on every turn A chat comment went to the agent in full once and was then never mentioned again, so an agent that moved on to the next request forgot comments it had not addressed. Every later turn now carries a short reminder that lists the sent-but-open comments by number and points at t3_list_comments and t3_reply_comment. New, edited, replied or reopened comments still go in full; addressed comments and paused threads get no reminder. Co-Authored-By: Claude Opus 5.5 --- .../ThreadCommentsService.test.ts | 43 +++++++ .../threadcomments/ThreadCommentsService.ts | 32 +++++- .../src/threadcomments/turnContext.test.ts | 106 +++++++++++++++++- apps/server/src/threadcomments/turnContext.ts | 82 ++++++++++---- .../src/wsForkHandlers.threadComments.test.ts | 1 + docs/user/chat-comments.md | 4 +- 6 files changed, 237 insertions(+), 31 deletions(-) diff --git a/apps/server/src/threadcomments/ThreadCommentsService.test.ts b/apps/server/src/threadcomments/ThreadCommentsService.test.ts index fa99a3d4d3685..4e02438e97395 100644 --- a/apps/server/src/threadcomments/ThreadCommentsService.test.ts +++ b/apps/server/src/threadcomments/ThreadCommentsService.test.ts @@ -313,6 +313,49 @@ describe("ThreadCommentsService", () => { ), ); + // T3-CUSTOM(expbkt3): sent but still open comments earn a reminder on every turn. + it.effect("deliveryState splits unsent from already-sent open comments", () => + withService((service) => + Effect.gen(function* () { + yield* service.add({ threadId, kind: "comment", anchor: anchor(), body: "a", ...actor }); + const sent = yield* service.openForDelivery(threadId); + yield* service.markSent({ threadId, comments: sent }); + const added = yield* service.add({ + threadId, + kind: "comment", + anchor: anchor(), + body: "b", + ...actor, + }); + const done = yield* service.add({ + threadId, + kind: "comment", + anchor: anchor(), + body: "c", + ...actor, + }); + yield* service.markSent({ threadId, comments: [done.comments[2]!] }); + // Addressed by the agent: waits on the user, so no reminder. + yield* service.agentReply({ + threadId, + commentId: done.comments[2]!.commentId, + addressed: true, + }); + + const state = yield* service.deliveryState(threadId); + expect(state.unsent.map((comment) => comment.commentId)).toEqual([ + added.comments[1]!.commentId, + ]); + expect(state.alreadySent.map((comment) => comment.commentId)).toEqual([ + sent[0]!.commentId, + ]); + + yield* service.setDeliveryPaused({ threadId, paused: true }); + expect(yield* service.deliveryState(threadId)).toEqual({ unsent: [], alreadySent: [] }); + }), + ), + ); + it.effect("openForDelivery returns open comments only, and nothing while paused", () => withService((service) => Effect.gen(function* () { diff --git a/apps/server/src/threadcomments/ThreadCommentsService.ts b/apps/server/src/threadcomments/ThreadCommentsService.ts index ec078e566cb5a..8e9f437709cc4 100644 --- a/apps/server/src/threadcomments/ThreadCommentsService.ts +++ b/apps/server/src/threadcomments/ThreadCommentsService.ts @@ -89,6 +89,14 @@ export interface ThreadCommentsServiceShape { readonly openForDelivery: ( threadId: ThreadId, ) => Effect.Effect, ThreadCommentsError>; + /** + * One read for a turn start: the unsent open comments (`openForDelivery`) + * and the open ones the agent already got but has not marked addressed, + * which only earn a short reminder. Both are empty while paused. + */ + readonly deliveryState: ( + threadId: ThreadId, + ) => Effect.Effect; /** * Records that these comments went to the agent with a turn. A comment that * changed after it was read (its `updatedAt` moved) stays unsent. @@ -110,6 +118,11 @@ export interface ThreadCommentsServiceShape { ) => Stream.Stream; } +export interface ThreadCommentsDeliveryState { + readonly unsent: ReadonlyArray; + readonly alreadySent: ReadonlyArray; +} + export class ThreadCommentsService extends Context.Service< ThreadCommentsService, ThreadCommentsServiceShape @@ -367,18 +380,26 @@ export const make = Effect.gen(function* () { return updated; }); - const openForDelivery: ThreadCommentsServiceShape["openForDelivery"] = Effect.fn( - "ThreadCommentsService.openForDelivery", + const deliveryState: ThreadCommentsServiceShape["deliveryState"] = Effect.fn( + "ThreadCommentsService.deliveryState", )( function* (threadId: ThreadId) { const settings = yield* repository.getSettings(threadId); - if (settings.deliveryPaused) return []; + if (settings.deliveryPaused) return { unsent: [], alreadySent: [] }; const comments = yield* repository.listForThread(threadId); - return comments.filter(isThreadCommentUnsent); + return { + unsent: comments.filter(isThreadCommentUnsent), + alreadySent: comments.filter( + (comment) => comment.status === "open" && !isThreadCommentUnsent(comment), + ), + } satisfies ThreadCommentsDeliveryState; }, - Effect.mapError(internal("openForDelivery")), + Effect.mapError(internal("deliveryState")), ); + const openForDelivery: ThreadCommentsServiceShape["openForDelivery"] = (threadId) => + Effect.map(deliveryState(threadId), (state) => state.unsent); + const markSent: ThreadCommentsServiceShape["markSent"] = Effect.fn( "ThreadCommentsService.markSent", )(function* (input) { @@ -426,6 +447,7 @@ export const make = Effect.gen(function* () { setDeliveryPaused, agentReply, openForDelivery, + deliveryState, markSent, resend, watch, diff --git a/apps/server/src/threadcomments/turnContext.test.ts b/apps/server/src/threadcomments/turnContext.test.ts index 8442d55296518..25766d0659987 100644 --- a/apps/server/src/threadcomments/turnContext.test.ts +++ b/apps/server/src/threadcomments/turnContext.test.ts @@ -24,6 +24,7 @@ import { ThreadCommentsService } from "./ThreadCommentsService.ts"; import { appendOpenThreadComments, formatOpenThreadCommentsForAgent, + formatOpenThreadCommentsReminder, neutralizeOwnTags, OPEN_THREAD_COMMENTS_MAX_CHARS, prepareOpenThreadComments, @@ -68,8 +69,13 @@ const reply = (index: number, body = `reply ${index}`): ThreadComment["replies"] const serviceWith = ( openForDelivery: ThreadCommentsService["Service"]["openForDelivery"], markSent: ThreadCommentsService["Service"]["markSent"] = () => Effect.void, + alreadySent: ReadonlyArray = [], ): ThreadCommentsService["Service"] => - ({ openForDelivery, markSent }) as unknown as ThreadCommentsService["Service"]; + ({ + deliveryState: (id: ThreadId) => + Effect.map(openForDelivery(id), (unsent) => ({ unsent, alreadySent })), + markSent, + }) as unknown as ThreadCommentsService["Service"]; describe("neutralizeOwnTags", () => { it("defuses only the block's own tags and leaves other markup alone", () => { @@ -177,7 +183,105 @@ describe("formatOpenThreadCommentsForAgent", () => { }); }); +describe("formatOpenThreadCommentsReminder", () => { + it("names the comments by number and caps the list", () => { + expect(formatOpenThreadCommentsReminder([])).toBeNull(); + + const one = formatOpenThreadCommentsReminder([comment({ number: 4 })])!; + expect(one.startsWith('')).toBe(true); + expect(one).toContain("1 review comment is still open in this session: #4."); + expect(one).toContain("t3_reply_comment"); + expect(one).toContain("t3_list_comments"); + expect(one).not.toContain("quoted passage"); + expect(one).not.toContain("in addition to the new comments above"); + + const many = formatOpenThreadCommentsReminder( + Array.from({ length: 25 }, (_, index) => comment({ number: index + 1 })), + { alongsideNewComments: true }, + )!; + expect(many).toContain("#1, #2,"); + expect(many).toContain("#20 and 5 more."); + expect(many).not.toContain("#21"); + expect(many).toContain("in addition to the new comments above"); + expect(many.length).toBeLessThan(1_000); + }); + + it("is defused inside a quote like the block's other tags", () => { + expect(neutralizeOwnTags("")).toBe( + "‹/open_chat_comments_reminder>", + ); + }); +}); + describe("appendOpenThreadComments", () => { + // T3-CUSTOM(expbkt3): an open comment the agent already got is not forgotten. + it.effect("reminds the agent of sent comments that are still open, without marking", () => + Effect.gen(function* () { + let marks = 0; + const result = yield* appendOpenThreadComments(threadId, "hello").pipe( + Effect.provideService( + ThreadCommentsService, + serviceWith( + () => Effect.succeed([]), + () => Effect.sync(() => void (marks += 1)), + [comment({ number: 3 }), comment({ number: 5 })], + ), + ), + ); + expect(result.startsWith('hello\n\n')).toBe(true); + expect(result).toContain("#3, #5"); + expect(result).not.toContain(" + Effect.gen(function* () { + const marked: Array> = []; + const result = yield* appendOpenThreadComments(threadId, "hello").pipe( + Effect.provideService( + ThreadCommentsService, + serviceWith( + () => Effect.succeed([comment({ number: 6 })]), + (input) => Effect.sync(() => void marked.push(input.comments.map((c) => c.commentId))), + [comment({ number: 2 })], + ), + ), + ); + const block = result.indexOf(''); + const reminder = result.indexOf(''); + expect(block).toBeGreaterThan(0); + expect(reminder).toBeGreaterThan(result.indexOf("")); + expect(result).toContain('id="tc_6"'); + expect(result).not.toContain('id="tc_2"'); + expect(result).toContain("in addition to the new comments above"); + expect(marked).toEqual([["tc_6"]]); + }), + ); + + it.effect("keeps the reminder when the full block does not fit", () => + Effect.gen(function* () { + let marks = 0; + const text = "u".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - 1_200); + const result = yield* appendOpenThreadComments(threadId, text).pipe( + Effect.provideService( + ThreadCommentsService, + serviceWith( + () => Effect.succeed([comment({ number: 1, body: "z".repeat(3_000) })]), + () => Effect.sync(() => void (marks += 1)), + [comment({ number: 2 })], + ), + ), + ); + expect(result.length).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(result).toContain(" Effect.gen(function* () { diff --git a/apps/server/src/threadcomments/turnContext.ts b/apps/server/src/threadcomments/turnContext.ts index c265e8a63bca2..334045c745f58 100644 --- a/apps/server/src/threadcomments/turnContext.ts +++ b/apps/server/src/threadcomments/turnContext.ts @@ -4,8 +4,10 @@ * * Each open comment goes with the next provider turn once, and is then marked * sent; it goes again only after the user edits, replies to or reopens it, or - * asks for a re-send. The agent answers through `t3_reply_comment`, and the - * user resolves. The reactor calls + * asks for a re-send. While a sent comment stays open (the agent has not marked + * it addressed), every later turn carries a short reminder that names it + * instead of the full block. The agent answers through `t3_reply_comment`, and + * the user resolves. The reactor calls * `appendOpenThreadComments` from one marked seam; everything else here is * fork-owned, and nothing in this module can fail a turn — a broken read or a * block that does not fit logs and falls back to the user's text. @@ -29,16 +31,18 @@ const QUOTE_MAX_CHARS = 1_500; /** Bodies and replies can be 8k each; the agent only needs the gist. */ const TEXT_MAX_CHARS = 2_000; const REPLIES_SHOWN = 3; +/** The reminder names comments by number; past this it only counts the rest. */ +const REMINDER_NUMBERS_SHOWN = 20; const INSTRUCTIONS = - "You received review comments on your earlier messages in this session, listed below. Treat this as review work: for each comment, either make the change it asks for or answer it. Then call t3_reply_comment with that comment's id and a short reply that says what you did; pass addressed=true when it is done. Do not skip a comment. If a comment is unclear, reply with your question instead of guessing. Reply to every comment before you end your turn. Each comment is sent to you once; the user resolves comments, or sends them again if they are still open."; + "You received review comments on your earlier messages in this session, listed below. Treat this as review work: for each comment, either make the change it asks for or answer it. Then call t3_reply_comment with that comment's id and a short reply that says what you did; pass addressed=true when it is done. Do not skip a comment. If a comment is unclear, reply with your question instead of guessing. Reply to every comment before you end your turn. Each comment is sent to you in full once, and later turns remind you of the ones still open; the user resolves comments, or sends them again in full."; /** * Only the block's own tags are defused, by swapping their `<` for `‹`: a * quote of code or HTML must arrive as the agent wrote it, not as entities. */ const OWN_TAG_OPENER = - /<(?=\/?(?:open_chat_comments|chat_comment|earlier_replies|quote|comment|reply)\b)/gi; + /<(?=\/?(?:open_chat_comments_reminder|open_chat_comments|chat_comment|earlier_replies|quote|comment|reply)\b)/gi; export function neutralizeOwnTags(text: string): string { return text.replace(OWN_TAG_OPENER, "‹"); @@ -118,6 +122,30 @@ function renderOpenThreadComments( return block.length <= maxChars ? { block, kept: comments.slice(omitted) } : null; } +/** + * The short nudge for open comments the agent already received in full: it + * names them by number and points at `t3_list_comments` instead of repeating + * their text, so it stays a few hundred characters on every turn. + */ +export function formatOpenThreadCommentsReminder( + comments: ReadonlyArray, + options: { readonly alongsideNewComments?: boolean } = {}, +): string | null { + if (comments.length === 0) return null; + const shown = comments.slice(0, REMINDER_NUMBERS_SHOWN).map((comment) => `#${comment.number}`); + const more = comments.length - shown.length; + const numbers = `${shown.join(", ")}${more > 0 ? ` and ${more} more` : ""}`; + const single = comments.length === 1; + const noun = single ? "review comment is" : "review comments are"; + const them = single ? "it" : "them"; + const alongside = options.alongsideNewComments ? ", in addition to the new comments above" : ""; + return [ + ``, + `Reminder: ${comments.length} ${noun} still open in this session: ${numbers}. You received ${them} in an earlier turn${alongside}. Keep working toward resolving ${them} along with this message: make the change each one asks for, or answer it, then call t3_reply_comment with its id and addressed=true. Call t3_list_comments to read them again. If you already replied and wait for the user's answer, do not reply again.`, + "", + ].join("\n"); +} + /** A send while a real turn is running steers that turn rather than starting one. */ function isSteeringRunningTurn( session: Pick | null | undefined, @@ -137,11 +165,12 @@ export interface PreparedThreadComments { } /** - * The user's text with the thread's unsent open comments appended, or the text - * unchanged when there is nothing to add, the message steers a running turn - * (the block already went out with that turn), the service is not wired - * (isolated upstream reactor tests), the block would not fit the provider's - * input limit, or the read fails. + * The user's text with the thread's unsent open comments appended, followed by + * a short reminder of the open comments already sent. The text stays unchanged + * when nothing is open, the message steers a running turn (the block already + * went out with that turn), the service is not wired (isolated upstream + * reactor tests), nothing fits the provider's input limit, or the read fails. + * When the full block does not fit, the reminder still goes if it fits. */ export const prepareOpenThreadComments = ( threadId: ThreadId, @@ -153,35 +182,40 @@ export const prepareOpenThreadComments = ( const service = yield* Effect.serviceOption(ThreadCommentsService); if (Option.isNone(service)) return unchanged; if (isSteeringRunningTurn(session)) return unchanged; - const comments = yield* service.value.openForDelivery(threadId); - if (comments.length === 0) return unchanged; + const { unsent, alreadySent } = yield* service.value.deliveryState(threadId); + if (unsent.length === 0 && alreadySent.length === 0) return unchanged; const separator = text.length === 0 ? "" : "\n\n"; const room = PROVIDER_SEND_TURN_MAX_INPUT_CHARS - text.length - separator.length; + const reminder = formatOpenThreadCommentsReminder(alreadySent, { + alongsideNewComments: unsent.length > 0, + }); + const reminderRoom = reminder === null ? 0 : reminder.length + 1; const rendered = - room > 0 - ? renderOpenThreadComments(comments, { - maxChars: Math.min(OPEN_THREAD_COMMENTS_MAX_CHARS, room), + unsent.length > 0 && room - reminderRoom > 0 + ? renderOpenThreadComments(unsent, { + maxChars: Math.min(OPEN_THREAD_COMMENTS_MAX_CHARS, room - reminderRoom), }) : null; - const result = rendered === null ? null : `${text}${separator}${rendered.block}`; - if ( - rendered === null || - result === null || - result.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS - ) { + const appended = [rendered?.block ?? null, reminder] + .filter((part): part is string => part !== null) + .join("\n"); + if (unsent.length > 0 && rendered === null) { yield* Effect.logWarning("thread comments skipped: no room left in the turn input", { threadId, - openComments: comments.length, + openComments: unsent.length, textLength: text.length, }); - return unchanged; } + if (appended.length === 0 || appended.length > room) return unchanged; yield* Effect.logInfo("thread comments appended to turn input", { threadId, - openComments: comments.length, - blockLength: rendered.block.length, + openComments: rendered?.kept.length ?? 0, + remindedComments: alreadySent.length, + blockLength: appended.length, }); + const result = `${text}${separator}${appended}`; + if (rendered === null) return { text: result, markSent: Effect.void }; // Only the comments read here, unchanged since: a reply that lands in // between keeps its comment unsent, so the reply still reaches the agent. const markSent = service.value.markSent({ threadId, comments: rendered.kept }).pipe( diff --git a/apps/server/src/wsForkHandlers.threadComments.test.ts b/apps/server/src/wsForkHandlers.threadComments.test.ts index f3bacb484e4c2..e82330bad98fd 100644 --- a/apps/server/src/wsForkHandlers.threadComments.test.ts +++ b/apps/server/src/wsForkHandlers.threadComments.test.ts @@ -56,6 +56,7 @@ function makeHandlers() { resend: record("resend")(snapshotFor(visibleThreadId)), agentReply: () => Effect.die("unused"), openForDelivery: () => Effect.die("unused"), + deliveryState: () => Effect.die("unused"), markSent: () => Effect.die("unused"), watch: (threadId: ThreadId) => Stream.fromEffect( diff --git a/docs/user/chat-comments.md b/docs/user/chat-comments.md index 1cab0d02288f7..4240638dc199a 100644 --- a/docs/user/chat-comments.md +++ b/docs/user/chat-comments.md @@ -58,7 +58,9 @@ the thread under a comment, and the agent sees the replies too. Each open comment goes to the agent once, with your next message: quoted with your note, plus the instruction to act on it or answer it. The agent can reply under a comment and mark it addressed; only you resolve. A comment goes again -when you edit it, reply to it or reopen it. +when you edit it, reply to it or reopen it. Until the agent marks a sent comment +addressed, each later message carries a short reminder that lists it by number, +so the agent keeps working on it alongside whatever you asked next. While some comments are not sent yet, a strip above the composer says how many. **Review** opens the panel. **Send now** sends just the comments and leaves your From 3ad3864835ed0bb9f8c9d92aee673f6cfd4bbd5d Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Mon, 26 Oct 2026 17:27:54 +0000 Subject: [PATCH 02/14] style(server): format the deliveryState test Co-Authored-By: Claude Opus 5.5 --- apps/server/src/threadcomments/ThreadCommentsService.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/threadcomments/ThreadCommentsService.test.ts b/apps/server/src/threadcomments/ThreadCommentsService.test.ts index 4e02438e97395..e97e9c8100cbf 100644 --- a/apps/server/src/threadcomments/ThreadCommentsService.test.ts +++ b/apps/server/src/threadcomments/ThreadCommentsService.test.ts @@ -346,9 +346,7 @@ describe("ThreadCommentsService", () => { expect(state.unsent.map((comment) => comment.commentId)).toEqual([ added.comments[1]!.commentId, ]); - expect(state.alreadySent.map((comment) => comment.commentId)).toEqual([ - sent[0]!.commentId, - ]); + expect(state.alreadySent.map((comment) => comment.commentId)).toEqual([sent[0]!.commentId]); yield* service.setDeliveryPaused({ threadId, paused: true }); expect(yield* service.deliveryState(threadId)).toEqual({ unsent: [], alreadySent: [] }); From ebb41474b8d8904f36f6575f398c55f38f0014bc Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 14:02:16 +0000 Subject: [PATCH 03/14] feat(server): scheduled and webhook prompts steer a busy session by default A scheduled task or webhook task bound to a session always sent its prompt with mode "queue", so a busy session parked it until the turn ended. Runs now send with mode "auto" (steer a running turn, start an idle one, queue behind a turn that cannot take steering yet). A task can opt back into the queue with ifBusy: "queue", stored in the fork's task options side table: - t3_schedule: ifBusy on create and update; t3_schedules shows it. - schedule_task / update_scheduled_task: ifBusy, echoed in task summaries. - A steer the session refuses falls back to the queue under its own command id, so the prompt is never lost. - ifBusy alone does not add the "Scheduled prompt" header or the pause rules that other fork options turn on. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 20 ++- .../src/mcp/toolkits/agent/schedules.ts | 13 +- apps/server/src/mcp/toolkits/agent/tools.ts | 8 +- ...ledTaskService.forkOptions.expbkt3.test.ts | 64 +++++++++- .../scheduledTasks/ScheduledTaskService.ts | 23 ++-- .../scheduledTaskForkOptions.expbkt3.ts | 116 ++++++++++++++++-- packages/contracts/src/orchestrationFork.ts | 12 ++ packages/contracts/src/orchestratorMcp.ts | 7 ++ 8 files changed, 231 insertions(+), 32 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 73f6d1c28e57f..c0000c4ad67a1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -124,6 +124,11 @@ import { saveScheduledTaskCustomGroupForMcp, withScheduledTaskCustomGroup, } from "../scheduledTasks/scheduledTaskCustomGroup.expbkt3.ts"; +// T3-CUSTOM(expbkt3): steer (default) or queue when a task's bound thread is busy. +import { + saveScheduledTaskIfBusyForMcp, + withScheduledTaskIfBusy, +} from "../scheduledTasks/scheduledTaskForkOptions.expbkt3.ts"; // T3-CUSTOM(expbkt3): team mode lists and changes only the caller's scheduled tasks. import { callerMayManageScheduledTask } from "./scheduledTaskAccess.expbkt3.ts"; @@ -1830,9 +1835,12 @@ const make = Effect.gen(function* () { modes.value.every((mode) => withinLimits(caller.limits, mode)), ); }, - // T3-CUSTOM(expbkt3): echo the task's custom group so a caller can read it back. + // T3-CUSTOM(expbkt3): echo the task's custom group and ifBusy so a caller can read them back. (effect) => - effect.pipe(Effect.flatMap((summary) => withScheduledTaskCustomGroup(forkSql, summary))), + effect.pipe( + Effect.flatMap((summary) => withScheduledTaskCustomGroup(forkSql, summary)), + Effect.flatMap((summary) => withScheduledTaskIfBusy(forkSql, summary)), + ), ); /** @@ -1921,8 +1929,10 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not schedule task: ${error.message}`), ), ); - // T3-CUSTOM(expbkt3): the custom group of the threads this task's runs create. + // T3-CUSTOM(expbkt3): the custom group of the threads this task's runs create, + // and whether a run steers or queues when its bound thread is busy. yield* saveScheduledTaskCustomGroupForMcp(forkSql, task.id, input.customGroup); + yield* saveScheduledTaskIfBusyForMcp(forkSql, task.id, input.ifBusy); return yield* summarizeScheduledTask(scope, { parent, limits }, task); }), listScheduledTasks: Effect.fn("OrchestratorMcpService.listScheduledTasks")( @@ -2005,8 +2015,10 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not update scheduled task: ${error.message}`), ), ); - // T3-CUSTOM(expbkt3): a label sets the custom group, null removes it, omitted keeps it. + // T3-CUSTOM(expbkt3): a label sets the custom group, null removes it, omitted keeps it; + // ifBusy, when given, replaces the task's steer-or-queue choice. yield* saveScheduledTaskCustomGroupForMcp(forkSql, task.id, input.customGroup); + yield* saveScheduledTaskIfBusyForMcp(forkSql, task.id, input.ifBusy); return yield* summarizeScheduledTask(scope, { parent, limits }, task); }, ), diff --git a/apps/server/src/mcp/toolkits/agent/schedules.ts b/apps/server/src/mcp/toolkits/agent/schedules.ts index 7eaa21f527bbd..392a78a5c7c9f 100644 --- a/apps/server/src/mcp/toolkits/agent/schedules.ts +++ b/apps/server/src/mcp/toolkits/agent/schedules.ts @@ -3,9 +3,10 @@ * * A schedule is upstream's scheduled task plus the fork's options for it * (scheduledTaskForkOptions.expbkt3.ts): the person its runs act for, the - * tags and links of the sessions it starts, quiet, and run-once. By default a - * schedule sends its prompt into a session as a queued message with that - * session's own model; with a project it starts a new session on every run. + * tags and links of the sessions it starts, quiet, run-once, and ifBusy. By + * default a schedule sends its prompt into a session with that session's own + * model, steering its turn when it is busy (ifBusy: "queue" waits instead); + * with a project it starts a new session on every run. * * A caller manages the schedules it owns, those that send into a session it * can see, and ownerless ones (made in the app or by schedule_task) in a @@ -230,14 +231,16 @@ const targetOf = (view: ScheduleView, context: ScheduleContext) => { if (task.threadId !== null) { const shell = context.sessions.get(task.threadId); const ref = shortRef(task.threadId); + const ifBusy = options.ifBusy ?? "steer"; return { - text: `into session \`${ref}\`${shell === undefined ? "" : ` ${oneLine(shell.title, 60)}`}${shell?.archivedAt != null ? " (archived)" : ""}`, + text: `into session \`${ref}\`${shell === undefined ? "" : ` ${oneLine(shell.title, 60)}`}${shell?.archivedAt != null ? " (archived)" : ""}${ifBusy === "queue" ? " (queued when busy)" : ""}`, data: { type: "session" as const, sessionId: task.threadId, ref, title: shell?.title ?? null, link: sessionLink(context.origin, context.caller.scope.environmentId, task.threadId), + ifBusy, }, }; } @@ -766,6 +769,7 @@ const createSchedule = Effect.fn("agentToolkit.createSchedule")(function* ( creationSource: "mcp", }; } + if (input.ifBusy !== undefined) options = { ...options, ifBusy: input.ifBusy }; const commandId = input.clientRequestId === undefined ? undefined @@ -847,6 +851,7 @@ const updateSchedule = Effect.fn("agentToolkit.updateSchedule")(function* ( } let options: ScheduledTaskForkOptions = when === undefined ? view.options : withWhenOptions(view.options, when.success); + if (input.ifBusy !== undefined) options = { ...options, ifBusy: input.ifBusy }; let group: ThreadCustomGroup | null | undefined; let settings: Partial< Pick diff --git a/apps/server/src/mcp/toolkits/agent/tools.ts b/apps/server/src/mcp/toolkits/agent/tools.ts index 582c28478d5bd..faacc5beeeb45 100644 --- a/apps/server/src/mcp/toolkits/agent/tools.ts +++ b/apps/server/src/mcp/toolkits/agent/tools.ts @@ -434,7 +434,7 @@ const SCHEDULE_REF_HELP = export const ScheduleTool = destructive( Tool.make("t3_schedule", { description: - 'Schedule a prompt, in one call. By default the prompt is sent into a session (your own when you are in one, or the one you name) as a queued message, so it never interrupts a turn: use it to remind yourself, check back on work, or nudge another session. Pass project instead to start a new session on every run, with the same options as t3_start_session plus quiet (snoozed while it works, so it only surfaces when it finishes, fails or asks). Say when with exactly one of: in ("10m", once), at (an ISO time or "09:00", once), every ("2h"), daily ("09:00", with weekdays and timeZone), or webhook: true. Runs act for the person you act for: new sessions are theirs and show in their sidebar. Other actions take id: update, pause, resume, delete, run_now.', + 'Schedule a prompt, in one call. By default the prompt is sent into a session (your own when you are in one, or the one you name), and when that session is busy it steers the running turn; pass ifBusy: "queue" to wait for the turn to end instead. Use it to remind yourself, check back on work, or nudge another session. Pass project instead to start a new session on every run, with the same options as t3_start_session plus quiet (snoozed while it works, so it only surfaces when it finishes, fails or asks). Say when with exactly one of: in ("10m", once), at (an ISO time or "09:00", once), every ("2h"), daily ("09:00", with weekdays and timeZone), or webhook: true. Runs act for the person you act for: new sessions are theirs and show in their sidebar. Other actions take id: update, pause, resume, delete, run_now.', parameters: Schema.Struct({ action: Schema.optional( described( @@ -529,6 +529,12 @@ export const ScheduleTool = destructive( "For new sessions: snooze each one while it works, so it surfaces only when it finishes, fails or asks a person. Defaults to false.", ), ), + ifBusy: Schema.optional( + described( + Schema.Literals(["steer", "queue"]), + 'When the session it sends into is busy: steer (the default) adds the prompt to the running turn; queue waits until the turn ends. Pass it with action "update" to change an existing schedule.', + ), + ), clientRequestId: Schema.optional( described( Schema.String, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts index 018ecb656fa15..fa726c7f2bcbf 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts @@ -155,7 +155,8 @@ it.effect("tags new sessions, snoozes quiet ones, keeps the model and pauses aft yield* scheduled.runNow({ id: bound.id }); expect(sends).toHaveLength(1); expect(sends[0]?.actor).toBeNull(); - expect(sends[0]?.input.mode).toBe("queue"); + // A busy session takes the prompt as steering by default. + expect(sends[0]?.input.mode).toBe("auto"); expect("modelSelection" in (sends[0]?.input ?? {})).toBe(false); const { tasks: stillRunning } = yield* scheduled.list(); expect(stillRunning.find((task) => task.id === bound.id)?.enabled).toBe(true); @@ -175,6 +176,67 @@ it.effect("tags new sessions, snoozes quiet ones, keeps the model and pauses aft }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); +it.effect("a bound run steers by default, queues on request, and queues a refused steer", () => + Effect.gen(function* () { + const sends: Array = []; + let refuseSteer = false; + const services = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: (input) => + Effect.gen(function* () { + sends.push(input); + if (refuseSteer && input.mode === "auto") { + return yield* new ThreadManagementService.ThreadManagementNoSteerableRunError({ + threadId: input.threadId, + mode: "steer", + }); + } + return {} as ThreadManagementService.ThreadManagementSendResult; + }), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + ), + ), + ); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const scheduled = yield* ScheduledTaskService.ScheduledTaskService; + const { task } = yield* scheduled.upsert({ ...baseTask, threadId: boundThreadId }); + + // No options: steer, and the prompt goes out unchanged. + yield* scheduled.runNow({ id: task.id }); + expect(sends.map((send) => send.mode)).toEqual(["auto"]); + expect(sends[0]?.text).toBe("Check the nightly build."); + + // ifBusy alone (as schedule_task stores it) changes only the send mode. + yield* writeScheduledTaskForkOptions(sql, task.id, { ifBusy: "queue" }); + const { task: queued } = yield* scheduled.runNow({ id: task.id }); + expect(sends.map((send) => send.mode)).toEqual(["auto", "queue"]); + expect(sends[1]?.text).toBe("Check the nightly build."); + expect(queued).toMatchObject({ enabled: true, lastRunStatus: "succeeded" }); + + // A steer the session refuses waits in the queue under its own command id. + yield* writeScheduledTaskForkOptions(sql, task.id, { ifBusy: "steer" }); + refuseSteer = true; + const { task: fellBack } = yield* scheduled.runNow({ id: task.id }); + expect(sends.slice(2).map((send) => send.mode)).toEqual(["auto", "queue"]); + expect(sends[3]?.commandId).toBe(`${sends[2]?.commandId}:queue`); + expect(sends[3]?.messageId).toBe(sends[2]?.messageId); + expect(fellBack.lastRunStatus).toBe("succeeded"); + }).pipe(Effect.provide(services)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("a task without options runs as nobody, as upstream's do", () => Effect.gen(function* () { const actors: Array = []; diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 6cc96248cb6e9..5e3a930132792 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -80,6 +80,7 @@ import { scheduledTaskForkOptionsForRun, scheduledTaskOwner, scheduledPromptText, + sendScheduledPrompt, } from "./scheduledTaskForkOptions.expbkt3.ts"; // T3-CUSTOM(expbkt3): watches — tasks that run once another session changes. import { @@ -876,8 +877,9 @@ export const layer = Layer.effect( // aborting before markCompleted. // T3-CUSTOM(expbkt3): BEGIN — dispatch as the task's owner (forkOptions). const text = watchNote ?? scheduledPromptText(active, forkOptions, prompt); + const boundThreadId = active.threadId === null ? null : ThreadId.make(active.threadId); const result = - active.threadId === null + boundThreadId === null ? yield* Effect.exit( threadLaunch .launch({ @@ -908,23 +910,22 @@ export const layer = Layer.effect( // follows the thread's current provider, so it passes none. // T3-CUSTOM(expbkt3): upstream now always does what the fork's // `keepThreadModel` option asked for, so that option is a no-op here. - threadManagement - .sendToThread({ + // T3-CUSTOM(expbkt3): a busy session takes the prompt as steering + // unless the task asks to queue (scheduledTaskForkOptions.expbkt3.ts). + sendScheduledPrompt(forkOptions, commandId, (mode, sendCommandId) => + threadManagement.sendToThread({ projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), + commandId: sendCommandId, + threadId: boundThreadId, messageId, scheduledTaskId: active.id, text, attachments: [], - mode: "queue", + mode, createdBy: active.createdBy, creationSource: active.creationSource, - }) - .pipe( - asScheduledTaskOwner(forkOwner, trigger), - Effect.as(ThreadId.make(active.threadId)), - ), + }), + ).pipe(asScheduledTaskOwner(forkOwner, trigger), Effect.as(boundThreadId)), ); // T3-CUSTOM(expbkt3): END diff --git a/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts b/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts index 9928aabaf5e27..b707950538cab 100644 --- a/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts +++ b/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts @@ -3,7 +3,8 @@ // Upstream's task row records no person, so every run acted for nobody: a // session it launched had no owner and no member, and in team mode nobody // could see it. These options say who a run acts for and who it tags, how -// loud a launched session is, and whether the task runs only once. They live +// loud a launched session is, whether the task runs only once, and whether a +// prompt for a busy session steers its turn or waits in its queue. They live // in a fork-owned side table keyed by task id, so the upstream contract, its // upsert SQL and the web editor stay untouched and a web save keeps them. // @@ -11,6 +12,8 @@ // without options runs as upstream's do) and deletes them with the task. import { CommandId, + OrchestratorMcpFailure, + type OrchestratorMcpScheduledTask, type ScheduledTask, ThreadId, ThreadPriority, @@ -58,10 +61,64 @@ export const ScheduledTaskForkOptions = Schema.Struct({ timeZone: Schema.optional(Schema.String), /** Run only once this session changes (see scheduledTaskWatch.expbkt3.ts). */ watch: Schema.optional(ScheduledTaskWatch), + /** + * What a prompt sent into a busy session does: steer joins the running turn + * (the default, also when unset), queue waits until the turn ends. + */ + ifBusy: Schema.optional(Schema.Literals(["steer", "queue"])), }); export type ScheduledTaskForkOptions = typeof ScheduledTaskForkOptions.Type; const NO_OPTIONS: ScheduledTaskForkOptions = {}; + +/** + * Whether the task has options that change what a run says or does after it. + * `ifBusy` only picks how the prompt is delivered, so a task made by + * schedule_task with just that option still sends and pauses as upstream's do. + */ +const hasRunOptions = (options: ScheduledTaskForkOptions): boolean => + Object.keys(options).some((key) => key !== "ifBusy"); + +/** + * The send mode of a prompt for a bound session: `auto` steers a running + * turn, starts an idle session, and queues behind a turn that cannot take + * steering yet; `queue` always waits for the turn to end. + */ +export const scheduledTaskSendMode = (options: ScheduledTaskForkOptions): "auto" | "queue" => + options.ifBusy === "queue" ? "queue" : "auto"; + +const GONE_TARGET_TAGS: ReadonlySet = new Set([ + "ThreadManagementThreadArchivedError", + "ThreadManagementThreadNotFoundError", +]); + +/** Whether a failed send found its session gone, so another try cannot help. */ +export const isGoneScheduledTaskTarget = (error: unknown): boolean => + typeof error === "object" && + error !== null && + "_tag" in error && + GONE_TARGET_TAGS.has(String(error._tag)); + +/** + * Sends a run's prompt into its bound session with the task's send mode. A + * steer the session refuses (a provider without steering, a turn that ended + * meanwhile) goes to the queue instead, so the prompt is never lost. + */ +export const sendScheduledPrompt = ( + options: ScheduledTaskForkOptions, + commandId: CommandId, + send: (mode: "auto" | "queue", commandId: CommandId) => Effect.Effect, +): Effect.Effect => + scheduledTaskSendMode(options) === "queue" + ? send("queue", commandId) + : send("auto", commandId).pipe( + Effect.catch((error) => + isGoneScheduledTaskTarget(error) + ? Effect.fail(error) + : send("queue", CommandId.make(`${commandId}:queue`)), + ), + ); + const decodeStored = Schema.decodeUnknownOption(Schema.fromJsonString(ScheduledTaskForkOptions)); /** The task's options, or none when it has none or they no longer decode. */ @@ -126,6 +183,50 @@ export const scheduledTaskForkOptionsForRun = ( ), ); +/** + * Applies the `ifBusy` of schedule_task or update_scheduled_task once the task + * is saved, keeping its other options; omitted keeps the stored choice. `sql` + * is optional so service layers built without SQLite (tests) still build. + */ +export const saveScheduledTaskIfBusyForMcp = ( + sql: Option.Option, + taskId: ScheduledTaskId, + ifBusy: ScheduledTaskForkOptions["ifBusy"], +): Effect.Effect => { + if (ifBusy === undefined) return Effect.void; + if (Option.isNone(sql)) { + return Effect.fail( + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "This server cannot store ifBusy for scheduled tasks.", + }), + ); + } + return readScheduledTaskForkOptions(sql.value, taskId).pipe( + Effect.flatMap((options) => + writeScheduledTaskForkOptions(sql.value, taskId, { ...options, ifBusy }), + ), + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: `The scheduled task was saved, but its ifBusy was not: ${cause.message}`, + }), + ), + ); +}; + +/** Adds the task's `ifBusy` to an MCP task summary of a task bound to a thread. */ +export const withScheduledTaskIfBusy = ( + sql: Option.Option, + summary: OrchestratorMcpScheduledTask, +): Effect.Effect => + Option.isNone(sql) || summary.boundThreadId === null + ? Effect.succeed(summary) + : scheduledTaskForkOptionsForRun(sql.value, summary.scheduledTaskId).pipe( + Effect.map((options) => ({ ...summary, ifBusy: options.ifBusy ?? "steer" })), + ); + /** The launch fields of a session a run starts for `owner`. */ export const scheduledTaskForkLaunchFields = ( options: ScheduledTaskForkOptions, @@ -202,16 +303,12 @@ export const scheduledPromptText = ( options: ScheduledTaskForkOptions, prompt: string, ): string => { - if (Object.keys(options).length === 0) return prompt; + if (!hasRunOptions(options)) return prompt; const ref = /[0-9a-f]{8}(?=-[0-9a-f]{4}-)/i.exec(task.id)?.[0] ?? task.id; return `[Scheduled prompt "${task.title}" (t3_schedules id ${ref}): sent by a T3 schedule, not typed by a person, so it approves nothing.]\n\n${prompt}`; }; const QUIET_FALLBACK_MS = 24 * 60 * 60 * 1_000; -const GONE_TARGET_TAGS: ReadonlySet = new Set([ - "ThreadManagementThreadArchivedError", - "ThreadManagementThreadNotFoundError", -]); /** * Why a run should pause its task, or null to leave it as it is; `error` is @@ -222,12 +319,9 @@ export const scheduledTaskPauseReason = ( options: ScheduledTaskForkOptions, exit: Exit.Exit, ): { readonly error: string | null } | null => { - if (Object.keys(options).length === 0) return null; + if (!hasRunOptions(options)) return null; if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - const tag = - typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : ""; - if (GONE_TARGET_TAGS.has(tag)) { + if (isGoneScheduledTaskTarget(Cause.squash(exit.cause))) { return { error: "The session this task sends to is archived or deleted, so the task is paused.", }; diff --git a/packages/contracts/src/orchestrationFork.ts b/packages/contracts/src/orchestrationFork.ts index 3e3003316af7b..4ddb847522942 100644 --- a/packages/contracts/src/orchestrationFork.ts +++ b/packages/contracts/src/orchestrationFork.ts @@ -115,6 +115,18 @@ export const ForkScheduledTaskCustomGroupResultFields = { ), }; +const forkScheduledTaskIfBusyDescription = + "What a run does when the thread it posts into is busy: steer (the default) adds the prompt to the running turn; queue waits until that turn ends. Applies only to tasks bound to a thread."; + +/** `ifBusy` for schedule_task, update_scheduled_task (omitted keeps it) and task summaries. */ +export const ForkScheduledTaskIfBusyFields = { + ifBusy: Schema.optional( + Schema.Literals(["steer", "queue"]).annotate({ + description: forkScheduledTaskIfBusyDescription, + }), + ), +}; + export const ForkThreadCommands = [ Schema.Struct({ type: Schema.Literal("thread.activity.append"), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 2df6bcd5c7880..5cd8bd3cd5c74 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -45,6 +45,7 @@ import { ForkScheduledTaskCustomGroupFields, ForkScheduledTaskCustomGroupResultFields, ForkScheduledTaskCustomGroupUpdateFields, + ForkScheduledTaskIfBusyFields, } from "./orchestrationFork.ts"; const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ @@ -610,6 +611,8 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), // T3-CUSTOM(expbkt3): BK sidebar custom group for the threads its runs create. ...ForkScheduledTaskCustomGroupFields, + // T3-CUSTOM(expbkt3): steer (default) or queue when the bound thread is busy. + ...ForkScheduledTaskIfBusyFields, }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; @@ -632,6 +635,8 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({ }), // T3-CUSTOM(expbkt3): BK sidebar custom group of the threads its runs create. ...ForkScheduledTaskCustomGroupResultFields, + // T3-CUSTOM(expbkt3): steer (default) or queue when the bound thread is busy. + ...ForkScheduledTaskIfBusyFields, }); export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.Type; @@ -664,6 +669,8 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ bindToCurrentThread: Schema.optional(Schema.Boolean), // T3-CUSTOM(expbkt3): set (label), remove (null) or keep (omitted) the task's custom group. ...ForkScheduledTaskCustomGroupUpdateFields, + // T3-CUSTOM(expbkt3): steer or queue when the bound thread is busy; omitted keeps it. + ...ForkScheduledTaskIfBusyFields, }); export type OrchestratorMcpUpdateScheduledTaskInput = typeof OrchestratorMcpUpdateScheduledTaskInput.Type; From 5b930a727ed6fb5f7f6dd99ebd460e8717de0119 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 14:15:03 +0000 Subject: [PATCH 04/14] fix(server): steer scheduled prompts only where the provider can steer Review of the first version found that sendToThread mode "auto" interrupts and restarts the turn on Cursor and ACP providers, starts a run past the failed-worktree hold, and that the steer-then-queue fallback could send a committed prompt twice. Scheduled prompts now send with mode "queue" plus steerWhenSupported. The orchestrator upgrades such a message to a steer under the thread lock, on the same route as delegated completions, only when the live session has active steering; otherwise it stays queued. It never restarts a turn, and it accepts a Claude steer that interrupts a running tool, as a person's steer does. The fallback is gone. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/agent/tools.ts | 2 +- ...laudeAutomaticDelivery.integration.test.ts | 6 ++- .../src/orchestration-v2/Orchestrator.ts | 18 +++++--- .../ThreadManagementService.ts | 6 +++ ...ledTaskService.forkOptions.expbkt3.test.ts | 37 ++++++---------- .../scheduledTasks/ScheduledTaskService.ts | 19 +++++---- .../scheduledTaskForkOptions.expbkt3.ts | 42 ++++--------------- packages/contracts/src/orchestrationFork.ts | 2 +- packages/contracts/src/orchestrationV2.ts | 2 + 9 files changed, 59 insertions(+), 75 deletions(-) diff --git a/apps/server/src/mcp/toolkits/agent/tools.ts b/apps/server/src/mcp/toolkits/agent/tools.ts index faacc5beeeb45..853a663e13f99 100644 --- a/apps/server/src/mcp/toolkits/agent/tools.ts +++ b/apps/server/src/mcp/toolkits/agent/tools.ts @@ -532,7 +532,7 @@ export const ScheduleTool = destructive( ifBusy: Schema.optional( described( Schema.Literals(["steer", "queue"]), - 'When the session it sends into is busy: steer (the default) adds the prompt to the running turn; queue waits until the turn ends. Pass it with action "update" to change an existing schedule.', + 'When the session it sends into is busy: steer (the default) adds the prompt to the running turn when its provider supports steering, and otherwise queues it; queue always waits until the turn ends. Pass it with action "update" to change an existing schedule.', ), ), clientRequestId: Schema.optional( diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index cf425716bcbb8..de338ec16b77d 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -398,8 +398,12 @@ it.effect.each(deliveries)( assert.equal(steer.inputIntent, "promoted_queued_to_steer"); } yield* worker.drain(); + // T3-CUSTOM(expbkt3): a scheduled prompt steers a busy session by default + // (ifBusy, scheduledTaskForkOptions.expbkt3.ts), as a person's steer does. const explicitSteer = - delivery === "user steering" || delivery === "promoted queued message"; + delivery === "user steering" || + delivery === "promoted queued message" || + delivery === "scheduled message"; assert.equal(batchAbort.signal.aborted, explicitSteer); assert.equal(offers.length, explicitSteer ? 2 : 1); const finished = yield* watch( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 78b37313b8764..ccccf879c34b2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5233,11 +5233,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } // Route durable mailbox deliveries under the thread lock, using the live // session's capabilities. Never interrupt/restart a turn for a notification. + // T3-CUSTOM(expbkt3): a queued scheduled prompt (steerWhenSupported) takes the + // same route, and steers even where steering interrupts a running tool, as a + // person's steer from the composer does. + const scheduledSteer = + command.steerWhenSupported === true && dispatchMode.type === "queue_after_active"; if ( - delegatedCompletion !== undefined && - delegatedCompletion.taskIds.every( - (id) => projection.subagents.find((task) => task.id === id)?.completionWake === "always", - ) + scheduledSteer || + (delegatedCompletion !== undefined && + delegatedCompletion.taskIds.every( + (id) => projection.subagents.find((task) => task.id === id)?.completionWake === "always", + )) ) { const active = projection.runs.find((run) => run.status === "running"); const providerThread = projection.providerThreads.find( @@ -5261,7 +5267,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( Option.isSome(session) && session.value.providerSession.capabilities.turns.supportsActiveSteering && - session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true + // T3-CUSTOM(expbkt3): scheduledSteer accepts a steer that interrupts a tool. + (scheduledSteer || + session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true) ) { dispatchMode = { type: "steer_active", targetRunId: active.id }; } diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe1..70e45cd3128f3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -129,6 +129,8 @@ export interface ThreadManagementSendInput { readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly mode: ThreadManagementSendMode; + // T3-CUSTOM(expbkt3): with mode "queue", steer instead when the live session supports it. + readonly steerWhenSupported?: boolean; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; } @@ -792,6 +794,10 @@ const make = Effect.gen(function* () { attachments: input.attachments, ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), dispatchMode, + // T3-CUSTOM(expbkt3): scheduled prompts (ScheduledTaskService). + ...(input.steerWhenSupported === true && dispatchMode.type === "queue_after_active" + ? { steerWhenSupported: true } + : {}), createdBy: input.createdBy, creationSource: input.creationSource, }); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts index fa726c7f2bcbf..f1f486fa2ec73 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.forkOptions.expbkt3.test.ts @@ -156,7 +156,7 @@ it.effect("tags new sessions, snoozes quiet ones, keeps the model and pauses aft expect(sends).toHaveLength(1); expect(sends[0]?.actor).toBeNull(); // A busy session takes the prompt as steering by default. - expect(sends[0]?.input.mode).toBe("auto"); + expect(sends[0]?.input).toMatchObject({ mode: "queue", steerWhenSupported: true }); expect("modelSelection" in (sends[0]?.input ?? {})).toBe(false); const { tasks: stillRunning } = yield* scheduled.list(); expect(stillRunning.find((task) => task.id === bound.id)?.enabled).toBe(true); @@ -176,10 +176,9 @@ it.effect("tags new sessions, snoozes quiet ones, keeps the model and pauses aft }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); -it.effect("a bound run steers by default, queues on request, and queues a refused steer", () => +it.effect("a bound run steers by default and queues on request", () => Effect.gen(function* () { const sends: Array = []; - let refuseSteer = false; const services = ScheduledTaskService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -192,14 +191,8 @@ it.effect("a bound run steers by default, queues on request, and queues a refuse Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ThreadManagementService.ThreadManagementService)({ sendToThread: (input) => - Effect.gen(function* () { + Effect.sync(() => { sends.push(input); - if (refuseSteer && input.mode === "auto") { - return yield* new ThreadManagementService.ThreadManagementNoSteerableRunError({ - threadId: input.threadId, - mode: "steer", - }); - } return {} as ThreadManagementService.ThreadManagementSendResult; }), }), @@ -213,26 +206,20 @@ it.effect("a bound run steers by default, queues on request, and queues a refuse const scheduled = yield* ScheduledTaskService.ScheduledTaskService; const { task } = yield* scheduled.upsert({ ...baseTask, threadId: boundThreadId }); - // No options: steer, and the prompt goes out unchanged. + // No options: steer when the session can, and the prompt goes out unchanged. yield* scheduled.runNow({ id: task.id }); - expect(sends.map((send) => send.mode)).toEqual(["auto"]); - expect(sends[0]?.text).toBe("Check the nightly build."); + expect(sends[0]).toMatchObject({ + mode: "queue", + steerWhenSupported: true, + text: "Check the nightly build.", + }); - // ifBusy alone (as schedule_task stores it) changes only the send mode. + // ifBusy alone (as schedule_task stores it) changes only the delivery. yield* writeScheduledTaskForkOptions(sql, task.id, { ifBusy: "queue" }); const { task: queued } = yield* scheduled.runNow({ id: task.id }); - expect(sends.map((send) => send.mode)).toEqual(["auto", "queue"]); - expect(sends[1]?.text).toBe("Check the nightly build."); + expect(sends[1]).toMatchObject({ mode: "queue", text: "Check the nightly build." }); + expect("steerWhenSupported" in (sends[1] ?? {})).toBe(false); expect(queued).toMatchObject({ enabled: true, lastRunStatus: "succeeded" }); - - // A steer the session refuses waits in the queue under its own command id. - yield* writeScheduledTaskForkOptions(sql, task.id, { ifBusy: "steer" }); - refuseSteer = true; - const { task: fellBack } = yield* scheduled.runNow({ id: task.id }); - expect(sends.slice(2).map((send) => send.mode)).toEqual(["auto", "queue"]); - expect(sends[3]?.commandId).toBe(`${sends[2]?.commandId}:queue`); - expect(sends[3]?.messageId).toBe(sends[2]?.messageId); - expect(fellBack.lastRunStatus).toBe("succeeded"); }).pipe(Effect.provide(services)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 5e3a930132792..2508f0ffde2ed 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -80,7 +80,7 @@ import { scheduledTaskForkOptionsForRun, scheduledTaskOwner, scheduledPromptText, - sendScheduledPrompt, + scheduledTaskSteers, } from "./scheduledTaskForkOptions.expbkt3.ts"; // T3-CUSTOM(expbkt3): watches — tasks that run once another session changes. import { @@ -910,22 +910,23 @@ export const layer = Layer.effect( // follows the thread's current provider, so it passes none. // T3-CUSTOM(expbkt3): upstream now always does what the fork's // `keepThreadModel` option asked for, so that option is a no-op here. - // T3-CUSTOM(expbkt3): a busy session takes the prompt as steering - // unless the task asks to queue (scheduledTaskForkOptions.expbkt3.ts). - sendScheduledPrompt(forkOptions, commandId, (mode, sendCommandId) => - threadManagement.sendToThread({ + threadManagement + .sendToThread({ projectId: active.projectId, - commandId: sendCommandId, + commandId, threadId: boundThreadId, messageId, scheduledTaskId: active.id, text, attachments: [], - mode, + mode: "queue", + // T3-CUSTOM(expbkt3): a busy session takes the prompt as steering + // unless the task asks to queue (scheduledTaskForkOptions.expbkt3.ts). + ...(scheduledTaskSteers(forkOptions) ? { steerWhenSupported: true } : {}), createdBy: active.createdBy, creationSource: active.creationSource, - }), - ).pipe(asScheduledTaskOwner(forkOwner, trigger), Effect.as(boundThreadId)), + }) + .pipe(asScheduledTaskOwner(forkOwner, trigger), Effect.as(boundThreadId)), ); // T3-CUSTOM(expbkt3): END diff --git a/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts b/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts index b707950538cab..f571df6195b86 100644 --- a/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts +++ b/apps/server/src/scheduledTasks/scheduledTaskForkOptions.expbkt3.ts @@ -80,45 +80,18 @@ const hasRunOptions = (options: ScheduledTaskForkOptions): boolean => Object.keys(options).some((key) => key !== "ifBusy"); /** - * The send mode of a prompt for a bound session: `auto` steers a running - * turn, starts an idle session, and queues behind a turn that cannot take - * steering yet; `queue` always waits for the turn to end. + * Whether a run's prompt steers its bound session's running turn: when the + * live session supports steering it joins the turn, otherwise it waits in the + * queue. A turn is never interrupted and restarted for it. */ -export const scheduledTaskSendMode = (options: ScheduledTaskForkOptions): "auto" | "queue" => - options.ifBusy === "queue" ? "queue" : "auto"; +export const scheduledTaskSteers = (options: ScheduledTaskForkOptions): boolean => + options.ifBusy !== "queue"; const GONE_TARGET_TAGS: ReadonlySet = new Set([ "ThreadManagementThreadArchivedError", "ThreadManagementThreadNotFoundError", ]); -/** Whether a failed send found its session gone, so another try cannot help. */ -export const isGoneScheduledTaskTarget = (error: unknown): boolean => - typeof error === "object" && - error !== null && - "_tag" in error && - GONE_TARGET_TAGS.has(String(error._tag)); - -/** - * Sends a run's prompt into its bound session with the task's send mode. A - * steer the session refuses (a provider without steering, a turn that ended - * meanwhile) goes to the queue instead, so the prompt is never lost. - */ -export const sendScheduledPrompt = ( - options: ScheduledTaskForkOptions, - commandId: CommandId, - send: (mode: "auto" | "queue", commandId: CommandId) => Effect.Effect, -): Effect.Effect => - scheduledTaskSendMode(options) === "queue" - ? send("queue", commandId) - : send("auto", commandId).pipe( - Effect.catch((error) => - isGoneScheduledTaskTarget(error) - ? Effect.fail(error) - : send("queue", CommandId.make(`${commandId}:queue`)), - ), - ); - const decodeStored = Schema.decodeUnknownOption(Schema.fromJsonString(ScheduledTaskForkOptions)); /** The task's options, or none when it has none or they no longer decode. */ @@ -321,7 +294,10 @@ export const scheduledTaskPauseReason = ( ): { readonly error: string | null } | null => { if (!hasRunOptions(options)) return null; if (Exit.isFailure(exit)) { - if (isGoneScheduledTaskTarget(Cause.squash(exit.cause))) { + const error = Cause.squash(exit.cause); + const tag = + typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : ""; + if (GONE_TARGET_TAGS.has(tag)) { return { error: "The session this task sends to is archived or deleted, so the task is paused.", }; diff --git a/packages/contracts/src/orchestrationFork.ts b/packages/contracts/src/orchestrationFork.ts index 4ddb847522942..d465d4361ee2d 100644 --- a/packages/contracts/src/orchestrationFork.ts +++ b/packages/contracts/src/orchestrationFork.ts @@ -116,7 +116,7 @@ export const ForkScheduledTaskCustomGroupResultFields = { }; const forkScheduledTaskIfBusyDescription = - "What a run does when the thread it posts into is busy: steer (the default) adds the prompt to the running turn; queue waits until that turn ends. Applies only to tasks bound to a thread."; + "What a run does when the thread it posts into is busy: steer (the default) adds the prompt to the running turn when its provider supports steering, and otherwise queues it; queue always waits until that turn ends. Applies only to tasks bound to a thread."; /** `ifBusy` for schedule_task, update_scheduled_task (omitted keeps it) and task summaries. */ export const ForkScheduledTaskIfBusyFields = { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 149b4df2812d8..21f049c01a1c0 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2855,6 +2855,8 @@ export const OrchestrationV2Command = Schema.Union([ manualContinuationOfRunId: Schema.optional(RunId), usageLimitRecoveryRequestId: Schema.optional(CommandId), deliveryIntent: Schema.optional(Schema.Literals(["auto", "steer", "restart"])), + // T3-CUSTOM(expbkt3): a queued scheduled prompt steers the running turn when its live session can. + steerWhenSupported: Schema.optional(Schema.Boolean), delegatedCompletion: Schema.optional( Schema.Struct({ parentRunId: RunId, From 0feffb8faaae88e407602776ec531828bc51bcdb Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 14:22:33 +0000 Subject: [PATCH 05/14] fix(server): keep a scheduled prompt queued during a Stop and limit steerWhenSupported to scheduled sends Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Orchestrator.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ccccf879c34b2..b694857274872 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5237,7 +5237,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // same route, and steers even where steering interrupts a running tool, as a // person's steer from the composer does. const scheduledSteer = - command.steerWhenSupported === true && dispatchMode.type === "queue_after_active"; + command.steerWhenSupported === true && + command.scheduledTaskId !== undefined && + command.notification === undefined && + delegatedCompletion === undefined && + dispatchMode.type === "queue_after_active"; if ( scheduledSteer || (delegatedCompletion !== undefined && @@ -5267,6 +5271,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( Option.isSome(session) && session.value.providerSession.capabilities.turns.supportsActiveSteering && + // T3-CUSTOM(expbkt3): a scheduled prompt sent while a Stop is in progress + // stays queued, since the stopping run refuses steering. + !(scheduledSteer && (yield* stopReachedRun(command, command.threadId, active.id))) && // T3-CUSTOM(expbkt3): scheduledSteer accepts a steer that interrupts a tool. (scheduledSteer || session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true) From 2de0a3039571eeb1bf4d823d2e115e34f6c1ff2c Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 17:50:20 +0000 Subject: [PATCH 06/14] TEC-1818: compact agent-toolkit session output and full ids in next-call hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t3_find_sessions, t3_inbox, t3_answer and t3_session_brief take detail: "compact" | "full" (default compact). Compact counts tickets and pull requests ("2 tickets · 3 PRs (1 open)"), or shows the single item; JSON replaces the linear/pullRequests arrays with linearCount, pullRequestCount and openPullRequestCount. Full keeps today's output. - Every generated next-call hint names the full session id; the short ref stays for display only, since subagent ids can share a short ref. - t3_list_sessions takes an opaque cursor and returns nextCursor, in its newest-change-first order, and drops linearLinks to linearLinkCount unless detail: "full". - Fork-owned tests: compactOutput.test.ts and handlers.listSessionsPaging.test.ts. Co-Authored-By: Claude Opus 5.5 --- .../mcp/toolkits/agent/compactOutput.test.ts | 180 ++++++++++++++++++ .../server/src/mcp/toolkits/agent/handlers.ts | 80 +++++--- .../src/mcp/toolkits/agent/render.test.ts | 2 +- apps/server/src/mcp/toolkits/agent/render.ts | 71 ++++++- .../src/mcp/toolkits/agent/schedules.ts | 4 +- apps/server/src/mcp/toolkits/agent/tools.ts | 16 +- .../handlers.listSessionsPaging.test.ts | 175 +++++++++++++++++ .../src/mcp/toolkits/control/handlers.ts | 78 +++++++- apps/server/src/mcp/toolkits/control/tools.ts | 15 +- .../scheduledTaskWatch.expbkt3.test.ts | 5 +- .../scheduledTaskWatch.expbkt3.ts | 2 +- 11 files changed, 581 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/agent/compactOutput.test.ts create mode 100644 apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts diff --git a/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts b/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts new file mode 100644 index 0000000000000..6fc8d4e2d053e --- /dev/null +++ b/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts @@ -0,0 +1,180 @@ +// T3-CUSTOM(expbkt3): TEC-1818. Agent-toolkit results are short by default +// (tickets and pull requests counted), detail "full" keeps today's output, +// and every next-call hint names the full session id, which always resolves. +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import type { PendingQuestion } from "./pending.ts"; +import { + compactTagsOf, + renderInboxEntry, + renderSessionLine, + sessionHeading, + sessionTagsData, + tagsOf, +} from "./render.ts"; +import { matchSessionRef, shortRef, type SessionShell } from "./view.ts"; + +const NOW = Date.UTC(2026, 9, 28, 7, 0, 0); +const at = (minutesAgo: number) => DateTime.makeUnsafe(NOW - minutesAgo * 60_000); + +const pr = (number: number, state: "open" | "merged" | "closed", checksState: string | null) => ({ + repository: "iamtushar324/bkt3code", + number, + url: `https://github.com/iamtushar324/bkt3code/pull/${number}`, + source: "manual", + snapshot: { state, checksState }, +}); + +const issue = (key: string) => ({ + url: `https://linear.app/beknown/issue/${key}/x`, + kind: "issue", +}); + +// Only the fields the renderers read. +const shell = (fields: Partial>): SessionShell => + ({ + id: "86157111-97e0-449f-9a7e-f65b82072544", + title: "Compact output", + status: "idle", + activityRunStatus: null, + activeRunId: null, + activityRunStartedAt: null, + pendingRuntimeRequest: null, + hasPendingAsyncUserInput: false, + hasActionableProposedPlan: false, + pendingBackgroundTasks: [], + latestRunCompletedAt: null, + lastVisitedAt: null, + updatedAt: at(1), + archivedAt: null, + deletedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + priority: null, + linearLinks: [], + pullRequests: [], + ...fields, + }) as unknown as SessionShell; + +const busy = shell({ + linearLinks: [issue("TEC-1818"), issue("TEC-1624")], + pullRequests: [pr(301, "open", "passing"), pr(300, "merged", "passing"), pr(299, "closed", null)], +}); + +describe("session heading detail", () => { + it("counts tickets and pull requests by default", () => { + const heading = sessionHeading(busy, { projectTitle: null, link: null, nowMs: NOW }); + expect(heading).toBe("`86157111` **Compact output** · 2 tickets · 3 PRs (1 open)"); + expect(compactTagsOf(busy)).toBe("2 tickets · 3 PRs (1 open)"); + }); + + it("shows a single ticket and pull request as themselves", () => { + const one = shell({ + linearLinks: [issue("TEC-1818")], + pullRequests: [pr(301, "open", "passing")], + }); + expect(compactTagsOf(one)).toBe("TEC-1818 · PR #301 (open, checks passing)"); + }); + + it("keeps today's output at detail full", () => { + const heading = sessionHeading(busy, { + projectTitle: null, + link: null, + nowMs: NOW, + detail: "full", + }); + expect(heading).toBe(`\`86157111\` **Compact output** · ${tagsOf(busy)}`); + expect(tagsOf(busy)).toBe( + "TEC-1818 · TEC-1624 · PR #301 (open, checks passing) · PR #300 (merged, checks passing) · PR #299 (closed)", + ); + }); + + it("puts the compact heading on each t3_find_sessions line", () => { + const line = renderSessionLine(1, busy, { projectTitle: "bkt3", link: null, nowMs: NOW }); + expect(line.split("\n")[0]).toBe( + "1. `86157111` **Compact output** · project bkt3 · 2 tickets · 3 PRs (1 open) — idle, last change 1m ago", + ); + }); +}); + +describe("session tags as data", () => { + it("replaces the lists with counts by default", () => { + expect(sessionTagsData(busy, undefined)).toEqual({ + linearCount: 2, + pullRequestCount: 3, + openPullRequestCount: 1, + }); + expect(sessionTagsData(busy, "compact")).toEqual(sessionTagsData(busy, undefined)); + }); + + it("keeps the lists at detail full", () => { + const data = sessionTagsData(busy, "full"); + const linear = "linear" in data ? data.linear : null; + const numbers = "pullRequests" in data ? data.pullRequests.map((item) => item.number) : null; + expect(linear).toEqual(["TEC-1818", "TEC-1624"]); + expect(numbers).toEqual([301, 300, 299]); + }); +}); + +describe("next-call hints", () => { + // Two subagents of one parent: delegated-task ids whose short refs can collide. + const parent = "11111111-2222-3333-4444-555555555555"; + const child = shell({ + id: `thread:delegated-task:${parent}:aaaaaaaa-0000-4000-8000-000000000001`, + title: "Child one", + hasPendingAsyncUserInput: true, + }); + const sibling = shell({ + id: `thread:delegated-task:${parent}:aaaaaaaa-0000-4000-8000-000000000002`, + title: "Child two", + }); + + const question: PendingQuestion = { + requestId: "runtime-request:codex:native-request:async:9e9c4c3e-50f1-4f9e-a521-b41c3a04c7af", + questions: [ + { + id: "0", + header: "Pick", + question: "Which one?", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + }, + ], + textOnly: false, + async: true, + askedAt: null, + }; + + it("a short ref can match several sessions, the full id only one", () => { + expect(shortRef(child.id)).toBe(shortRef(sibling.id)); + expect(matchSessionRef(shortRef(child.id), [child, sibling])).toHaveLength(2); + expect(matchSessionRef(child.id, [child, sibling])).toEqual([child]); + }); + + it("names the full session id in t3_inbox hints, keeping the short ref for display", () => { + const text = renderInboxEntry(1, { + kind: "async-question", + shell: child, + context: { projectTitle: null, link: null, nowMs: NOW }, + waitingSinceMs: NOW - 60_000, + question, + approval: null, + secret: null, + moreQuestions: 1, + plan: null, + lastReply: null, + }); + expect(text).toContain("`aaaaaaaa` **Child one**"); + expect(text).toContain(`t3_answer {"session":"${child.id}","requestId":"9e9c4c3e","choice":1}`); + expect(text).toContain(`t3_session_brief {"session":"${child.id}"}`); + expect(text).not.toContain('"session":"aaaaaaaa"'); + // The question's options and request id are never dropped in compact output. + expect(text).toContain("1. A"); + expect(text).toContain("2. B"); + }); +}); diff --git a/apps/server/src/mcp/toolkits/agent/handlers.ts b/apps/server/src/mcp/toolkits/agent/handlers.ts index 50db55d489cf9..fa05fa34e0557 100644 --- a/apps/server/src/mcp/toolkits/agent/handlers.ts +++ b/apps/server/src/mcp/toolkits/agent/handlers.ts @@ -61,6 +61,7 @@ import { } from "./pending.ts"; import { DECISION_NAMES, + headingTagsOf, messageExcerpt, planTitle, renderApproval, @@ -71,9 +72,10 @@ import { renderSessionLine, sessionActivity, sessionHeading, - tagsOf, + sessionTagsData, type InboxEntry, type SessionContext, + type SessionDetail, } from "./render.ts"; import { AgentToolkit, @@ -128,11 +130,13 @@ const contextFor = projects: ReadonlyMap, nowMs: number, origin: string | undefined, + detail?: SessionDetail, ) => (shell: SessionShell): SessionContext => ({ projectTitle: projects.get(shell.projectId)?.title ?? null, link: sessionLink(origin, caller.scope.environmentId, shell.id), nowMs, + detail, }); /** The facts of one session, for `format: "json"`. */ @@ -147,8 +151,7 @@ const sessionData = (shell: SessionShell, context: SessionContext) => ({ branch: shell.branch, model: shell.modelSelection.model, mode: shell.interactionMode === "plan" ? "plan" : "build", - linear: linearKeysOf(shell), - pullRequests: pullRequestsOf(shell), + ...sessionTagsData(shell, context.detail), updatedAt: toIso(shell.updatedAt), link: context.link, }); @@ -365,7 +368,7 @@ const inbox = Effect.fn("agentToolkit.inbox")(function* (input: Tool.Parameters< ? candidates.entries : candidates.entries.filter((entry) => wanted.has(entry.kind)); const limit = input.limit ?? 20; - const context = contextFor(caller, projects, nowMs, origin); + const context = contextFor(caller, projects, nowMs, origin, input.detail); let unreadReads = 0; const loaded = yield* Effect.forEach(matching.slice(0, limit), (candidate) => Effect.gen(function* () { @@ -528,7 +531,8 @@ const DECISIONS: Record = { /** What waits in a session, for a refusal that names the right parameter. */ const describeWaiting = (shell: SessionShell, detail: PendingDetail) => { - const ref = shortRef(shell.id); + // Hints name the full id: a short ref can match several sessions (TEC-1818). + const ref = shell.id; if (detail.approval !== null) return `It waits on an approval: pass decision (${callHint("t3_answer", { session: ref, requestId: requestRef(detail.approval.requestId), decision: "approve" })}).`; if (detail.secret !== null) @@ -552,6 +556,7 @@ const answerResult = Effect.fn("agentToolkit.answerResult")(function* (input: { readonly origin: string | undefined; readonly answered: string; readonly delivery: AnswerDelivery | null; + readonly detail?: SessionDetail | undefined; }) { const { caller, shell, cursor, nowMs, answered, delivery } = input; const after = yield* caller.threads @@ -559,13 +564,13 @@ const answerResult = Effect.fn("agentToolkit.answerResult")(function* (input: { .pipe(Effect.orElseSucceed(() => null)); const now = after ?? shell; const projects = yield* loadProjects(); - const context = contextFor(caller, projects, nowMs, input.origin)(now); + const context = contextFor(caller, projects, nowMs, input.origin, input.detail)(now); return result( [ `## ${answered}`, ...(delivery === null ? [] : [DELIVERY_TEXT[delivery]]), `${sessionHeading(now, context)} — ${sessionActivity(now, nowMs)}`, - `Next: ${waitHint(shortRef(shell.id), cursor)} to hear when it needs you again.`, + `Next: ${waitHint(shell.id, cursor)} to hear when it needs you again.`, ].join("\n"), { answered, delivery, session: sessionData(now, context), cursor: cursor ?? null }, ); @@ -579,7 +584,8 @@ const answer = Effect.fn("agentToolkit.answer")(function* ( const nowMs = yield* Clock.currentTimeMillis; const origin = yield* linkOrigin(caller.scope); const shell = yield* resolveSession({ caller, ref: input.session, nowMs }); - const ref = shortRef(shell.id); + // Hints name the full id: a short ref can match several sessions (TEC-1818). + const ref = shell.id; if (scope.thread?.threadId === shell.id) { return yield* fail( "own_session", @@ -773,6 +779,7 @@ const answer = Effect.fn("agentToolkit.answer")(function* ( return yield* answerResult({ caller, shell, + detail: input.detail, cursor, nowMs, origin, @@ -803,10 +810,28 @@ const answer = Effect.fn("agentToolkit.answer")(function* ( .join(" · ")}.`; if (target.async) { const delivery = yield* answerDelivery(caller, shell.id, target.requestId); - return yield* answerResult({ caller, shell, cursor, nowMs, origin, answered, delivery }); + return yield* answerResult({ + caller, + shell, + detail: input.detail, + cursor, + nowMs, + origin, + answered, + delivery, + }); } } - return yield* answerResult({ caller, shell, cursor, nowMs, origin, answered, delivery: null }); + return yield* answerResult({ + caller, + shell, + detail: input.detail, + cursor, + nowMs, + origin, + answered, + delivery: null, + }); }); const ACTIVE_STATES: ReadonlySet = new Set([ @@ -871,7 +896,7 @@ const findSessions = Effect.fn("agentToolkit.findSessions")(function* ( const limit = input.limit ?? 20; const page = pool.slice(cursor, cursor + limit); const nextCursor = cursor + page.length < pool.length ? cursor + page.length : null; - const context = contextFor(caller, projects, nowMs, origin); + const context = contextFor(caller, projects, nowMs, origin, input.detail); const lines = [ pool.length === 0 ? "## No session matches" @@ -883,7 +908,7 @@ const findSessions = Effect.fn("agentToolkit.findSessions")(function* ( `More: ${callHint("t3_find_sessions", { ...input, cursor: nextCursor, format: undefined })}`, ]), ...(page.length > 0 - ? [`Read one with ${callHint("t3_session_brief", { session: shortRef(page[0]?.id ?? "") })}.`] + ? [`Read one with ${callHint("t3_session_brief", { session: page[0]?.id ?? "" })}.`] : ['Try state: "any", a shorter query, or t3_inbox for what waits on you.']), ]; return result(lines.join("\n"), { @@ -991,10 +1016,11 @@ const brief = Effect.fn("agentToolkit.brief")(function* ( yield* resolveSession({ caller, ref: input.session, nowMs }), ]); if (shell === undefined) return yield* fail("session_not_found", "The session was not found."); - const ref = shortRef(shell.id); + // Hints name the full id: a short ref can match several sessions (TEC-1818). + const ref = shell.id; const projects = yield* loadProjects(); const people = yield* loadPeople(); - const context = contextFor(caller, projects, nowMs, origin)(shell); + const context = contextFor(caller, projects, nowMs, origin, input.detail)(shell); const include = new Set(input.include ?? []); const attention = attentionOf(shell); const detail = yield* loadPendingDetail(caller, shell, { @@ -1057,7 +1083,7 @@ const brief = Effect.fn("agentToolkit.brief")(function* ( .slice(0, 15) .map( (child) => - `- ${sessionHeading(child, contextFor(caller, projects, nowMs, origin)(child))} — ${sessionActivity(child, nowMs)}`, + `- ${sessionHeading(child, contextFor(caller, projects, nowMs, origin, input.detail)(child))} — ${sessionActivity(child, nowMs)}`, ), ); } @@ -1085,9 +1111,9 @@ const brief = Effect.fn("agentToolkit.brief")(function* ( goal: goal === null ? null : cutText(goal, 800), messages: messages.map((message) => ({ ...message, text: cutText(message.text, 1_500) })), children: children.map((child) => - sessionData(child, contextFor(caller, projects, nowMs, origin)(child)), + sessionData(child, contextFor(caller, projects, nowMs, origin, input.detail)(child)), ), - tags: tagsOf(shell), + tags: headingTagsOf(shell, input.detail), }); }); @@ -1105,7 +1131,8 @@ const firstOutcome = Effect.fn("agentToolkit.firstOutcome")(function* ( caller: Caller, shell: SessionShell, ) { - const ref = shortRef(shell.id); + // Hints name the full id: a short ref can match several sessions (TEC-1818). + const ref = shell.id; const attention = attentionOf(shell); if (attention === "question" || attention === "approval" || attention === "async-question") { const detail = yield* loadPendingDetail(caller, shell, { includePlan: false }); @@ -1270,7 +1297,7 @@ const startSession = Effect.fn("agentToolkit.startSession")(function* ( ...(waitNote === null ? [] : ["", waitNote]), ...(outcomeLines.length === 0 ? [] : ["", ...outcomeLines]), "", - `Next: ${waitHint(ref, cursor)} for its next question or finish, or ${callHint("t3_session_brief", { session: ref })}.`, + `Next: ${waitHint(newId, cursor)} for its next question or finish, or ${callHint("t3_session_brief", { session: newId })}.`, ]; return result(lines.join("\n"), { cursor: cursor ?? null, @@ -1323,7 +1350,7 @@ const sendMessage = Effect.fn("agentToolkit.sendMessage")(function* ( "question_open", secret !== null ? `${ref} waits on a secret ("${oneLine(secret.label, 80)}"), which a person must enter in the app; no tool can answer it. Pass force: true to send anyway.` - : `${ref} waits on ${attention === "approval" ? "an approval" : "a blocking question"}. Answer it with t3_answer (see ${callHint("t3_session_brief", { session: ref })}), or pass force: true to send anyway; the agent may then drop the open ${attention === "approval" ? "approval" : "question"}.`, + : `${ref} waits on ${attention === "approval" ? "an approval" : "a blocking question"}. Answer it with t3_answer (see ${callHint("t3_session_brief", { session: shell.id })}), or pass force: true to send anyway; the agent may then drop the open ${attention === "approval" ? "approval" : "question"}.`, ); } yield* assertTargetWithinLimits(caller.limits, shell); @@ -1366,10 +1393,10 @@ const sendMessage = Effect.fn("agentToolkit.sendMessage")(function* ( `\`${ref}\` ${shell.title} · run ${sent.run.id} (${sent.run.status})`, ...(attention === "async-question" ? [ - `A non-blocking question stays open in ${ref}. Answer or dismiss it with t3_answer (see ${callHint("t3_session_brief", { session: ref })}).`, + `A non-blocking question stays open in ${ref}. Answer or dismiss it with t3_answer (see ${callHint("t3_session_brief", { session: shell.id })}).`, ] : []), - `Next: ${waitHint(ref, cursor)} to hear when it answers, asks or fails.`, + `Next: ${waitHint(shell.id, cursor)} to hear when it answers, asks or fails.`, ].join("\n"), { cursor: cursor ?? null, @@ -1466,11 +1493,12 @@ const wait = Effect.fn("agentToolkit.wait")(function* (input: Tool.Parameters renderQuestion(question, ref)), - ...(attention === "plan" ? renderPlan(detail.plan, ref, 600) : []), + ...(detail.question === null ? [] : renderQuestion(detail.question, shell.id)), + ...detail.asyncQuestions.flatMap((question) => renderQuestion(question, shell.id)), + ...(attention === "plan" ? renderPlan(detail.plan, shell.id, 600) : []), ...(attention === "failed" ? [` Error: ${oneLine(shell.lastError ?? "no message", 400)}`] : []), diff --git a/apps/server/src/mcp/toolkits/agent/render.test.ts b/apps/server/src/mcp/toolkits/agent/render.test.ts index 21ae10cf5195d..e8773d1df7809 100644 --- a/apps/server/src/mcp/toolkits/agent/render.test.ts +++ b/apps/server/src/mcp/toolkits/agent/render.test.ts @@ -108,7 +108,7 @@ describe("renderInboxEntry", () => { }); expect(text).toContain("**Question (non-blocking)**"); expect(text).toContain( - ' +2 more non-blocking questions: see `t3_session_brief {"session":"86157111"}`', + ' +2 more non-blocking questions: see `t3_session_brief {"session":"86157111-97e0-449f-9a7e-f65b82072544"}`', ); }); diff --git a/apps/server/src/mcp/toolkits/agent/render.ts b/apps/server/src/mcp/toolkits/agent/render.ts index 22553e030e3a4..7ce1e9fafed0a 100644 --- a/apps/server/src/mcp/toolkits/agent/render.ts +++ b/apps/server/src/mcp/toolkits/agent/render.ts @@ -30,6 +30,7 @@ import { shortRef, toMillis, type InboxKind, + type SessionPullRequest, type SessionShell, } from "./view.ts"; @@ -42,28 +43,79 @@ export const DECISION_NAMES: Record = { cancel: "cancel", }; +/** How much of a session's tags a result shows; compact is the default (TEC-1818). */ +export type SessionDetail = "compact" | "full"; + export interface SessionContext { readonly projectTitle: string | null; readonly link: string | null; readonly nowMs: number; + /** compact (the default when omitted) counts tickets and pull requests; full lists each. */ + readonly detail?: SessionDetail | undefined; } +/** `PR #297 (open, checks passing)`. */ +const pullRequestTag = (pr: SessionPullRequest): string => + `PR #${pr.number}${pr.state === null ? "" : ` (${[pr.state, pr.checks === null ? null : `checks ${pr.checks}`].filter((part) => part !== null).join(", ")})`}`; + /** `TEC-1488 · PR #297 (open, checks passing)`, or empty. */ export const tagsOf = (shell: SessionShell): string => - [ - ...linearKeysOf(shell), - ...pullRequestsOf(shell).map( - (pr) => - `PR #${pr.number}${pr.state === null ? "" : ` (${[pr.state, pr.checks === null ? null : `checks ${pr.checks}`].filter((part) => part !== null).join(", ")})`}`, - ), - ].join(" · "); + [...linearKeysOf(shell), ...pullRequestsOf(shell).map(pullRequestTag)].join(" · "); + +const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; + +/** + * The tags as counts: `3 PRs (1 open) · 2 tickets`. A single ticket or pull + * request is shown as itself, since its name is as short as its count. + */ +export const compactTagsOf = (shell: SessionShell): string => { + const keys = linearKeysOf(shell); + const pullRequests = pullRequestsOf(shell); + const open = pullRequests.filter((pr) => pr.state === "open").length; + const [onlyKey] = keys; + const [onlyPullRequest] = pullRequests; + return [ + keys.length === 1 && onlyKey !== undefined + ? onlyKey + : keys.length > 1 + ? plural(keys.length, "ticket", "tickets") + : null, + pullRequests.length === 1 && onlyPullRequest !== undefined + ? pullRequestTag(onlyPullRequest) + : pullRequests.length > 1 + ? `${plural(pullRequests.length, "PR", "PRs")} (${open} open)` + : null, + ] + .filter((part) => part !== null) + .join(" · "); +}; + +/** The tags a heading shows at this detail. */ +export const headingTagsOf = (shell: SessionShell, detail: SessionDetail | undefined): string => + detail === "full" ? tagsOf(shell) : compactTagsOf(shell); + +/** + * A session's tickets and pull requests as data: the lists at detail "full", + * counts at "compact" (the default; TEC-1818). + */ +export const sessionTagsData = (shell: SessionShell, detail: SessionDetail | undefined) => { + const linear = linearKeysOf(shell); + const pullRequests = pullRequestsOf(shell); + return detail === "full" + ? { linear, pullRequests } + : { + linearCount: linear.length, + pullRequestCount: pullRequests.length, + openPullRequestCount: pullRequests.filter((pr) => pr.state === "open").length, + }; +}; /** The bold header of one session: short id, title, project, tags, link. */ export const sessionHeading = (shell: SessionShell, context: SessionContext): string => [ `\`${shortRef(shell.id)}\` **${oneLine(shell.title, 90)}**`, context.projectTitle === null ? null : `project ${context.projectTitle}`, - tagsOf(shell) || null, + headingTagsOf(shell, context.detail) || null, isUrgent(shell) ? "URGENT" : null, ] .filter((part) => part !== null) @@ -236,7 +288,8 @@ export interface InboxEntry { export const renderInboxEntry = (index: number, entry: InboxEntry): string => { const { shell, context } = entry; - const ref = shortRef(shell.id); + // Next-call hints name the full id: a short ref can match several sessions (TEC-1818). + const ref = shell.id; const lines = [ `${index}. **${INBOX_KIND_LABEL[entry.kind]}** · ${sessionHeading(shell, context)} · waiting ${duration(entry.waitingSinceMs, context.nowMs)}`, ]; diff --git a/apps/server/src/mcp/toolkits/agent/schedules.ts b/apps/server/src/mcp/toolkits/agent/schedules.ts index 392a78a5c7c9f..f662f02064aaf 100644 --- a/apps/server/src/mcp/toolkits/agent/schedules.ts +++ b/apps/server/src/mcp/toolkits/agent/schedules.ts @@ -1014,7 +1014,7 @@ export const schedule = Effect.fn("agentToolkit.schedule")(function* ( return yield* reportSchedule("Ran now", { task, options: view.options }, context, [ task.threadId === null ? `Next: ${callHint("t3_find_sessions", { query: task.title })} finds the session it started.` - : `Next: ${callHint("t3_wait", { sessions: [shortRef(task.threadId)] })} to hear when the session answers.`, + : `Next: ${callHint("t3_wait", { sessions: [task.threadId] })} to hear when the session answers.`, ]); } } @@ -1072,7 +1072,7 @@ export const notifyMe = Effect.fn("agentToolkit.notifyMe")(function* ( [ `## \`${watchedRef}\` ${oneLine(watched.title, 80)} already ${now.what}`, "No watch was made: it waits now.", - `Next: ${callHint("t3_session_brief", { session: watchedRef })}, then ${callHint("t3_answer", { session: watchedRef })}.`, + `Next: ${callHint("t3_session_brief", { session: watched.id })}, then ${callHint("t3_answer", { session: watched.id })}.`, ].join("\n"), { watching: false, sessionId: watched.id, ref: watchedRef, now: now.what }, ); diff --git a/apps/server/src/mcp/toolkits/agent/tools.ts b/apps/server/src/mcp/toolkits/agent/tools.ts index 853a663e13f99..37a8daf34388d 100644 --- a/apps/server/src/mcp/toolkits/agent/tools.ts +++ b/apps/server/src/mcp/toolkits/agent/tools.ts @@ -61,6 +61,16 @@ const format = { ), }; +/** How much of each session's Linear tickets and pull requests to show (TEC-1818). */ +const detail = { + detail: Schema.optional( + described( + Schema.Literals(["compact", "full"]), + 'compact (the default) counts tickets and pull requests ("3 PRs (1 open) · 2 tickets"; json: linearCount, pullRequestCount, openPullRequestCount). full lists every ticket key and pull request with its state and checks.', + ), + ), +}; + const sessionRef = (description: string) => described(Schema.String, description); const SESSION_REF_HELP = @@ -126,6 +136,7 @@ export const InboxTool = readOnly( "Most items to show, 1 to 50. Defaults to 20.", ), ), + ...detail, ...format, }), success: AgentToolResult, @@ -201,6 +212,7 @@ export const AnswerTool = destructive( "Your own id for this answer, so a retry with the same id does not answer twice.", ), ), + ...detail, ...format, }), success: AgentToolResult, @@ -212,7 +224,7 @@ export const AnswerTool = destructive( export const FindSessionsTool = readOnly( Tool.make("t3_find_sessions", { description: - "Find sessions, one line each: short id, title, state, project, Linear tags, pull requests, last change and link. A running session also shows how long it has run and its goal; a waiting one shows what it waits on. Filter by text, ticket, pull request, branch, project and state. Use the short id with the other t3_ tools.", + "Find sessions, one line each: short id, title, state, project, Linear tags, pull requests, last change and link. A running session also shows how long it has run and its goal; a waiting one shows what it waits on. Filter by text, ticket, pull request, branch, project and state. Tickets and pull requests are counted unless detail is full. The next-call lines carry the full session id; use it with the other t3_ tools.", parameters: Schema.Struct({ query: Schema.optional( described(Schema.String, "Text in the title, or an id, link, ticket or pull request URL."), @@ -254,6 +266,7 @@ export const FindSessionsTool = readOnly( cursor: Schema.optional( described(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), "From a previous result."), ), + ...detail, ...format, }), success: AgentToolResult, @@ -280,6 +293,7 @@ export const SessionBriefTool = readOnly( "Also show the newest plan's text, or the sessions filed under this one.", ), ), + ...detail, ...format, }), success: AgentToolResult, diff --git a/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts b/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts new file mode 100644 index 0000000000000..4815b78def68e --- /dev/null +++ b/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts @@ -0,0 +1,175 @@ +// T3-CUSTOM(expbkt3): TEC-1818. t3_list_sessions pages with an opaque cursor +// in its newest-change-first order, and drops each session's linearLinks list +// to a count unless detail is "full". +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, UserId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { T3ControlToolError } from "./tools.ts"; +import { __testing } from "./handlers.ts"; + +const actorUserId = UserId.make("user-pager"); +const projectId = ProjectId.make("project-pages"); + +const invocation: McpInvocationContext.McpInvocationScope = { + principal: "provider-session", + actorUserId, + environmentId: EnvironmentId.make("environment-pages"), + requestNamespace: "provider-session-pages", + thread: { + threadId: ThreadId.make("thread-caller"), + providerSessionId: "provider-session-pages", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["t3.read"]), + issuedAt: 1, +}; + +const accessControl = OrchestrationAccessControl.of({ + actorFor: () => Option.some(actorUserId), + canAccessThread: () => Effect.succeed(true), + canAccessProject: () => Effect.succeed(true), + canTransferThreadOwnership: () => Effect.succeed(false), + canTransferProjectOwnership: () => Effect.succeed(false), +}); + +const ISSUE = "https://linear.app/beknown/issue/TEC-1818/compact"; + +function makeThread(index: number, updatedSecond: number) { + return { + id: ThreadId.make(`thread-${index}`), + projectId, + title: `Session ${index}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + latestTurn: null, + ownerUserId: actorUserId, + memberUserIds: [], + createdAt: "2026-10-28T00:00:00.000Z", + updatedAt: `2026-10-28T00:00:${String(updatedSecond).padStart(2, "0")}.000Z`, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + titleRegeneration: null, + priority: null, + customGroup: null, + linearIssueUrl: ISSUE, + linearLinks: [{ url: ISSUE, kind: "issue" }], + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +let threads = [1, 2, 3, 4, 5].map((index) => makeThread(index, index)); + +const query = { + getSnapshot: () => Effect.die("full projection snapshot materialized"), + getShellSnapshot: () => + Effect.sync(() => ({ + snapshotSequence: 1, + projects: [ + { + id: projectId, + title: "Project", + workspaceRoot: "/tmp/project", + repositoryIdentity: null, + defaultModelSelection: null, + threadCreationDefaults: { baseRef: null, setupScript: null }, + scripts: [], + ownerUserId: null, + memberUserIds: [], + createdAt: "2026-10-28T00:00:00.000Z", + updatedAt: "2026-10-28T00:00:00.000Z", + }, + ], + threads, + updatedAt: "2026-10-28T00:00:09.000Z", + })), + getArchivedShellSnapshot: () => Effect.die("archived shell should not be read"), +} as unknown as ProjectionSnapshotQuery["Service"]; + +const list = (input: Parameters[0]) => + __testing + .listSessions(input) + .pipe( + Effect.provideService(ProjectionSnapshotQuery, query), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(OrchestrationAccessControl, accessControl), + ); + +const ids = (result: { readonly sessions: ReadonlyArray<{ readonly sessionId: string }> }) => + result.sessions.map((session) => session.sessionId); + +it.effect("pages through every session once, newest change first", () => + Effect.gen(function* () { + threads = [1, 2, 3, 4, 5].map((index) => makeThread(index, index)); + const first = yield* list({ limit: 2 }); + expect(ids(first)).toEqual(["thread-5", "thread-4"]); + expect(typeof first.nextCursor).toBe("string"); + + const second = yield* list({ limit: 2, cursor: first.nextCursor ?? undefined }); + expect(ids(second)).toEqual(["thread-3", "thread-2"]); + + const third = yield* list({ limit: 2, cursor: second.nextCursor ?? undefined }); + expect(ids(third)).toEqual(["thread-1"]); + expect(third.nextCursor).toBeNull(); + }), +); + +it.effect("returns no cursor when everything fits, as callers that pass nothing expect", () => + Effect.gen(function* () { + threads = [1, 2, 3].map((index) => makeThread(index, index)); + const all = yield* list({}); + expect(ids(all)).toEqual(["thread-3", "thread-2", "thread-1"]); + expect(all.nextCursor).toBeNull(); + }), +); + +it.effect("resumes after the last session shown when a newer one arrives", () => + Effect.gen(function* () { + threads = [1, 2, 3, 4].map((index) => makeThread(index, index)); + const first = yield* list({ limit: 2 }); + expect(ids(first)).toEqual(["thread-4", "thread-3"]); + threads = [...threads, makeThread(9, 9)]; + const second = yield* list({ limit: 2, cursor: first.nextCursor ?? undefined }); + expect(ids(second)).toEqual(["thread-2", "thread-1"]); + }), +); + +it.effect("refuses a cursor it did not make", () => + Effect.gen(function* () { + const error = yield* list({ cursor: "not-a-cursor" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(T3ControlToolError); + expect(error.message).toContain("nextCursor"); + expect(__testing.decodeListSessionsCursor("not-a-cursor")).toBeNull(); + }), +); + +it.effect("counts Linear tags by default and lists them at detail full", () => + Effect.gen(function* () { + threads = [makeThread(1, 1)]; + const compact = yield* list({}); + const [session] = compact.sessions; + expect(session && "linearLinks" in session).toBe(false); + expect(session && "linearLinkCount" in session && session.linearLinkCount).toBe(1); + + const full = yield* list({ detail: "full" }); + const [detailed] = full.sessions; + const links = detailed !== undefined && "linearLinks" in detailed ? detailed.linearLinks : null; + expect(links).toEqual([{ url: ISSUE, kind: "issue" }]); + }), +); diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index 0a3a2d3af5837..65bf3970c121d 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -716,6 +716,56 @@ const moveGroupSessions = Effect.fn("T3ControlToolkit.moveGroupSessions")(functi }); // T3-CUSTOM(expbkt3): END +// T3-CUSTOM(expbkt3): t3_list_sessions paging (TEC-1818). The cursor is +// opaque to callers: base64url JSON of the next offset and the last session it +// returned. The next page starts after that session when it has not changed +// since, so a session that moved to the front does not shift the page; else +// it falls back to the offset. +interface ListSessionsCursor { + readonly offset: number; + readonly id: string; + readonly updatedAt: string; +} + +const encodeListSessionsCursor = (cursor: ListSessionsCursor): string => + Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); + +const decodeListSessionsCursor = (value: string): ListSessionsCursor | null => { + try { + const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (typeof parsed !== "object" || parsed === null) return null; + const { offset, id, updatedAt } = parsed as Record; + if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) return null; + if (typeof id !== "string" || typeof updatedAt !== "string") return null; + return { offset, id, updatedAt }; + } catch { + return null; + } +}; + +const listSessionsPageStart = ( + operation: string, + ordered: ReadonlyArray<{ readonly id: string; readonly updatedAt: string }>, + value: string | undefined, +): Effect.Effect => { + if (value === undefined || value.trim().length === 0) return Effect.succeed(0); + const cursor = decodeListSessionsCursor(value.trim()); + if (cursor === null) { + return Effect.fail( + new T3ControlToolError({ + operation, + message: + "cursor is not a nextCursor from t3_list_sessions. Call again without cursor to start from the first page.", + }), + ); + } + const anchor = ordered.findIndex((thread) => thread.id === cursor.id); + return Effect.succeed( + anchor >= 0 && ordered[anchor]?.updatedAt === cursor.updatedAt ? anchor + 1 : cursor.offset, + ); +}; +// T3-CUSTOM(expbkt3): END + function sessionSummary( thread: OrchestrationThreadShell, project: @@ -1070,11 +1120,18 @@ const handlers = { ) // T3-CUSTOM(expbkt3): custom sidebar group facet. .filter((thread) => matchesCustomGroupFilter(thread, input.customGroup)) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - .slice(0, boundedLimit(input.limit, 100, 500)); - const summaries = selected.map((thread) => - sessionSummary(thread, projects.get(thread.projectId)), - ); + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + // T3-CUSTOM(expbkt3): cursor paging and compact Linear tags (TEC-1818). + const start = yield* listSessionsPageStart(operation, selected, input.cursor); + const limit = boundedLimit(input.limit, 100, 500); + const page = selected.slice(start, start + limit); + const summaries = page.map((thread) => { + const summary = sessionSummary(thread, projects.get(thread.projectId)); + if (input.detail === "full") return summary; + const { linearLinks, ...rest } = summary; + return { ...rest, linearLinkCount: linearLinks.length }; + }); + const last = page.at(-1); return { totals: { returned: summaries.length, @@ -1082,7 +1139,16 @@ const handlers = { running: summaries.filter((session) => isRunningStatus(session.status)).length, }, sessions: summaries, + nextCursor: + last === undefined || start + page.length >= selected.length + ? null + : encodeListSessionsCursor({ + offset: start + page.length, + id: last.id, + updatedAt: last.updatedAt, + }), }; + // T3-CUSTOM(expbkt3): END }), t3_get_session: Effect.fn("T3ControlToolkit.getSession")(function* (input) { @@ -2131,6 +2197,8 @@ function resolveCreatedSessionDefaults(options: { export const __testing = { resolveSessionId, + // T3-CUSTOM(expbkt3): t3_list_sessions cursor (TEC-1818). + decodeListSessionsCursor, // T3-CUSTOM(expbkt3): saved new-thread defaults for created sessions. resolveCreatedSessionDefaults, // T3-CUSTOM(expbkt3): caller-seam regression for bounded session list reads. diff --git a/apps/server/src/mcp/toolkits/control/tools.ts b/apps/server/src/mcp/toolkits/control/tools.ts index 9eda6706fb26d..bc605de3a3557 100644 --- a/apps/server/src/mcp/toolkits/control/tools.ts +++ b/apps/server/src/mcp/toolkits/control/tools.ts @@ -141,7 +141,7 @@ const destructiveTool = (tool: T): T => export const T3ListSessionsTool = readonlyTool( Tool.make("t3_list_sessions", { description: - "List T3 Code sessions with project, model, mode, provider session status, human-attention reasons, and latest turn summary. User-bound agents see every session accessible to that user; external operators see all sessions. To see what waits on a person, prefer t3_inbox; to find a session by title, ticket or pull request, prefer t3_find_sessions.", + "List T3 Code sessions with project, model, mode, provider session status, human-attention reasons, and latest turn summary, newest change first. A result with more sessions than limit returns nextCursor; pass it as cursor for the next page. User-bound agents see every session accessible to that user; external operators see all sessions. To see what waits on a person, prefer t3_inbox; to find a session by title, ticket or pull request, prefer t3_find_sessions.", parameters: Schema.Struct({ includeArchived: Schema.optional( described( @@ -165,6 +165,19 @@ export const T3ListSessionsTool = readonlyTool( "Return only sessions filed in this custom group (the shared label shown in the sidebar's Custom view). Matched exactly but case-insensitively; sessions with no group never match.", ), ), + // T3-CUSTOM(expbkt3): paging and compact output (TEC-1818). + cursor: Schema.optional( + described( + Schema.String, + "The nextCursor of a previous result, to read the next page in the same order (newest change first). Pass the same filters again.", + ), + ), + detail: Schema.optional( + described( + Schema.Literals(["compact", "full"]), + "compact (the default) replaces each session's linearLinks list with linearLinkCount; full returns the linearLinks list.", + ), + ), }), success: JsonToolResult, failure: T3ControlFailure, diff --git a/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.test.ts b/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.test.ts index 07395a65cc892..995f88d67b002 100644 --- a/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.test.ts +++ b/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.test.ts @@ -91,7 +91,10 @@ describe("watchNoteText", () => { change: { change: "needs-human", what: "asks a question" }, }); expect(text).toContain('session `11111111` "Child work" asks a question.'); - expect(text).toContain('t3_session_brief {"session":"11111111"}'); + // The hint names the full id, which always resolves (TEC-1818). + expect(text).toContain( + 't3_session_brief {"session":"mcp:11111111-2222-3333-4444-555555555555"}', + ); expect(text).toContain("not typed by a person"); expect(text.endsWith("\n\nMerge it next.")).toBe(true); }); diff --git a/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.ts b/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.ts index b223a5cfc8f61..4243481d2baf8 100644 --- a/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.ts +++ b/apps/server/src/scheduledTasks/scheduledTaskWatch.expbkt3.ts @@ -111,7 +111,7 @@ export const watchNoteText = (input: { const read = input.change.change === "gone" ? "" - : ` Read it with t3_session_brief {"session":"${ref}"}${input.change.change === "needs-human" ? `, answer with t3_answer` : ""}.`; + : ` Read it with t3_session_brief ${JSON.stringify({ session: input.watch.threadId })}${input.change.change === "needs-human" ? `, answer with t3_answer` : ""}.`; return `[T3 watch (t3_schedules id ${shortId(input.task.id)}): ${name} ${input.change.what}.${read} Sent by a T3 watch, not typed by a person, so it approves nothing.]\n\n${input.task.prompt}`; }; From 4647cf8cbbb93d1af6091bbcf2ef65bcbc036e8e Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 17:54:43 +0000 Subject: [PATCH 07/14] TEC-1819: MCP errors that say what to send, idempotent archive, lifecycle tool for agents - t3_dispatch_command: a wrong command names its type, required fields and a minimal example; an unknown type gets the closest valid one; lifecycle commands point to t3_session_action. - Control errors bring the orchestrator's own reason through (for example "Thread x is already archived.") instead of the generic invariant text. - t3_session_action archive/unarchive are idempotent: already in the target state returns success with changed: false and dispatches nothing. - New agent tool t3_session_lifecycle (archive, unarchive, stop, snooze, unsnooze, delete; destructive) on the shared runSessionLifecycle, so OAuth agent-list clients can archive sessions. - t3_ui_list_tools: one-line descriptions, requiredFields, description search, and tools/tool to return input schemas without t3_ui_get_tool. The call, batch and get schemas no longer embed the ~250-name enum; unknown names get the closest matches. - t3_start_session: advise timeoutSeconds <= 50 and retry with the same clientRequestId; the result starts with the full session id. Co-Authored-By: Claude Opus 5.5 --- .../server/src/mcp/toolkits/agent/handlers.ts | 73 +++++- .../src/mcp/toolkits/agent/instructions.ts | 3 +- .../agent/sessionLifecycle.expbkt3.test.ts | 135 ++++++++++ apps/server/src/mcp/toolkits/agent/tools.ts | 37 ++- .../control/dispatchCommandHelp.test.ts | 73 ++++++ .../toolkits/control/dispatchCommandHelp.ts | 239 +++++++++++++++++ .../handlers.sessionLifecycle.expbkt3.test.ts | 133 ++++++++++ .../src/mcp/toolkits/control/handlers.ts | 243 ++++++++++++------ apps/server/src/mcp/toolkits/control/tools.ts | 6 +- apps/server/src/mcp/toolkits/webUi/catalog.ts | 114 ++++++++ .../registration.listTools.expbkt3.test.ts | 132 ++++++++++ .../src/mcp/toolkits/webUi/registration.ts | 91 ++++++- packages/shared/src/t3McpToolPresentation.ts | 4 + 13 files changed, 1187 insertions(+), 96 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts create mode 100644 apps/server/src/mcp/toolkits/control/dispatchCommandHelp.test.ts create mode 100644 apps/server/src/mcp/toolkits/control/dispatchCommandHelp.ts create mode 100644 apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts create mode 100644 apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts diff --git a/apps/server/src/mcp/toolkits/agent/handlers.ts b/apps/server/src/mcp/toolkits/agent/handlers.ts index 50db55d489cf9..4b8158e7d34b4 100644 --- a/apps/server/src/mcp/toolkits/agent/handlers.ts +++ b/apps/server/src/mcp/toolkits/agent/handlers.ts @@ -33,7 +33,7 @@ import { ProviderRegistry } from "../../../provider/ProviderRegistry.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { assertTargetWithinLimits, dispatchFailure, type Caller } from "../../threadAccess.ts"; -import { createSession } from "../control/handlers.ts"; +import { createSession, runSessionLifecycle } from "../control/handlers.ts"; import { buildAnswers } from "./answers.ts"; import { fail, @@ -83,6 +83,7 @@ import { type InboxTool, type SendMessageTool, type SessionBriefTool, + type SessionLifecycleTool, type StartSessionTool, type WaitTool, type WhoamiTool, @@ -1258,6 +1259,9 @@ const startSession = Effect.fn("agentToolkit.startSession")(function* ( const outcomeLines = shell === null || input.waitFor !== "first-reply" ? [] : yield* firstOutcome(caller, shell); const lines = [ + // TEC-1819: the full id comes first, so a client that cut the call short + // still has what it needs to find the session. + `Session id: ${newId}`, `## ${existing !== null ? "Already started" : "Started"}: \`${ref}\` ${shell?.title ?? input.title ?? oneLine(prompt, 80)}`, `- Project ${project.title} · ${created?.workspace === undefined ? (shell?.worktreePath ?? project.workspaceRoot) : `${created.workspace.mode}${"path" in created.workspace && created.workspace.path !== undefined ? ` at ${created.workspace.path}` : ""}`}`, ...(shell === null @@ -1266,7 +1270,6 @@ const startSession = Effect.fn("agentToolkit.startSession")(function* ( `- Model ${shell.modelSelection.model} · mode ${shell.interactionMode === "plan" ? "plan" : "build"} · ${sessionActivity(shell, context?.nowMs ?? nowMs)}`, ]), ...(context?.link === null || context === null ? [] : [`- Link: ${context.link}`]), - `- Id ${newId}`, ...(waitNote === null ? [] : ["", waitNote]), ...(outcomeLines.length === 0 ? [] : ["", ...outcomeLines]), "", @@ -1383,6 +1386,70 @@ const sendMessage = Effect.fn("agentToolkit.sendMessage")(function* ( ); }); +// TEC-1819: lifecycle actions for agents on the OAuth tool list, which does +// not include the control toolkit's t3_session_action. Same logic, one call. +const LIFECYCLE_DONE: Readonly> = { + archive: "Archived", + unarchive: "Unarchived", + stop: "Stopped", + snooze: "Snoozed", + unsnooze: "Unsnoozed", + delete: "Deleted", +}; + +const sessionLifecycle = Effect.fn("agentToolkit.sessionLifecycle")(function* ( + input: Tool.Parameters, +) { + const caller = yield* loadAgentCaller("t3.control"); + const nowMs = yield* Clock.currentTimeMillis; + // An exact id reads archived sessions too, so unarchive finds its target. + const shell = yield* resolveSession({ caller, ref: input.session, nowMs }); + const ref = shortRef(shell.id); + if (input.action === "snooze" && (input.snoozedUntil ?? "").trim().length === 0) { + return yield* fail( + "snoozed_until_required", + "Pass snoozedUntil (an ISO-8601 time, for example 2026-11-02T09:00:00.000Z) with action snooze.", + ); + } + yield* assertTargetWithinLimits(caller.limits, shell); + const commandId = yield* commandIdFor( + caller.scope, + `lifecycle-${input.action}`, + shell.id, + input.clientRequestId, + ); + const outcome = yield* runSessionLifecycle({ + operation: `session-${input.action}`, + sessionId: shell.id, + action: input.action, + snoozedUntil: input.snoozedUntil, + commandId, + }).pipe( + Effect.mapError((error) => fail("lifecycle_failed", error.message)), + withModeLimit(caller), + ); + const heading = outcome.changed + ? `## ${LIFECYCLE_DONE[input.action] ?? input.action}: \`${ref}\` ${shell.title}` + : `## Nothing to do: \`${ref}\` ${shell.title}`; + return result( + [ + `Session id: ${shell.id}`, + heading, + ...("message" in outcome ? [outcome.message] : []), + ...(input.action === "archive" + ? [`Undo: ${callHint("t3_session_lifecycle", { session: ref, action: "unarchive" })}.`] + : []), + ].join("\n"), + { + sessionId: shell.id, + ref, + action: input.action, + changed: outcome.changed, + ...("sequence" in outcome ? { sequence: outcome.sequence } : {}), + }, + ); +}); + const wait = Effect.fn("agentToolkit.wait")(function* (input: Tool.Parameters) { const caller = yield* loadAgentCaller("t3.read"); const nowMs = yield* Clock.currentTimeMillis; @@ -1517,6 +1584,7 @@ export const AgentToolkitHandlers = McpToolAccess.toLayer(AgentToolkit, { startSession, ), t3_send_message: McpToolAccess.writes(sendMessage), + t3_session_lifecycle: McpToolAccess.writes(sessionLifecycle), t3_wait: McpToolAccess.reads(wait), // Creating may start sessions on each run, at modes no broader than the caller's. t3_schedule: McpToolAccess.startsThreads( @@ -1535,5 +1603,6 @@ export const __testing = { brief, startSession, sendMessage, + sessionLifecycle, wait, }; diff --git a/apps/server/src/mcp/toolkits/agent/instructions.ts b/apps/server/src/mcp/toolkits/agent/instructions.ts index 0a230fa7e1c32..44cf13aa3312d 100644 --- a/apps/server/src/mcp/toolkits/agent/instructions.ts +++ b/apps/server/src/mcp/toolkits/agent/instructions.ts @@ -11,8 +11,9 @@ export const MCP_SERVER_INSTRUCTIONS = [ "- t3_start_session and t3_send_message: start work, or send to a session.", "- t3_wait: wait for the next question, approval, finish or failure with a cursor. Do not poll t3_inbox.", '- t3_schedule and t3_schedules: send a prompt later or on a clock (in "30m", every "2h", daily "09:00"), into your own session, another one, or a new session each run.', + "- t3_session_lifecycle: archive, unarchive, stop, snooze or delete a session. Archive is safe to retry; delete cannot be undone.", "- t3_notify_me: get one message in your session when another session waits on a person, finishes or fails. Do not tail transcripts or poll.", "Name a session by its id, the first 8 characters of its id, a T3 link, a Linear key, a pull request URL or its title. Inside a session, omit it to mean your own.", 'Results are short Markdown that ends with the next call; pass format: "json" for data. Pass clientRequestId on writes so a retry does not act twice.', - "For anything these do not cover, t3_ui_list_tools and t3_ui_call reach every action of the web app.", + "For anything these do not cover, t3_ui_list_tools (pass tools: [name] for its input schema) and t3_ui_call reach every action of the web app.", ].join("\n"); diff --git a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts new file mode 100644 index 0000000000000..c66df0462f696 --- /dev/null +++ b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts @@ -0,0 +1,135 @@ +// T3-CUSTOM(expbkt3): TEC-1819 — t3_session_lifecycle, the agent toolkit's +// archive/stop/snooze/delete tool for clients on the OAuth tool list, which +// does not include the control toolkit's t3_session_action. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId, type OrchestrationCommand } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Tool } from "effect/ai"; + +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; +import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import { __testing } from "./handlers.ts"; +import { AGENT_TOOL_NAMES, SessionLifecycleTool } from "./tools.ts"; + +const sessionId = ThreadId.make("thread-agent-lifecycle"); +const ARCHIVED_AT = "2026-10-28T10:00:00.000Z"; + +/** An approved OAuth client with no session of its own. */ +const scope: McpInvocationContext.McpInvocationScope = { + principal: "external-user", + actorUserId: null, + environmentId: EnvironmentId.make("environment-agent-lifecycle"), + requestNamespace: "external-user:agent-lifecycle", + thread: undefined, + client: { + sessionId: "external-user:agent-lifecycle", + label: "OAuth client", + access: "full-access", + }, + capabilities: new Set(["orchestration", "t3.read", "t3.control"]), + issuedAt: 1, +}; + +it("is in the agent tool list and marked destructive", () => { + expect(AGENT_TOOL_NAMES).toContain("t3_session_lifecycle"); + expect(Context.get(SessionLifecycleTool.annotations, Tool.Destructive)).toBe(true); + expect(Tool.getDescription(SessionLifecycleTool)).toContain("DESTRUCTIVE"); +}); + +it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { + let archivedAt: string | null = null; + const dispatched: OrchestrationCommand[] = []; + const threads = { + getThreadShell: (id: ThreadId) => + Effect.sync(() => + id === sessionId + ? { + ...liveThreadShell(id, { activeRunId: null }), + ...(archivedAt === null ? {} : { archivedAt: DateTime.makeUnsafe(archivedAt) }), + } + : null, + ), + } as unknown as ThreadManagement.ThreadManagementService["Service"]; + const query = { + getThreadShellById: (id: ThreadId) => + Effect.sync(() => (id === sessionId ? Option.some({ id, archivedAt }) : Option.none())), + } as unknown as ProjectionSnapshotQuery["Service"]; + const dispatcher = TurnStartBootstrap.of({ + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + if (command.type === "thread.archive") archivedAt = ARCHIVED_AT; + if (command.type === "thread.unarchive") archivedAt = null; + return { sequence: dispatched.length }; + }), + createThread: () => Effect.die("createThread should not be called"), + }); + const provide = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(ThreadManagement.ThreadManagementService, threads), + Effect.provideService(ProjectionSnapshotQuery, query), + Effect.provideService(TurnStartBootstrap, dispatcher), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + ); + const reset = () => { + archivedAt = null; + dispatched.length = 0; + }; + + it.effect("archives, and a retry is a success that dispatches nothing", () => + Effect.gen(function* () { + reset(); + const first = yield* __testing + .sessionLifecycle({ session: sessionId, action: "archive" }) + .pipe(provide); + expect(first.markdown.split("\n")[0]).toBe(`Session id: ${sessionId}`); + expect(first.data).toMatchObject({ changed: true, action: "archive" }); + expect(dispatched.map((command) => command.type)).toEqual(["thread.archive"]); + + const retry = yield* __testing + .sessionLifecycle({ session: sessionId, action: "archive" }) + .pipe(provide); + expect(retry.markdown).toContain("Nothing to do"); + expect(retry.markdown).toContain("already archived"); + expect(retry.data).toMatchObject({ changed: false }); + expect(dispatched).toHaveLength(1); + }), + ); + + it.effect("a retry with the same clientRequestId sends the same command id", () => + Effect.gen(function* () { + reset(); + for (let attempt = 0; attempt < 2; attempt += 1) { + yield* __testing + .sessionLifecycle({ session: sessionId, action: "stop", clientRequestId: "stop-1" }) + .pipe(provide); + } + expect(dispatched.map((command) => command.type)).toEqual([ + "thread.session.stop", + "thread.session.stop", + ]); + expect(dispatched[0]?.commandId).toBe(dispatched[1]?.commandId); + }), + ); + + it.effect("deletes, and asks for snoozedUntil before a snooze", () => + Effect.gen(function* () { + reset(); + yield* __testing.sessionLifecycle({ session: sessionId, action: "delete" }).pipe(provide); + expect(dispatched.map((command) => command.type)).toEqual(["thread.delete"]); + + const refused = yield* Effect.flip( + __testing.sessionLifecycle({ session: sessionId, action: "snooze" }).pipe(provide), + ); + expect(refused).toMatchObject({ code: "snoozed_until_required" }); + expect(dispatched).toHaveLength(1); + }), + ); +}); diff --git a/apps/server/src/mcp/toolkits/agent/tools.ts b/apps/server/src/mcp/toolkits/agent/tools.ts index 853a663e13f99..f6464ccc419ed 100644 --- a/apps/server/src/mcp/toolkits/agent/tools.ts +++ b/apps/server/src/mcp/toolkits/agent/tools.ts @@ -291,7 +291,7 @@ export const SessionBriefTool = readOnly( export const StartSessionTool = changing( Tool.make("t3_start_session", { description: - "Start a new session with a first message, in one call. Name the project by name, path or id, and the model by alias (opus, sonnet, haiku, codex, or any model name or id from the server's catalog). Omitted settings follow the project's saved new-session defaults. Set waitFor: first-reply to wait for the agent's first answer or question. With clientRequestId, a retry returns the same session.", + "Start a new session with a first message, in one call. Name the project by name, path or id, and the model by alias (opus, sonnet, haiku, codex, or any model name or id from the server's catalog). Omitted settings follow the project's saved new-session defaults. Set waitFor: first-reply to wait for the agent's first answer or question (keep timeoutSeconds at 50 or less). Always pass clientRequestId: if the call fails or times out, retry with the same id and it returns the same session. The result starts with the full session id.", parameters: Schema.Struct({ project: described(Schema.String, "The project: its name, path or id."), prompt: described(Schema.String, "The first message: the outcome you want and its limits."), @@ -339,7 +339,7 @@ export const StartSessionTool = changing( timeoutSeconds: Schema.optional( described( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 600 })), - "For waitFor: first-reply, the longest wait. Defaults to 50; most clients stop a call after 60 seconds.", + "For waitFor: first-reply, the longest wait. Defaults to 50. Keep it at 50 or less: most clients stop a call after 60 seconds, and the session may have started anyway. If your client reports a failure or timeout, call again with the same clientRequestId; it returns the session that started instead of a second one.", ), ), clientRequestId: Schema.optional( @@ -613,6 +613,38 @@ export const NotifyMeTool = changing( }).annotate(Tool.Title, "Notify me when a session changes"), ); +// Destructive: delete removes a session for good, so clients that run +// non-destructive tools without asking still ask (TEC-1819). +export const SessionLifecycleTool = destructive( + Tool.make("t3_session_lifecycle", { + description: + "Archive, unarchive, stop, snooze, unsnooze or delete a session. archive and unarchive are safe to retry: a session already in that state returns success with nothing changed. stop ends the session's provider process; snooze hides it until snoozedUntil. delete is DESTRUCTIVE and cannot be undone: it removes the session and its history; prefer archive. With clientRequestId, a retry does not act twice.", + parameters: Schema.Struct({ + session: sessionRef(SESSION_REF_HELP), + action: described( + Schema.Literals(["archive", "unarchive", "stop", "snooze", "unsnooze", "delete"]), + "archive, unarchive, stop, snooze (needs snoozedUntil), unsnooze, or delete (destructive, cannot be undone).", + ), + snoozedUntil: Schema.optional( + described( + Schema.String, + "For snooze: the ISO-8601 wake time, for example 2026-11-02T09:00:00.000Z.", + ), + ), + clientRequestId: Schema.optional( + described( + Schema.String, + "Your own id for this action, so a retry with the same id does not act twice.", + ), + ), + ...format, + }), + success: AgentToolResult, + failure: AgentToolFailure, + dependencies: [...dependencies, ProjectionSnapshotQuery, TurnStartBootstrap], + }).annotate(Tool.Title, "Archive, stop, snooze or delete a session"), +); + export const AgentToolkit = Toolkit.make( WhoamiTool, InboxTool, @@ -621,6 +653,7 @@ export const AgentToolkit = Toolkit.make( SessionBriefTool, StartSessionTool, SendMessageTool, + SessionLifecycleTool, WaitTool, ScheduleTool, SchedulesTool, diff --git a/apps/server/src/mcp/toolkits/control/dispatchCommandHelp.test.ts b/apps/server/src/mcp/toolkits/control/dispatchCommandHelp.test.ts new file mode 100644 index 0000000000000..b4a81603e12b4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/control/dispatchCommandHelp.test.ts @@ -0,0 +1,73 @@ +// T3-CUSTOM(expbkt3): TEC-1819 — a wrong t3_dispatch_command input gets an +// error that says exactly what to send. +import { describe, expect, it } from "@effect/vitest"; + +import { + closestNames, + commandShape, + describeCommandDecodeFailure, + orchestrationCommandMembers, +} from "./dispatchCommandHelp.ts"; + +const FALLBACK = "union decode failed"; + +describe("dispatchCommandHelp", () => { + it("knows the command types of both nested unions", () => { + const types = orchestrationCommandMembers(); + expect(types.has("thread.archive")).toBe(true); + expect(types.has("thread.unarchive")).toBe(true); + expect(types.has("project.create")).toBe(true); + expect(types.has("thread.turn.start")).toBe(true); + expect(types.size).toBeGreaterThan(30); + }); + + it("gives thread.archive's required fields and a minimal example", () => { + const shape = commandShape("thread.archive"); + expect(shape?.requiredFields).toEqual(["type", "commandId", "threadId"]); + expect(shape?.example).toEqual({ + type: "thread.archive", + commandId: "mcp:", + threadId: "", + }); + }); + + it("names the fields and t3_session_action for an archive without threadId", () => { + const message = describeCommandDecodeFailure( + { type: "thread.archive", commandId: "mcp:1" }, + FALLBACK, + ); + expect(message).toContain("Invalid thread.archive command."); + expect(message).toContain("Required fields: type, commandId, threadId."); + expect(message).toContain('"threadId":""'); + expect(message).toContain("Use t3_session_action action=archive instead"); + // The issue is about this one command, not a list of every type. + expect(message).not.toContain("Valid types:"); + }); + + it("suggests the closest type for an unknown one and lists the valid types once", () => { + const message = describeCommandDecodeFailure( + { type: "thread.archived", commandId: "mcp:1", threadId: "t" }, + FALLBACK, + ); + expect(message).toContain( + 'Unknown command type "thread.archived". Did you mean "thread.archive"?', + ); + expect(message).toContain("t3_session_action action=archive"); + expect(message.match(/Valid types:/g)).toHaveLength(1); + expect(message).toContain("thread.unarchive"); + }); + + it("asks for a type when it is missing, and for an object when the command is not one", () => { + expect(describeCommandDecodeFailure({ threadId: "t" }, FALLBACK)).toContain( + "command.type is missing", + ); + expect(describeCommandDecodeFailure("thread.archive", FALLBACK)).toContain( + "command must be a JSON object", + ); + }); + + it("ranks the closest names first", () => { + const names = ["thread.archive", "thread.unarchive", "project.create"]; + expect(closestNames("thread.unarchve", names)[0]).toBe("thread.unarchive"); + }); +}); diff --git a/apps/server/src/mcp/toolkits/control/dispatchCommandHelp.ts b/apps/server/src/mcp/toolkits/control/dispatchCommandHelp.ts new file mode 100644 index 0000000000000..c825dcafbe5f1 --- /dev/null +++ b/apps/server/src/mcp/toolkits/control/dispatchCommandHelp.ts @@ -0,0 +1,239 @@ +/** + * T3-CUSTOM(expbkt3): an error for a wrong t3_dispatch_command input that says + * exactly what to send (TEC-1819). + * + * Decoding against the whole OrchestrationCommand union gives an error that + * lists every literal `type` and hides what was wrong with the one the agent + * meant. This reads `command.type` first: a known type is decoded against its + * own member, and the error names that type, its required fields and a minimal + * example; an unknown type gets the closest valid one. Lifecycle commands also + * point to t3_session_action, which builds them for the agent. + */ +import { OrchestrationCommand as OrchestrationCommandSchema } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool } from "effect/ai"; + +type JsonObject = { readonly [key: string]: unknown }; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The t3_session_action that builds each lifecycle command. */ +const SESSION_ACTION_FOR_TYPE: Readonly> = { + "thread.archive": "archive", + "thread.unarchive": "unarchive", + "thread.delete": "delete", + "thread.session.stop": "stop", + "thread.session.restart": "restart", + "thread.turn.interrupt": "interrupt", + "thread.settle": "settle", + "thread.unsettle": "activate", + "thread.snooze": "snooze", + "thread.unsnooze": "unsnooze", +}; + +/** Walks nested unions to the struct members, keyed by their `type` literal. */ +const collectMembers = (schema: unknown, out: Map): void => { + if (schema === null || (typeof schema !== "object" && typeof schema !== "function")) return; + const members = (schema as { readonly members?: unknown }).members; + if (Array.isArray(members)) { + for (const member of members) collectMembers(member, out); + return; + } + const fields = (schema as { readonly fields?: unknown }).fields; + if (!isObject(fields)) return; + const typeField = fields.type as + | { readonly literal?: unknown; readonly literals?: unknown } + | undefined; + const literals = + typeof typeField?.literal === "string" + ? [typeField.literal] + : Array.isArray(typeField?.literals) + ? typeField.literals.filter((value): value is string => typeof value === "string") + : []; + for (const literal of literals) { + if (!out.has(literal)) out.set(literal, schema as Schema.Top); + } +}; + +let membersCache: ReadonlyMap | undefined; + +/** Every command `type` t3_dispatch_command accepts, with its own schema. */ +export const orchestrationCommandMembers = (): ReadonlyMap => { + if (membersCache === undefined) { + const out = new Map(); + collectMembers(OrchestrationCommandSchema, out); + membersCache = out; + } + return membersCache; +}; + +const levenshtein = (left: string, right: string): number => { + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let i = 1; i <= left.length; i += 1) { + const current = [i]; + for (let j = 1; j <= right.length; j += 1) { + current[j] = Math.min( + (current[j - 1] ?? 0) + 1, + (previous[j] ?? 0) + 1, + (previous[j - 1] ?? 0) + (left[i - 1] === right[j - 1] ? 0 : 1), + ); + } + previous = current; + } + return previous[right.length] ?? 0; +}; + +/** The valid names nearest to `input`, best first. */ +export const closestNames = ( + input: string, + names: Iterable, + count = 3, +): ReadonlyArray => { + const lower = input.toLowerCase(); + return [...names] + .map((name) => { + const candidate = name.toLowerCase(); + const contains = candidate.includes(lower) || lower.includes(candidate); + return { name, score: levenshtein(lower, candidate) - (contains ? 3 : 0) }; + }) + .toSorted((left, right) => left.score - right.score || left.name.localeCompare(right.name)) + .slice(0, count) + .map(({ name }) => name); +}; + +const resolveRef = (node: unknown, defs: JsonObject): JsonObject | undefined => { + if (!isObject(node)) return undefined; + const ref = node.$ref; + if (typeof ref === "string" && ref.startsWith("#/$defs/")) { + return resolveRef(defs[ref.slice("#/$defs/".length)], defs); + } + return node; +}; + +const exampleValue = (name: string, node: unknown, defs: JsonObject, depth = 0): unknown => { + if (name === "commandId") return "mcp:"; + if (name === "createdAt" || name.endsWith("At")) return ""; + const schema = resolveRef(node, defs); + if (schema === undefined || depth > 4) return `<${name}>`; + if ("const" in schema) return schema.const; + if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0]; + const branches = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : undefined; + if (branches !== undefined) { + const branch = branches.find((item) => resolveRef(item, defs)?.type !== "null"); + return exampleValue(name, branch, defs, depth + 1); + } + switch (schema.type) { + case "number": + case "integer": + return 0; + case "boolean": + return false; + case "array": + return []; + case "object": + return {}; + default: + return `<${name}>`; + } +}; + +interface CommandShape { + readonly type: string; + readonly requiredFields: ReadonlyArray; + readonly example: JsonObject; +} + +/** The required fields and a minimal example of one command type. */ +export const commandShape = (type: string): CommandShape | undefined => { + const member = orchestrationCommandMembers().get(type); + if (member === undefined) return undefined; + const root = Tool.getJsonSchemaFromSchema(member) as unknown; + const defs = isObject(root) && isObject(root.$defs) ? root.$defs : {}; + const schema = resolveRef(root, defs) ?? {}; + const properties = isObject(schema.properties) ? schema.properties : {}; + const required = Array.isArray(schema.required) + ? schema.required.filter((field): field is string => typeof field === "string") + : []; + const requiredFields = ["type", ...required.filter((field) => field !== "type")]; + const example: Record = { type }; + for (const field of requiredFields) { + if (field === "type") continue; + example[field] = exampleValue(field, properties[field], defs); + } + return { type, requiredFields, example }; +}; + +const sessionActionHint = (type: string): string | undefined => { + const action = SESSION_ACTION_FOR_TYPE[type]; + return action === undefined + ? undefined + : `Use t3_session_action action=${action} instead; it builds this command for you${action === "archive" || action === "unarchive" ? " and is safe to retry" : ""}.`; +}; + +const decodeIssue = (member: Schema.Top, command: unknown): string | undefined => { + const codec = member as unknown as Schema.Codec; + try { + Schema.decodeUnknownSync(codec)(command); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +}; + +const validTypesLine = (types: ReadonlyArray) => + `Valid types: ${types.toSorted().join(", ")}.`; + +/** + * The message for a t3_dispatch_command input that did not decode. + * `fallback` is the union decoder's own message, used only when the input + * names a known type that nonetheless decodes on its own. + */ +export const describeCommandDecodeFailure = (command: unknown, fallback: string): string => { + const members = orchestrationCommandMembers(); + const types = [...members.keys()]; + if (!isObject(command)) { + return [ + 'command must be a JSON object with a string `type`, for example {"type":"thread.archive","commandId":"mcp:","threadId":""}.', + "For archive, unarchive, delete, stop or snooze, use t3_session_action instead.", + validTypesLine(types), + ].join("\n"); + } + const type = command.type; + if (typeof type !== "string" || type.trim().length === 0) { + return [ + "command.type is missing. Set it to one command type.", + "For archive, unarchive, delete, stop or snooze, use t3_session_action instead.", + validTypesLine(types), + ].join("\n"); + } + const member = members.get(type); + if (member === undefined) { + const closest = closestNames(type, types); + const hint = closest[0] === undefined ? undefined : sessionActionHint(closest[0]); + return [ + `Unknown command type "${type}".${closest.length > 0 ? ` Did you mean "${closest[0]}"?` : ""}`, + ...(hint === undefined ? [] : [hint]), + validTypesLine(types), + ].join("\n"); + } + const shape = commandShape(type); + const issue = decodeIssue(member, command); + const hint = sessionActionHint(type); + return [ + `Invalid ${type} command.${issue === undefined ? ` ${fallback}` : ""}`, + ...(issue === undefined ? [] : [`Problem: ${issue}`]), + ...(shape === undefined + ? [] + : [ + `Required fields: ${shape.requiredFields.join(", ")}.`, + `Example: ${JSON.stringify(shape.example)}`, + "commandId is any new unique string you choose for this command.", + ]), + ...(hint === undefined ? [] : [hint]), + ].join("\n"); +}; diff --git a/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts new file mode 100644 index 0000000000000..63f46c4edd1b6 --- /dev/null +++ b/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts @@ -0,0 +1,133 @@ +// T3-CUSTOM(expbkt3): TEC-1819 — lifecycle actions are safe to retry, and an +// orchestrator refusal reaches the agent as its own reason, not the generic +// "The V2 command could not complete." text. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + OrchestrationDispatchCommandError, + ThreadId, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { OrchestrationCommandInvariantError } from "../../../orchestration-v2/Errors.ts"; +import { OrchestratorDispatchError } from "../../../orchestration-v2/Orchestrator.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; +import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; +import { rejectionReason, runSessionLifecycle } from "./handlers.ts"; + +const threadId = ThreadId.make("thread-lifecycle"); +const ARCHIVED_AT = "2026-10-28T10:00:00.000Z"; + +/** The refusal chain the V2 engine produces for a second archive. */ +const alreadyArchivedFailure = () => + new OrchestrationDispatchCommandError({ + message: + "Orchestration command invariant failed (thread.archive): The V2 command could not complete.", + cause: new OrchestrationCommandInvariantError({ + commandType: "thread.archive", + detail: "The V2 command could not complete.", + cause: new OrchestratorDispatchError({ + commandId: CommandId.make("mcp:session-archive:race"), + commandType: "thread.archive", + cause: `Thread ${threadId} is already archived.`, + }), + }), + }); + +it.layer(NodeServices.layer)("runSessionLifecycle", (it) => { + let archivedAt: string | null = null; + let shellReads = 0; + let failNextDispatch = false; + const dispatched: OrchestrationCommand[] = []; + const query = { + getThreadShellById: (id: ThreadId) => + Effect.sync(() => { + shellReads += 1; + return id === threadId ? Option.some({ id, archivedAt }) : Option.none(); + }), + } as unknown as ProjectionSnapshotQuery["Service"]; + const dispatcher = TurnStartBootstrap.of({ + dispatch: (command) => + failNextDispatch + ? Effect.fail(alreadyArchivedFailure()) + : Effect.sync(() => { + dispatched.push(command); + if (command.type === "thread.archive") archivedAt = ARCHIVED_AT; + if (command.type === "thread.unarchive") archivedAt = null; + return { sequence: dispatched.length }; + }), + createThread: () => Effect.die("createThread should not be called"), + }); + const provide = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(TurnStartBootstrap, dispatcher), + Effect.provideService(ProjectionSnapshotQuery, query), + ); + const reset = (state: { archivedAt: string | null }) => { + archivedAt = state.archivedAt; + shellReads = 0; + failNextDispatch = false; + dispatched.length = 0; + }; + const run = (action: "archive" | "unarchive" | "stop") => + runSessionLifecycle({ operation: `session-${action}`, sessionId: threadId, action }).pipe( + provide, + ); + + it.effect("archives an active session, then a retry succeeds without a dispatch", () => + Effect.gen(function* () { + reset({ archivedAt: null }); + const first = yield* run("archive"); + expect(first).toMatchObject({ accepted: true, changed: true, action: "archive" }); + expect(dispatched.map((command) => command.type)).toEqual(["thread.archive"]); + + const retry = yield* run("archive"); + expect(retry).toMatchObject({ accepted: true, changed: false, action: "archive" }); + expect("message" in retry ? retry.message : "").toContain("already archived"); + // The retry read one row and dispatched nothing. + expect(dispatched).toHaveLength(1); + expect(shellReads).toBe(2); + }), + ); + + it.effect("unarchive of an active session is a success with nothing dispatched", () => + Effect.gen(function* () { + reset({ archivedAt: null }); + const outcome = yield* run("unarchive"); + expect(outcome).toMatchObject({ accepted: true, changed: false }); + expect("message" in outcome ? outcome.message : "").toContain("already active"); + expect(dispatched).toHaveLength(0); + }), + ); + + it.effect("other actions dispatch without reading the session first", () => + Effect.gen(function* () { + reset({ archivedAt: null }); + const outcome = yield* run("stop"); + expect(outcome).toMatchObject({ changed: true }); + expect(dispatched.map((command) => command.type)).toEqual(["thread.session.stop"]); + expect(shellReads).toBe(0); + }), + ); + + it.effect("a refusal surfaces the orchestrator's own reason", () => + Effect.gen(function* () { + // The read saw it active, but another archive won the race. + reset({ archivedAt: null }); + failNextDispatch = true; + const error = yield* Effect.flip(run("archive")); + expect(error._tag).toBe("T3ControlToolError"); + expect(error.message).toBe(`Thread ${threadId} is already archived.`); + }), + ); +}); + +it("rejectionReason finds a string cause through nested errors, and nothing otherwise", () => { + expect(rejectionReason(alreadyArchivedFailure())).toBe(`Thread ${threadId} is already archived.`); + expect(rejectionReason(new Error("plain"))).toBeUndefined(); + expect(rejectionReason({ cause: " " })).toBeUndefined(); + expect(rejectionReason("text")).toBeUndefined(); +}); diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index 0a3a2d3af5837..33ab7c172c6a3 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -75,8 +75,14 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { resolveInteractionMode, resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import { hasUserWideScope, resolveMcpSessionTarget } from "../../mcpSessionTarget.ts"; +import { describeCommandDecodeFailure } from "./dispatchCommandHelp.ts"; import { boundSessionDetail } from "./sessionDetail.ts"; -import { T3ControlToolkit, T3ControlToolError, type T3CreateSessionTool } from "./tools.ts"; +import { + T3ControlToolkit, + T3ControlToolError, + type T3CreateSessionTool, + type T3SessionActionTool, +} from "./tools.ts"; import type { Tool } from "effect/ai"; import { recordLinearLinked } from "../../../telemetry/serverEvents.ts"; import { CurrentOrchestrationActorUserId } from "../../../orchestration-v2/forkActor.expbkt3.ts"; @@ -91,7 +97,29 @@ function boundedLimit(value: number | undefined, fallback: number, maximum: numb return Math.max(0, Math.min(value, maximum)); } +/** + * The reason an orchestrator refusal carries as a plain string `cause`, such + * as "Thread x is already archived.", found through the error's cause chain. + * The wrapping errors' own messages are generic ("The V2 command could not + * complete."), so this is the text that tells an agent what went wrong. + */ +export function rejectionReason(cause: unknown): string | undefined { + let current: unknown = cause; + for (let depth = 0; depth < 6; depth += 1) { + if (typeof current !== "object" || current === null || !("cause" in current)) return undefined; + const next: unknown = current.cause; + if (typeof next === "string") { + const trimmed = next.trim(); + return trimmed.length === 0 ? undefined : Array.from(trimmed).slice(0, 1000).join(""); + } + current = next; + } + return undefined; +} + function errorMessage(cause: unknown): string { + const reason = rejectionReason(cause); + if (reason !== undefined) return reason; if (cause instanceof Error && cause.message.trim().length > 0) return cause.message; if ( typeof cause === "object" && @@ -784,6 +812,129 @@ const makeMessageId = (crypto: Crypto.Crypto, operation: string) => mapControlError(operation), ); +type SessionLifecycleAction = Tool.Parameters["action"]; + +/** + * One lifecycle action on a session the caller already resolved and may + * change: the control toolkit's t3_session_action and the agent toolkit's + * t3_session_lifecycle both run it. Archive and unarchive are idempotent: a + * session already in the asked state is a success with `changed: false`, and + * nothing is dispatched, so a retried archive does not fail. A caller may pass + * its own `commandId` (a retry with the same id is then the same command). + */ +export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecycle")(function* ( + input: { + readonly operation: string; + readonly sessionId: ThreadId; + readonly action: SessionLifecycleAction; + readonly snoozedUntil?: string | undefined; + readonly commandId?: CommandId | undefined; + }, +) { + const { operation, sessionId } = input; + const query = yield* ProjectionSnapshotQuery; + const dispatcher = yield* TurnStartBootstrap; + const crypto = yield* Crypto.Crypto; + if (input.action === "archive" || input.action === "unarchive") { + // One indexed row read; the shell query includes archived threads. + const shell = yield* query.getThreadShellById(sessionId).pipe(mapControlError(operation)); + if (Option.isSome(shell)) { + const archived = shell.value.archivedAt !== null; + if (input.action === "archive" ? archived : !archived) { + return { + accepted: true, + changed: false, + action: input.action, + sessionId, + message: + input.action === "archive" + ? `Session ${sessionId} is already archived. Nothing to do.` + : `Session ${sessionId} is already active (not archived). Nothing to do.`, + }; + } + } + } + const commandId = input.commandId ?? (yield* makeCommandId(crypto, operation)); + const createdAt = yield* nowIso; + let command: OrchestrationCommand; + switch (input.action) { + case "interrupt": + command = { + type: "thread.turn.interrupt", + commandId, + threadId: sessionId, + createdAt, + }; + break; + case "stop": + command = { + type: "thread.session.stop", + commandId, + threadId: sessionId, + createdAt, + }; + break; + case "restart": + command = { + type: "thread.session.restart", + commandId, + threadId: sessionId, + createdAt, + }; + break; + case "archive": + command = { type: "thread.archive", commandId, threadId: sessionId }; + break; + case "unarchive": + command = { type: "thread.unarchive", commandId, threadId: sessionId }; + break; + case "settle": + command = { type: "thread.settle", commandId, threadId: sessionId }; + break; + case "activate": + command = { + type: "thread.unsettle", + commandId, + threadId: sessionId, + reason: "user", + }; + break; + case "snooze": + if (input.snoozedUntil === undefined) { + return yield* new T3ControlToolError({ + operation, + message: "snoozedUntil is required for the snooze action.", + }); + } + command = { + type: "thread.snooze", + commandId, + threadId: sessionId, + snoozedUntil: input.snoozedUntil, + }; + break; + case "unsnooze": + command = { + type: "thread.unsnooze", + commandId, + threadId: sessionId, + reason: "user", + }; + break; + case "delete": + command = { type: "thread.delete", commandId, threadId: sessionId }; + break; + } + const result = yield* dispatcher.dispatch(command).pipe(mapControlError(operation)); + return { + accepted: true, + changed: true, + action: input.action, + sessionId, + sequence: result.sequence, + }; +}); + /** * `started` is what McpToolAccess.startsThreads resolved: the modes the caller * asked for, or its own where it asked for none. A saved default it did not ask @@ -1325,81 +1476,12 @@ const handlers = { t3_session_action: Effect.fn("T3ControlToolkit.sessionAction")(function* (input) { const operation = `session-${input.action}`; const sessionId = yield* resolveSessionId(operation, input.sessionId, "t3.control"); - const query = yield* ProjectionSnapshotQuery; - const dispatcher = yield* TurnStartBootstrap; - const crypto = yield* Crypto.Crypto; - const [commandId, createdAt] = yield* Effect.all([makeCommandId(crypto, operation), nowIso]); - let command: OrchestrationCommand; - switch (input.action) { - case "interrupt": - command = { - type: "thread.turn.interrupt", - commandId, - threadId: sessionId, - createdAt, - }; - break; - case "stop": - command = { - type: "thread.session.stop", - commandId, - threadId: sessionId, - createdAt, - }; - break; - case "restart": - command = { - type: "thread.session.restart", - commandId, - threadId: sessionId, - createdAt, - }; - break; - case "archive": - command = { type: "thread.archive", commandId, threadId: sessionId }; - break; - case "unarchive": - command = { type: "thread.unarchive", commandId, threadId: sessionId }; - break; - case "settle": - command = { type: "thread.settle", commandId, threadId: sessionId }; - break; - case "activate": - command = { - type: "thread.unsettle", - commandId, - threadId: sessionId, - reason: "user", - }; - break; - case "snooze": - if (input.snoozedUntil === undefined) { - return yield* new T3ControlToolError({ - operation, - message: "snoozedUntil is required for the snooze action.", - }); - } - command = { - type: "thread.snooze", - commandId, - threadId: sessionId, - snoozedUntil: input.snoozedUntil, - }; - break; - case "unsnooze": - command = { - type: "thread.unsnooze", - commandId, - threadId: sessionId, - reason: "user", - }; - break; - case "delete": - command = { type: "thread.delete", commandId, threadId: sessionId }; - break; - } - const result = yield* dispatcher.dispatch(command).pipe(mapControlError(operation)); - return { accepted: true, action: input.action, sessionId, sequence: result.sequence }; + return yield* runSessionLifecycle({ + operation, + sessionId, + action: input.action, + snoozedUntil: input.snoozedUntil, + }); }), t3_respond_approval: Effect.fn("T3ControlToolkit.respondApproval")(function* (input) { @@ -2034,8 +2116,15 @@ const handlers = { const operation = "dispatch-command"; yield* requireExternalOperator(operation); const dispatcher = yield* TurnStartBootstrap; + // TEC-1819: a wrong command gets an error that says exactly what to send. const command = yield* decodeOrchestrationCommand(input.command).pipe( - mapControlError(operation), + Effect.mapError( + (cause) => + new T3ControlToolError({ + operation, + message: describeCommandDecodeFailure(input.command, errorMessage(cause)), + }), + ), ); const result = yield* dispatcher.dispatch(command).pipe(mapControlError(operation)); return { accepted: true, commandType: command.type, sequence: result.sequence }; diff --git a/apps/server/src/mcp/toolkits/control/tools.ts b/apps/server/src/mcp/toolkits/control/tools.ts index 9eda6706fb26d..c5562775b5b0b 100644 --- a/apps/server/src/mcp/toolkits/control/tools.ts +++ b/apps/server/src/mcp/toolkits/control/tools.ts @@ -311,7 +311,7 @@ export const T3UpdateSessionTool = mutatingTool( export const T3SessionActionTool = destructiveTool( Tool.make("t3_session_action", { description: - "Perform a lifecycle action on a T3 session: interrupt the active turn, stop/restart its provider, archive/unarchive, settle/activate, snooze/unsnooze, or delete.", + "Perform a lifecycle action on a T3 session: interrupt the active turn, stop/restart its provider, archive/unarchive, settle/activate, snooze/unsnooze, or delete. Archive and unarchive are idempotent: a session already in that state returns success with changed: false.", parameters: Schema.Struct({ ...optionalSessionId, action: described( @@ -566,7 +566,7 @@ export const T3CreateSessionTool = mutatingTool( export const T3DispatchCommandTool = destructiveTool( Tool.make("t3_dispatch_command", { description: - "External-operator escape hatch for advanced T3 automation. Dispatch any validated OrchestrationCommand through T3's durable command gate. Not available to an in-session agent; prefer the focused tools whenever possible.", + "External-operator escape hatch for advanced T3 automation. Dispatch any validated OrchestrationCommand through T3's durable command gate. Not available to an in-session agent; prefer the focused tools whenever possible. To archive, unarchive, delete, stop, restart, interrupt, settle, snooze or unsnooze a session, use t3_session_action instead: it builds the command and is safe to retry. For a web app action's exact input schema, use t3_ui_get_tool. A wrong command returns its required fields and an example.", parameters: Schema.Struct({ // Schema.Unknown drops a schema-level description in the generated JSON // schema, so annotate the key instead — the agent needs to be told what @@ -574,7 +574,7 @@ export const T3DispatchCommandTool = destructiveTool( command: Schema.Unknown.pipe( Schema.annotateKey({ description: - "A complete OrchestrationCommand object conforming to the current T3 contracts schema.", + 'A complete OrchestrationCommand object, for example {"type":"thread.archive","commandId":"mcp:","threadId":""}. commandId is a new unique string you choose.', }), ), }), diff --git a/apps/server/src/mcp/toolkits/webUi/catalog.ts b/apps/server/src/mcp/toolkits/webUi/catalog.ts index 89b6bf831a54b..c254bb33cb233 100644 --- a/apps/server/src/mcp/toolkits/webUi/catalog.ts +++ b/apps/server/src/mcp/toolkits/webUi/catalog.ts @@ -26,6 +26,8 @@ export interface WebUiVirtualTool { } export interface WebUiVirtualToolDetail extends WebUiVirtualTool { + // T3-CUSTOM(expbkt3): TEC-1819 — the payload's required fields. + readonly requiredFields: ReadonlyArray; readonly inputSchema: unknown; readonly successSchema: unknown; readonly errorSchema: unknown; @@ -115,12 +117,124 @@ export const getWebUiVirtualToolDetail = (name: string): WebUiVirtualToolDetail const errorSchema = isStream ? rpc.successSchema.error : rpc.errorSchema; return { ...tool, + ...webUiVirtualToolSummary(tool), inputSchema: Tool.getJsonSchemaFromSchema(rpc.payloadSchema), successSchema: Tool.getJsonSchemaFromSchema(successSchema), errorSchema: Tool.getJsonSchemaFromSchema(errorSchema), }; }; +// T3-CUSTOM(expbkt3): TEC-1819 — one-line descriptions and required fields so +// an agent can pick a virtual tool and fill it from t3_ui_list_tools alone. + +/** Plain descriptions of the actions agents reach for most. */ +const METHOD_DESCRIPTIONS: Readonly> = { + "orchestration.dispatchCommand": + "Dispatch one orchestration command. To archive, unarchive, stop, snooze or delete a session, use t3_session_lifecycle instead.", + "orchestration.launchThread": "Start a new session in a project.", + "orchestration.searchThreads": "Search sessions by text.", + "orchestration.searchThread": "Search the messages of one session.", + "orchestration.getArchivedShellSnapshot": "List archived sessions.", + "orchestration.getThreadProjection": "Read one session's full state.", + "orchestration.getTurnDiff": "Read the file changes of one turn.", + "orchestration.getFullThreadDiff": "Read all file changes of a session.", + "orchestration.stopWorkflow": "Stop a running workflow.", + "projects.list": "List projects.", + "projects.add": "Add a project from a folder.", + "projects.remove": "Remove a project.", + "projects.listEntries": "List files and folders in a project.", + "projects.readFile": "Read a file in a project.", + "projects.writeFile": "Write a file in a project.", + "projects.searchEntries": "Find files and folders in a project by name.", + "projects.searchContents": "Search the text of files in a project.", + "server.getConfig": "Read the server configuration: providers, models and keybindings.", + "server.getSettings": "Read the server settings.", + "server.updateSettings": "Change server settings with a partial patch.", + "scheduledTasks.list": "List scheduled tasks.", + "scheduledTasks.upsert": "Add or change a scheduled task.", + "scheduledTasks.delete": "Delete a scheduled task.", + "scheduledTasks.runNow": "Run a scheduled task now.", + "scheduledTasks.setEnabled": "Turn a scheduled task on or off.", + "pullRequests.list": "List pull requests.", + "pullRequests.detail": "Read one pull request.", + "pullRequests.checks": "Read the CI checks of a pull request.", + "pullRequests.comment": "Comment on a pull request.", + "vcs.createWorktree": "Make a git worktree.", + "vcs.removeWorktree": "Remove a git worktree.", + "vcs.listRefs": "List git branches.", + "vcs.pull": "Pull the current branch.", + "terminal.open": "Open a terminal in a session's workspace.", + "terminal.write": "Type into an open terminal.", + "terminal.close": "Close a terminal.", + "threadComments.list": "List review comments on a session's messages.", + "threadComments.add": "Add a review comment to a session's message.", + "users.list": "List T3 users.", + "linearIssues.resolve": "Look up a Linear issue by key or URL.", + "planReview.get": "Read a plan under review.", + "planReview.list": "List plans under review.", + "planReview.submit": "Submit a plan review decision.", + "preview.open": "Open a browser preview.", + "preview.list": "List open browser previews.", + "preview.close": "Close a browser preview.", +}; + +type JsonObject = { readonly [key: string]: unknown }; + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The payload's top-level object schema, through a `$ref` into `$defs`. */ +const payloadObjectSchema = (method: WebUiRpcMethod): JsonObject => { + const root = Tool.getJsonSchemaFromSchema(rpcWithProps(method).payloadSchema) as unknown; + if (!isJsonObject(root)) return {}; + const defs = isJsonObject(root.$defs) ? root.$defs : {}; + let node: JsonObject = root; + for (let depth = 0; depth < 4; depth += 1) { + const ref = node.$ref; + if (typeof ref !== "string") break; + const target = defs[ref.replace(/^#\/\$defs\//, "")]; + if (!isJsonObject(target)) break; + node = target; + } + return node; +}; + +interface WebUiVirtualToolSummary { + readonly description: string; + readonly requiredFields: ReadonlyArray; +} + +const summaries = new Map(); + +/** + * A one-line description (the payload schema's own description, else a + * hand-written one, else the generic text) and the payload's required fields. + * Computed on first use and cached: the JSON Schema is not free to build. + */ +export const webUiVirtualToolSummary = (tool: WebUiVirtualTool): WebUiVirtualToolSummary => { + const cached = summaries.get(tool.name); + if (cached) return cached; + let schema: JsonObject = {}; + try { + schema = payloadObjectSchema(tool.method); + } catch { + schema = {}; + } + const annotated = + typeof schema.description === "string" && schema.description.trim().length > 0 + ? schema.description.trim() + : undefined; + const summary: WebUiVirtualToolSummary = { + description: annotated ?? METHOD_DESCRIPTIONS[tool.method] ?? tool.description, + requiredFields: Array.isArray(schema.required) + ? schema.required.filter((field): field is string => typeof field === "string") + : [], + }; + summaries.set(tool.name, summary); + return summary; +}; +// T3-CUSTOM(expbkt3): END + export const isWebUiVirtualToolAuthorized = ( tool: WebUiVirtualTool, scopes: ReadonlyArray, diff --git a/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts b/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts new file mode 100644 index 0000000000000..35dddbbc1ed6c --- /dev/null +++ b/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts @@ -0,0 +1,132 @@ +// T3-CUSTOM(expbkt3): TEC-1819 — an agent on the OAuth tool list (which has +// t3_ui_list_tools and t3_ui_call, not t3_ui_get_tool) can pick a web action +// and fill its input from t3_ui_list_tools alone, and the call schemas stay +// small. +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, UserId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { liveThreadsLayer } from "../../McpToolAccess.testkit.ts"; +import { makeWebUiRpcHandlers, WebUiRpcToolkit } from "./registration.ts"; + +const invocation: McpInvocationContext.McpInvocationScope = { + principal: "external-user", + actorUserId: UserId.make("user-web-ui-list-tools-test"), + environmentId: EnvironmentId.make("environment-web-ui-list-tools-test"), + requestNamespace: "external-user:user-web-ui-list-tools-test", + thread: undefined, + client: { + sessionId: "external-user:user-web-ui-list-tools-test", + label: "External MCP user", + access: "full-access", + }, + capabilities: new Set(["orchestration", "t3.read", "t3.control"]), + issuedAt: 1, +}; + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "web-ui-list-tools-test", version: "1.0.0" }, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "web-ui-list-tools-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +const handlers = makeWebUiRpcHandlers(() => Effect.die("no virtual call runs in these tests")); +const TestLayer = McpHttpServer.toolkitRegistration(WebUiRpcToolkit, handlers).pipe( + Layer.provide(liveThreadsLayer), + Layer.provideMerge(McpServer.McpServer.layer), +); + +const withInvocation = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + +interface ListedTool { + readonly name: string; + readonly description: string; + readonly requiredFields: ReadonlyArray; + readonly inputSchema?: unknown; +} + +it.effect("lists compact entries with a description and required fields", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const listed = yield* withInvocation( + server.callTool({ name: "t3_ui_list_tools", arguments: { query: "read_file" } }), + ); + expect(listed.isError).toBe(false); + const tools = (listed.structuredContent as { tools: ReadonlyArray }).tools; + const readFile = tools.find((tool) => tool.name === "t3_ui_projects_read_file"); + expect(readFile?.description).toBe("Read a file in a project."); + expect(readFile?.requiredFields).toEqual(expect.arrayContaining(["cwd", "relativePath"])); + expect(readFile).not.toHaveProperty("inputSchema"); + for (const tool of tools) expect(Array.isArray(tool.requiredFields)).toBe(true); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("returns the input schema of named tools, so no t3_ui_get_tool call is needed", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const listed = yield* withInvocation( + server.callTool({ + name: "t3_ui_list_tools", + arguments: { tools: ["t3_ui_projects_read_file"] }, + }), + ); + expect(listed.isError).toBe(false); + const [tool] = (listed.structuredContent as { tools: ReadonlyArray }).tools; + expect(tool?.name).toBe("t3_ui_projects_read_file"); + expect(JSON.stringify(tool?.inputSchema)).toContain("relativePath"); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("refuses an unknown name with the closest real names", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const listed = yield* withInvocation( + server.callTool({ + name: "t3_ui_list_tools", + arguments: { tool: "t3_ui_projects_readfile" }, + }), + ); + expect(listed.isError).toBe(true); + expect(JSON.stringify(listed.content)).toContain("t3_ui_projects_read_file"); + + const call = yield* withInvocation( + server.callTool({ name: "t3_ui_call", arguments: { tool: "t3_ui_projects_readfile" } }), + ); + expect(call.isError).toBe(true); + expect(JSON.stringify(call.content)).toContain("Closest: t3_ui_projects_read_file"); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("keeps the call schemas free of the full virtual tool list", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + for (const { tool } of server.tools) { + const schema = JSON.stringify(tool.inputSchema); + expect(schema).not.toContain("t3_ui_projects_list_entries"); + expect(schema.length).toBeLessThan(4000); + } + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/toolkits/webUi/registration.ts b/apps/server/src/mcp/toolkits/webUi/registration.ts index 8499b7e97b7f4..63eccd8909f70 100644 --- a/apps/server/src/mcp/toolkits/webUi/registration.ts +++ b/apps/server/src/mcp/toolkits/webUi/registration.ts @@ -27,7 +27,9 @@ import { WEB_UI_STREAM_TOOL_COUNT, WEB_UI_VIRTUAL_TOOL_COUNT, WEB_UI_VIRTUAL_TOOLS, + webUiVirtualToolSummary, } from "./catalog.ts"; +import { closestNames } from "../control/dispatchCommandHelp.ts"; import { makeWebUiAuthenticatedSession } from "./session.ts"; export const WEB_UI_MCP_TOOL_NAMES = [ @@ -39,6 +41,21 @@ export const WEB_UI_MCP_TOOL_NAMES = [ const virtualToolNames = WEB_UI_VIRTUAL_TOOLS.map((tool) => tool.name); +// T3-CUSTOM(expbkt3): TEC-1819 — the input schemas name a virtual tool as a +// plain string, not an enum of all ~250 names, which made each schema ~17k +// characters. The handlers still validate the name and suggest the closest. +const VIRTUAL_TOOL_NAME_HELP = + "Virtual tool name from t3_ui_list_tools, for example t3_ui_server_get_config."; + +/** The refusal for a name that is not a virtual tool, with the nearest names. */ +const unknownVirtualToolMessage = (name: string) => { + const closest = closestNames(name, virtualToolNames); + return `Unknown virtual tool: ${name}.${closest.length > 0 ? ` Closest: ${closest.join(", ")}.` : ""} Call t3_ui_list_tools with query to search.`; +}; + +const MAX_SCHEMA_TOOLS = 5; +// T3-CUSTOM(expbkt3): END + const streamOptionsJsonSchema = { type: "object", additionalProperties: false, @@ -68,8 +85,7 @@ const virtualCallProperties = { id: { type: "string", description: "Optional caller correlation ID." }, tool: { type: "string", - enum: virtualToolNames, - description: "Virtual tool name from t3_ui_list_tools.", + description: VIRTUAL_TOOL_NAME_HELP, }, input: { description: "Input validated against the virtual tool's exact web RPC schema.", @@ -81,7 +97,20 @@ const listToolsInputSchema = { type: "object", additionalProperties: false, properties: { - query: { type: "string", description: "Case-insensitive name, method, or category filter." }, + query: { + type: "string", + description: "Case-insensitive filter on name, method, category or description.", + }, + tools: { + type: "array", + maxItems: MAX_SCHEMA_TOOLS, + items: { type: "string" }, + description: `Up to ${MAX_SCHEMA_TOOLS} virtual tool names; returns each with its full inputSchema, so you need no t3_ui_get_tool call.`, + }, + tool: { + type: "string", + description: "One virtual tool name; same as tools: [name].", + }, authorizedOnly: { type: "boolean", description: "Return only tools whose transport scope is present for this caller.", @@ -96,7 +125,7 @@ const getToolInputSchema = { additionalProperties: false, required: ["tool"], properties: { - tool: { type: "string", enum: virtualToolNames }, + tool: { type: "string", description: VIRTUAL_TOOL_NAME_HELP }, }, } as const; @@ -145,6 +174,8 @@ const VirtualCallSchema = Schema.Struct({ const ListToolsSchema = Schema.Struct({ query: Schema.optionalKey(Schema.String), + tools: Schema.optionalKey(Schema.Array(Schema.String)), + tool: Schema.optionalKey(Schema.String), authorizedOnly: Schema.optionalKey(Schema.Boolean), cursor: Schema.optionalKey(Schema.Number), limit: Schema.optionalKey(Schema.Number), @@ -236,7 +267,7 @@ export const WebUiRpcToolkit = Toolkit.make( webUiTool("t3_ui_list_tools", { title: "List authenticated web UI tools", description: - "List the complete virtual tool surface generated from bkt3's authenticated web UI RPC contract. Call this first for deep/code-mode control.", + "List the web app's actions as virtual tools: name, one-line description and required input fields. Filter with query. Pass tools: [name] (up to 5) to get each one's full inputSchema, then run it with t3_ui_call. For session archive, stop, snooze or delete, use t3_session_lifecycle instead.", inputSchema: listToolsInputSchema, readOnly: true, }), @@ -285,6 +316,39 @@ const webUiRpcHandlers = (execute: WebUiRpcExecutor) => ({ if (Result.isFailure(decoded)) return yield* invalidToolInput(decoded.failure.message); const invocation = yield* McpInvocationContext.McpInvocationContext; const scopes = makeWebUiAuthenticatedSession(invocation).scopes; + // T3-CUSTOM(expbkt3): TEC-1819 — named tools come back with their schemas. + const named = [ + ...(decoded.success.tool === undefined ? [] : [decoded.success.tool]), + ...(decoded.success.tools ?? []), + ] + .map((name) => name.trim()) + .filter((name, index, all) => name.length > 0 && all.indexOf(name) === index); + if (named.length > 0) { + if (named.length > MAX_SCHEMA_TOOLS) { + return yield* invalidToolInput( + `Ask for at most ${MAX_SCHEMA_TOOLS} tools at a time; you asked for ${named.length}.`, + ); + } + const details: Array>> = []; + for (const name of named) { + const detail = getWebUiVirtualToolDetail(name); + if (!detail) return yield* invalidToolInput(unknownVirtualToolMessage(name)); + details.push({ + name: detail.name, + description: detail.description, + requiredFields: detail.requiredFields, + readOnly: detail.readOnly, + ...(detail.stream ? { streamMode: detail.streamMode } : {}), + authorized: isWebUiVirtualToolAuthorized(detail, scopes), + inputSchema: detail.inputSchema, + }); + } + return yield* toolResult({ + ok: true, + tools: details, + next: "Call t3_ui_call with tool and an input that matches inputSchema.", + }); + } const query = decoded.success.query?.trim().toLowerCase() ?? ""; const filtered = WEB_UI_VIRTUAL_TOOLS.filter((tool) => { const authorized = isWebUiVirtualToolAuthorized(tool, scopes); @@ -293,13 +357,18 @@ const webUiRpcHandlers = (execute: WebUiRpcExecutor) => ({ query.length === 0 || tool.name.includes(query) || tool.method.toLowerCase().includes(query) || - tool.category.toLowerCase().includes(query) + tool.category.toLowerCase().includes(query) || + webUiVirtualToolSummary(tool).description.toLowerCase().includes(query) ); }); const cursor = normalizedOffset(decoded.success.cursor, 0, filtered.length); const limit = normalizedOffset(decoded.success.limit, 100, 100) || 1; + // Compact entries: what to pick a tool by and what its input needs. const page = filtered.slice(cursor, cursor + limit).map((tool) => ({ - ...tool, + name: tool.name, + ...webUiVirtualToolSummary(tool), + readOnly: tool.readOnly, + ...(tool.stream ? { streamMode: tool.streamMode } : {}), authorized: isWebUiVirtualToolAuthorized(tool, scopes), })); const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; @@ -311,7 +380,7 @@ const webUiRpcHandlers = (execute: WebUiRpcExecutor) => ({ cursor, nextCursor, tools: page, - note: "authorized reflects the transport scope only; each call still enforces the web UI's user, project, thread, and administrator checks.", + note: "authorized reflects the transport scope only; each call still enforces the web UI's user, project, thread, and administrator checks. Pass tools: [name] for a tool's full inputSchema.", }); }), ), @@ -321,7 +390,7 @@ const webUiRpcHandlers = (execute: WebUiRpcExecutor) => ({ if (Result.isFailure(decoded)) return yield* invalidToolInput(decoded.failure.message); const detail = getWebUiVirtualToolDetail(decoded.success.tool); if (!detail) { - return yield* invalidToolInput(`Unknown virtual tool: ${decoded.success.tool}`); + return yield* invalidToolInput(unknownVirtualToolMessage(decoded.success.tool)); } const invocation = yield* McpInvocationContext.McpInvocationContext; const scopes = makeWebUiAuthenticatedSession(invocation).scopes; @@ -340,7 +409,7 @@ const webUiRpcHandlers = (execute: WebUiRpcExecutor) => ({ if (Result.isFailure(decoded)) return yield* invalidToolInput(decoded.failure.message); const call = toCallRequest(decoded.success); if (!call) { - return yield* invalidToolInput(`Unknown virtual tool: ${decoded.success.tool}`); + return yield* invalidToolInput(unknownVirtualToolMessage(decoded.success.tool)); } const invocation = yield* McpInvocationContext.McpInvocationContext; const outcomes = yield* execute(invocation, [call], false); @@ -359,7 +428,7 @@ const webUiRpcHandlers = (execute: WebUiRpcExecutor) => ({ const calls: Array = []; for (const input of decoded.success.calls) { const call = toCallRequest(input); - if (!call) return yield* invalidToolInput(`Unknown virtual tool: ${input.tool}`); + if (!call) return yield* invalidToolInput(unknownVirtualToolMessage(input.tool)); calls.push(call); } const invocation = yield* McpInvocationContext.McpInvocationContext; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 18fbff3aca7e2..85578b07b6cc9 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -310,6 +310,10 @@ const T3_MCP_TOOLS: Readonly> = { t3_session_brief: tool(["Read", "Reading", "Read", "a session brief"], "thread-read"), t3_start_session: tool(["Start", "Starting", "Started", "a T3 session"], "thread-create"), t3_send_message: tool(["Send", "Sending", "Sent", "to a T3 session"], "thread-send"), + t3_session_lifecycle: tool( + ["Change", "Changing", "Changed", "a session's state"], + "thread-update", + ), t3_wait: tool(["Wait", "Waiting", "Waited", "for a session change"], "thread-wait"), t3_schedule: tool(["Schedule", "Scheduling", "Scheduled", "a prompt"], "schedule-create"), t3_schedules: tool(["List", "Listing", "Listed", "schedules"], "schedule-list"), From 63f4153d9cf40bcf81f1200683644615cf5f5e37 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 18:04:35 +0000 Subject: [PATCH 08/14] TEC-1819: fix test casts and formatting Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/control/handlers.ts | 16 +++++++++------- .../webUi/registration.listTools.expbkt3.test.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index 33ab7c172c6a3..dd4ae1699e7ad 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -814,6 +814,14 @@ const makeMessageId = (crypto: Crypto.Crypto, operation: string) => type SessionLifecycleAction = Tool.Parameters["action"]; +interface SessionLifecycleInput { + readonly operation: string; + readonly sessionId: ThreadId; + readonly action: SessionLifecycleAction; + readonly snoozedUntil?: string | undefined; + readonly commandId?: CommandId | undefined; +} + /** * One lifecycle action on a session the caller already resolved and may * change: the control toolkit's t3_session_action and the agent toolkit's @@ -823,13 +831,7 @@ type SessionLifecycleAction = Tool.Parameters["actio * its own `commandId` (a retry with the same id is then the same command). */ export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecycle")(function* ( - input: { - readonly operation: string; - readonly sessionId: ThreadId; - readonly action: SessionLifecycleAction; - readonly snoozedUntil?: string | undefined; - readonly commandId?: CommandId | undefined; - }, + input: SessionLifecycleInput, ) { const { operation, sessionId } = input; const query = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts b/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts index 35dddbbc1ed6c..4deb5d0efdb2c 100644 --- a/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/webUi/registration.listTools.expbkt3.test.ts @@ -53,6 +53,10 @@ const withInvocation = (effect: Effect.Effect) => Effect.provideService(McpSchema.McpServerClient, client), ); +interface ListedTools { + readonly tools: ReadonlyArray; +} + interface ListedTool { readonly name: string; readonly description: string; @@ -68,7 +72,7 @@ it.effect("lists compact entries with a description and required fields", () => server.callTool({ name: "t3_ui_list_tools", arguments: { query: "read_file" } }), ); expect(listed.isError).toBe(false); - const tools = (listed.structuredContent as { tools: ReadonlyArray }).tools; + const tools = (listed.structuredContent as unknown as ListedTools).tools; const readFile = tools.find((tool) => tool.name === "t3_ui_projects_read_file"); expect(readFile?.description).toBe("Read a file in a project."); expect(readFile?.requiredFields).toEqual(expect.arrayContaining(["cwd", "relativePath"])); @@ -89,7 +93,7 @@ it.effect("returns the input schema of named tools, so no t3_ui_get_tool call is }), ); expect(listed.isError).toBe(false); - const [tool] = (listed.structuredContent as { tools: ReadonlyArray }).tools; + const [tool] = (listed.structuredContent as unknown as ListedTools).tools; expect(tool?.name).toBe("t3_ui_projects_read_file"); expect(JSON.stringify(tool?.inputSchema)).toContain("relativePath"); }), From a9cc2d4e8a399a09d6bd309ccd7f819eb902da98 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 18:11:57 +0000 Subject: [PATCH 09/14] TEC-1818: strict keyset cursor for t3_list_sessions; t3_wait hints use resolved ids - t3_list_sessions orders newest change first with an id tie-breaker and pages strictly after the last session's (updatedAt, id) key. No offset fallback, so a deleted or moved session neither skips nor repeats others. - t3_wait timeout and matched-change hints name the resolved full session ids instead of copying the caller's references. - Tests: deleted anchor, moved anchor, equal timestamps; short refs in wait hints. customGroup test now expects id order for equal change times. Co-Authored-By: Claude Opus 5.5 --- .../mcp/toolkits/agent/compactOutput.test.ts | 16 +++++ .../server/src/mcp/toolkits/agent/handlers.ts | 8 ++- apps/server/src/mcp/toolkits/agent/render.ts | 8 +++ .../control/handlers.customGroup.test.ts | 3 +- .../handlers.listSessionsPaging.test.ts | 47 +++++++++++++- .../src/mcp/toolkits/control/handlers.ts | 61 ++++++++++--------- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts b/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts index 6fc8d4e2d053e..3b7bcc0d05b27 100644 --- a/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts +++ b/apps/server/src/mcp/toolkits/agent/compactOutput.test.ts @@ -12,6 +12,7 @@ import { sessionHeading, sessionTagsData, tagsOf, + waitAgainHint, } from "./render.ts"; import { matchSessionRef, shortRef, type SessionShell } from "./view.ts"; @@ -177,4 +178,19 @@ describe("next-call hints", () => { expect(text).toContain("1. A"); expect(text).toContain("2. B"); }); + + it("keeps t3_wait on the resolved full ids, not the short refs it was given", () => { + // The caller passed a short ref while only the first child existed. + const resolved = matchSessionRef(shortRef(child.id), [child]); + expect(resolved).toEqual([child]); + const ids = resolved.map((session) => session.id); + // The same hint follows a timeout and a matched change. + const hint = waitAgainHint(ids, "42"); + expect(hint).toBe(`\`t3_wait {"sessions":["${child.id}"],"cursor":"42"}\``); + expect(hint).not.toContain('"aaaaaaaa"'); + // A sibling appears later: the short ref is now ambiguous, the hint's id is not. + expect(matchSessionRef(shortRef(child.id), [child, sibling])).toHaveLength(2); + expect(matchSessionRef(child.id, [child, sibling])).toEqual([child]); + expect(waitAgainHint(null, "42")).toBe('`t3_wait {"cursor":"42"}`'); + }); }); diff --git a/apps/server/src/mcp/toolkits/agent/handlers.ts b/apps/server/src/mcp/toolkits/agent/handlers.ts index fa05fa34e0557..720ca648af923 100644 --- a/apps/server/src/mcp/toolkits/agent/handlers.ts +++ b/apps/server/src/mcp/toolkits/agent/handlers.ts @@ -73,6 +73,7 @@ import { sessionActivity, sessionHeading, sessionTagsData, + waitAgainHint, type InboxEntry, type SessionContext, type SessionDetail, @@ -1418,6 +1419,8 @@ const wait = Effect.fn("agentToolkit.wait")(function* (input: Tool.Parameters | null = null; + // The resolved full ids, for the hints that keep waiting (TEC-1818). + let waitIds: ReadonlyArray | null = null; if (input.sessions !== undefined && input.sessions.length > 0) { // One snapshot for every reference that is not an exact id. if (input.sessions.length > 1) { @@ -1428,6 +1431,7 @@ const wait = Effect.fn("agentToolkit.wait")(function* (input: Tool.Parameters shell.id)); + waitIds = [...threadIds]; } let after: number; if (input.cursor !== undefined) { @@ -1463,7 +1467,7 @@ const wait = Effect.fn("agentToolkit.wait")(function* (input: Tool.Parameters | null, cursor: string): string => + callHint("t3_wait", { ...(sessionIds === null ? {} : { sessions: sessionIds }), cursor }); + /** The bold header of one session: short id, title, project, tags, link. */ export const sessionHeading = (shell: SessionShell, context: SessionContext): string => [ diff --git a/apps/server/src/mcp/toolkits/control/handlers.customGroup.test.ts b/apps/server/src/mcp/toolkits/control/handlers.customGroup.test.ts index 534fc394c76c0..63eceb6680b0f 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.customGroup.test.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.customGroup.test.ts @@ -149,10 +149,11 @@ it("matches the list filter exactly but case-insensitively", () => { it.effect("reports each session's custom group and filters the list by it", () => Effect.gen(function* () { const all = yield* provideReads(__testing.listSessions({})); + // Equal change times sort by id (TEC-1818). expect(all.sessions.map((session) => [session.sessionId, session.customGroup])).toEqual([ - ["thread-sprint", "Sprint 42"], ["thread-loose", null], ["thread-other", "Bugs"], + ["thread-sprint", "Sprint 42"], ]); const filtered = yield* provideReads(__testing.listSessions({ customGroup: "sprint 42" })); diff --git a/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts b/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts index 4815b78def68e..c9d0c7a92eb47 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.listSessionsPaging.test.ts @@ -1,6 +1,7 @@ // T3-CUSTOM(expbkt3): TEC-1818. t3_list_sessions pages with an opaque cursor -// in its newest-change-first order, and drops each session's linearLinks list -// to a count unless detail is "full". +// in its newest-change-first order (ties by id), resumes strictly after the +// last session it returned, and drops each session's linearLinks list to a +// count unless detail is "full". import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, UserId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -40,7 +41,7 @@ const accessControl = OrchestrationAccessControl.of({ const ISSUE = "https://linear.app/beknown/issue/TEC-1818/compact"; -function makeThread(index: number, updatedSecond: number) { +function makeThread(index: number | string, updatedSecond: number) { return { id: ThreadId.make(`thread-${index}`), projectId, @@ -150,6 +151,46 @@ it.effect("resumes after the last session shown when a newer one arrives", () => }), ); +it.effect("skips nothing when the last session shown is deleted", () => + Effect.gen(function* () { + threads = [1, 2, 3, 4, 5].map((index) => makeThread(index, index)); + const first = yield* list({ limit: 2 }); + expect(ids(first)).toEqual(["thread-5", "thread-4"]); + threads = threads.filter((thread) => thread.id !== "thread-4"); + const second = yield* list({ limit: 2, cursor: first.nextCursor ?? undefined }); + expect(ids(second)).toEqual(["thread-3", "thread-2"]); + }), +); + +it.effect("neither repeats nor skips when the last session shown moves to the front", () => + Effect.gen(function* () { + threads = [1, 2, 3, 4, 5].map((index) => makeThread(index, index)); + const first = yield* list({ limit: 2 }); + expect(ids(first)).toEqual(["thread-5", "thread-4"]); + threads = threads.map((thread) => (thread.id === "thread-4" ? makeThread(4, 9) : thread)); + const second = yield* list({ limit: 2, cursor: first.nextCursor ?? undefined }); + expect(ids(second)).toEqual(["thread-3", "thread-2"]); + const third = yield* list({ limit: 2, cursor: second.nextCursor ?? undefined }); + expect(ids(third)).toEqual(["thread-1"]); + expect(third.nextCursor).toBeNull(); + }), +); + +it.effect("pages through equal change times by id", () => + Effect.gen(function* () { + threads = ["c", "a", "e", "b", "d"].map((key) => makeThread(key, 1)); + const seen: Array = []; + let cursor: string | undefined; + for (let round = 0; round < 5; round += 1) { + const page = yield* list({ limit: 2, cursor }); + seen.push(...ids(page)); + if (page.nextCursor === null) break; + cursor = page.nextCursor; + } + expect(seen).toEqual(["thread-a", "thread-b", "thread-c", "thread-d", "thread-e"]); + }), +); + it.effect("refuses a cursor it did not make", () => Effect.gen(function* () { const error = yield* list({ cursor: "not-a-cursor" }).pipe(Effect.flip); diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index 65bf3970c121d..43aac3c2b8e4d 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -716,39 +716,46 @@ const moveGroupSessions = Effect.fn("T3ControlToolkit.moveGroupSessions")(functi }); // T3-CUSTOM(expbkt3): END -// T3-CUSTOM(expbkt3): t3_list_sessions paging (TEC-1818). The cursor is -// opaque to callers: base64url JSON of the next offset and the last session it -// returned. The next page starts after that session when it has not changed -// since, so a session that moved to the front does not shift the page; else -// it falls back to the offset. +// T3-CUSTOM(expbkt3): t3_list_sessions paging (TEC-1818). Sessions are +// ordered newest change first, then by id, so the order is total. The cursor +// is opaque to callers: base64url JSON of the last session's sort key. The +// next page holds the sessions strictly after that key, so a deleted or moved +// session neither skips nor repeats the others. interface ListSessionsCursor { - readonly offset: number; - readonly id: string; readonly updatedAt: string; + readonly id: string; +} + +function compareListedSessions(left: ListSessionsCursor, right: ListSessionsCursor): number { + if (left.updatedAt !== right.updatedAt) return left.updatedAt > right.updatedAt ? -1 : 1; + if (left.id === right.id) return 0; + return left.id < right.id ? -1 : 1; } -const encodeListSessionsCursor = (cursor: ListSessionsCursor): string => - Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +function encodeListSessionsCursor(cursor: ListSessionsCursor): string { + const json = JSON.stringify({ updatedAt: cursor.updatedAt, id: cursor.id }); + return Buffer.from(json, "utf8").toString("base64url"); +} const decodeListSessionsCursor = (value: string): ListSessionsCursor | null => { try { const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); if (typeof parsed !== "object" || parsed === null) return null; - const { offset, id, updatedAt } = parsed as Record; - if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) return null; - if (typeof id !== "string" || typeof updatedAt !== "string") return null; - return { offset, id, updatedAt }; + const { updatedAt, id } = parsed as Record; + if (typeof updatedAt !== "string" || typeof id !== "string") return null; + return { updatedAt, id }; } catch { return null; } }; -const listSessionsPageStart = ( +/** The sessions after the cursor's key, in the list order. */ +const listSessionsAfterCursor = ( operation: string, - ordered: ReadonlyArray<{ readonly id: string; readonly updatedAt: string }>, + ordered: ReadonlyArray, value: string | undefined, -): Effect.Effect => { - if (value === undefined || value.trim().length === 0) return Effect.succeed(0); +): Effect.Effect, T3ControlToolError> => { + if (value === undefined || value.trim().length === 0) return Effect.succeed(ordered); const cursor = decodeListSessionsCursor(value.trim()); if (cursor === null) { return Effect.fail( @@ -759,10 +766,7 @@ const listSessionsPageStart = ( }), ); } - const anchor = ordered.findIndex((thread) => thread.id === cursor.id); - return Effect.succeed( - anchor >= 0 && ordered[anchor]?.updatedAt === cursor.updatedAt ? anchor + 1 : cursor.offset, - ); + return Effect.succeed(ordered.filter((thread) => compareListedSessions(thread, cursor) > 0)); }; // T3-CUSTOM(expbkt3): END @@ -1120,11 +1124,12 @@ const handlers = { ) // T3-CUSTOM(expbkt3): custom sidebar group facet. .filter((thread) => matchesCustomGroupFilter(thread, input.customGroup)) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + // T3-CUSTOM(expbkt3): ties broken by id, so cursor pages are stable (TEC-1818). + .sort(compareListedSessions); // T3-CUSTOM(expbkt3): cursor paging and compact Linear tags (TEC-1818). - const start = yield* listSessionsPageStart(operation, selected, input.cursor); + const remaining = yield* listSessionsAfterCursor(operation, selected, input.cursor); const limit = boundedLimit(input.limit, 100, 500); - const page = selected.slice(start, start + limit); + const page = remaining.slice(0, limit); const summaries = page.map((thread) => { const summary = sessionSummary(thread, projects.get(thread.projectId)); if (input.detail === "full") return summary; @@ -1140,13 +1145,9 @@ const handlers = { }, sessions: summaries, nextCursor: - last === undefined || start + page.length >= selected.length + last === undefined || page.length >= remaining.length ? null - : encodeListSessionsCursor({ - offset: start + page.length, - id: last.id, - updatedAt: last.updatedAt, - }), + : encodeListSessionsCursor(last), }; // T3-CUSTOM(expbkt3): END }), From b34d819c84d5af7a13a6adcac2221e092473cc66 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 18:22:51 +0000 Subject: [PATCH 10/14] TEC-1819: review round 1 - own-session policy, durable no-op, archive race - t3_session_lifecycle authorizes its target through resolveMcpSessionTarget (t3.control), the same policy as t3_session_action: a provider credential without user-wide scope acts only on its own session. - A no-op archive/unarchive with a caller's command id writes an accepted command receipt with sequence 0; a retry of that id replays the no-op, and a later dispatch of that id replays instead of acting. - An archive/unarchive that loses a race and is refused as already in the target state returns success with changed: false; other refusals still fail. Co-Authored-By: Claude Opus 5.5 --- .../server/src/mcp/toolkits/agent/handlers.ts | 6 + .../agent/sessionLifecycle.expbkt3.test.ts | 76 ++++++++-- .../handlers.sessionLifecycle.expbkt3.test.ts | 131 ++++++++++++++---- .../src/mcp/toolkits/control/handlers.ts | 90 ++++++++++-- 4 files changed, 254 insertions(+), 49 deletions(-) diff --git a/apps/server/src/mcp/toolkits/agent/handlers.ts b/apps/server/src/mcp/toolkits/agent/handlers.ts index 4b8158e7d34b4..e117e3be43b67 100644 --- a/apps/server/src/mcp/toolkits/agent/handlers.ts +++ b/apps/server/src/mcp/toolkits/agent/handlers.ts @@ -32,6 +32,7 @@ import { PlanReviewService } from "../../../planreview/PlanReviewService.ts"; import { ProviderRegistry } from "../../../provider/ProviderRegistry.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import { resolveMcpSessionTarget } from "../../mcpSessionTarget.ts"; import { assertTargetWithinLimits, dispatchFailure, type Caller } from "../../threadAccess.ts"; import { createSession, runSessionLifecycle } from "../control/handlers.ts"; import { buildAnswers } from "./answers.ts"; @@ -1411,6 +1412,11 @@ const sessionLifecycle = Effect.fn("agentToolkit.sessionLifecycle")(function* ( "Pass snoozedUntil (an ISO-8601 time, for example 2026-11-02T09:00:00.000Z) with action snooze.", ); } + // The same session policy as t3_session_action: an in-session credential + // without user-wide scope acts only on its own session. + yield* resolveMcpSessionTarget({ requested: shell.id, capability: "t3.control" }).pipe( + Effect.mapError((error) => fail(error.code ?? "session_not_allowed", error.message)), + ); yield* assertTargetWithinLimits(caller.limits, shell); const commandId = yield* commandIdFor( caller.scope, diff --git a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts index c66df0462f696..b3a329783ee7f 100644 --- a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts @@ -3,7 +3,12 @@ // does not include the control toolkit's t3_session_action. import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ThreadId, type OrchestrationCommand } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -11,6 +16,7 @@ import * as Option from "effect/Option"; import { Tool } from "effect/ai"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -19,6 +25,7 @@ import { __testing } from "./handlers.ts"; import { AGENT_TOOL_NAMES, SessionLifecycleTool } from "./tools.ts"; const sessionId = ThreadId.make("thread-agent-lifecycle"); +const ownSessionId = ThreadId.make("thread-agent-lifecycle-own"); const ARCHIVED_AT = "2026-10-28T10:00:00.000Z"; /** An approved OAuth client with no session of its own. */ @@ -37,6 +44,30 @@ const scope: McpInvocationContext.McpInvocationScope = { issuedAt: 1, }; +/** A provider session's credential: its own thread, no user-wide scope. */ +const providerScope: McpInvocationContext.McpInvocationScope = { + principal: "provider-session", + actorUserId: null, + environmentId: EnvironmentId.make("environment-agent-lifecycle"), + requestNamespace: "provider-session-agent-lifecycle", + thread: { + threadId: ownSessionId, + providerSessionId: "provider-session-agent-lifecycle", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration", "t3.read", "t3.control"]), + issuedAt: 1, +}; + +const accessControl = OrchestrationAccessControl.of({ + actorFor: () => Option.none(), + canAccessThread: () => Effect.succeed(true), + canAccessProject: () => Effect.succeed(true), + canTransferThreadOwnership: () => Effect.succeed(false), + canTransferProjectOwnership: () => Effect.succeed(false), +}); + it("is in the agent tool list and marked destructive", () => { expect(AGENT_TOOL_NAMES).toContain("t3_session_lifecycle"); expect(Context.get(SessionLifecycleTool.annotations, Tool.Destructive)).toBe(true); @@ -49,7 +80,7 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { const threads = { getThreadShell: (id: ThreadId) => Effect.sync(() => - id === sessionId + id === sessionId || id === ownSessionId ? { ...liveThreadShell(id, { activeRunId: null }), ...(archivedAt === null ? {} : { archivedAt: DateTime.makeUnsafe(archivedAt) }), @@ -59,7 +90,9 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { } as unknown as ThreadManagement.ThreadManagementService["Service"]; const query = { getThreadShellById: (id: ThreadId) => - Effect.sync(() => (id === sessionId ? Option.some({ id, archivedAt }) : Option.none())), + Effect.sync(() => + id === sessionId || id === ownSessionId ? Option.some({ id, archivedAt }) : Option.none(), + ), } as unknown as ProjectionSnapshotQuery["Service"]; const dispatcher = TurnStartBootstrap.of({ dispatch: (command) => @@ -71,13 +104,17 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { }), createThread: () => Effect.die("createThread should not be called"), }); - const provide = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(ThreadManagement.ThreadManagementService, threads), - Effect.provideService(ProjectionSnapshotQuery, query), - Effect.provideService(TurnStartBootstrap, dispatcher), - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - ); + const provideAs = + (caller: McpInvocationContext.McpInvocationScope) => + (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(ThreadManagement.ThreadManagementService, threads), + Effect.provideService(ProjectionSnapshotQuery, query), + Effect.provideService(TurnStartBootstrap, dispatcher), + Effect.provideService(OrchestrationAccessControl, accessControl), + Effect.provideService(McpInvocationContext.McpInvocationContext, caller), + ); + const provide = provideAs(scope); const reset = () => { archivedAt = null; dispatched.length = 0; @@ -132,4 +169,23 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { expect(dispatched).toHaveLength(1); }), ); + + it.effect("a provider credential acts on its own session only, like t3_session_action", () => + Effect.gen(function* () { + reset(); + const refused = yield* Effect.flip( + __testing + .sessionLifecycle({ session: sessionId, action: "delete" }) + .pipe(provideAs(providerScope)), + ); + expect(refused).toMatchObject({ code: "own_session_only" }); + expect(dispatched).toHaveLength(0); + + const own = yield* __testing + .sessionLifecycle({ session: "me", action: "archive" }) + .pipe(provideAs(providerScope)); + expect(own.data).toMatchObject({ sessionId: ownSessionId, changed: true }); + expect(dispatched.map((command) => command.threadId)).toEqual([ownSessionId]); + }), + ); }); diff --git a/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts index 63f46c4edd1b6..4e5458458df60 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts @@ -12,6 +12,7 @@ import { import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as CommandReceiptStore from "../../../orchestration-v2/CommandReceiptStore.ts"; import { OrchestrationCommandInvariantError } from "../../../orchestration-v2/Errors.ts"; import { OrchestratorDispatchError } from "../../../orchestration-v2/Orchestrator.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; @@ -21,61 +22,92 @@ import { rejectionReason, runSessionLifecycle } from "./handlers.ts"; const threadId = ThreadId.make("thread-lifecycle"); const ARCHIVED_AT = "2026-10-28T10:00:00.000Z"; -/** The refusal chain the V2 engine produces for a second archive. */ -const alreadyArchivedFailure = () => +/** The refusal chain the V2 engine produces, with the orchestrator's reason. */ +const refusal = (commandType: string, reason: string) => new OrchestrationDispatchCommandError({ - message: - "Orchestration command invariant failed (thread.archive): The V2 command could not complete.", + message: `Orchestration command invariant failed (${commandType}): The V2 command could not complete.`, cause: new OrchestrationCommandInvariantError({ - commandType: "thread.archive", + commandType, detail: "The V2 command could not complete.", cause: new OrchestratorDispatchError({ - commandId: CommandId.make("mcp:session-archive:race"), - commandType: "thread.archive", - cause: `Thread ${threadId} is already archived.`, + commandId: CommandId.make("mcp:session-lifecycle:refused"), + commandType, + cause: reason, }), }), }); +type Action = "archive" | "unarchive" | "stop"; + it.layer(NodeServices.layer)("runSessionLifecycle", (it) => { let archivedAt: string | null = null; let shellReads = 0; - let failNextDispatch = false; + // A read that lags the writes, as when two calls race past the pre-check. + let staleView: { readonly archivedAt: string | null } | undefined; + let failWith: OrchestrationDispatchCommandError | undefined; const dispatched: OrchestrationCommand[] = []; + const receipts = new Map(); const query = { getThreadShellById: (id: ThreadId) => Effect.sync(() => { shellReads += 1; - return id === threadId ? Option.some({ id, archivedAt }) : Option.none(); + return id === threadId + ? Option.some({ id, archivedAt: (staleView ?? { archivedAt }).archivedAt }) + : Option.none(); }), } as unknown as ProjectionSnapshotQuery["Service"]; + // Behaves like the V2 orchestrator: a known command id replays its receipt, + // and archive/unarchive refuse a session already in the target state. const dispatcher = TurnStartBootstrap.of({ dispatch: (command) => - failNextDispatch - ? Effect.fail(alreadyArchivedFailure()) - : Effect.sync(() => { - dispatched.push(command); - if (command.type === "thread.archive") archivedAt = ARCHIVED_AT; - if (command.type === "thread.unarchive") archivedAt = null; - return { sequence: dispatched.length }; - }), + Effect.suspend(() => { + const receipt = receipts.get(command.commandId); + if (receipt !== undefined) return Effect.succeed({ sequence: receipt.resultSequence }); + if (failWith !== undefined) return Effect.fail(failWith); + if (command.type === "thread.archive" && archivedAt !== null) { + return Effect.fail(refusal(command.type, `Thread ${threadId} is already archived.`)); + } + if (command.type === "thread.unarchive" && archivedAt === null) { + return Effect.fail(refusal(command.type, `Thread ${threadId} is not archived.`)); + } + dispatched.push(command); + if (command.type === "thread.archive") archivedAt = ARCHIVED_AT; + if (command.type === "thread.unarchive") archivedAt = null; + return Effect.succeed({ sequence: dispatched.length }); + }), createThread: () => Effect.die("createThread should not be called"), }); + const receiptStore = { + insertIfAbsent: (receipt: CommandReceiptStore.CommandReceiptV2) => + Effect.sync(() => { + if (receipts.has(receipt.commandId)) return false; + receipts.set(receipt.commandId, receipt); + return true; + }), + getByCommandId: (commandId: CommandId) => + Effect.sync(() => Option.fromNullishOr(receipts.get(commandId))), + } as unknown as CommandReceiptStore.CommandReceiptStoreV2["Service"]; const provide = (effect: Effect.Effect) => effect.pipe( Effect.provideService(TurnStartBootstrap, dispatcher), Effect.provideService(ProjectionSnapshotQuery, query), + Effect.provideService(CommandReceiptStore.CommandReceiptStoreV2, receiptStore), ); const reset = (state: { archivedAt: string | null }) => { archivedAt = state.archivedAt; shellReads = 0; - failNextDispatch = false; + staleView = undefined; + failWith = undefined; dispatched.length = 0; + receipts.clear(); }; - const run = (action: "archive" | "unarchive" | "stop") => - runSessionLifecycle({ operation: `session-${action}`, sessionId: threadId, action }).pipe( - provide, - ); + const run = (action: Action, commandId?: string) => + runSessionLifecycle({ + operation: `session-${action}`, + sessionId: threadId, + action, + commandId: commandId === undefined ? undefined : CommandId.make(commandId), + }).pipe(provide); it.effect("archives an active session, then a retry succeeds without a dispatch", () => Effect.gen(function* () { @@ -113,20 +145,63 @@ it.layer(NodeServices.layer)("runSessionLifecycle", (it) => { }), ); - it.effect("a refusal surfaces the orchestrator's own reason", () => + it.effect("a retried no-op command id replays its outcome after the state changed", () => + Effect.gen(function* () { + reset({ archivedAt: ARCHIVED_AT }); + const first = yield* run("archive", "mcp:agent:archive:x"); + expect(first).toMatchObject({ changed: false }); + expect(receipts.get("mcp:agent:archive:x")).toMatchObject({ + status: "accepted", + resultSequence: 0, + }); + + // Someone unarchives the session between the call and its retry. + archivedAt = null; + const retry = yield* run("archive", "mcp:agent:archive:x"); + expect(retry).toMatchObject({ changed: false }); + expect(archivedAt).toBeNull(); + expect(dispatched).toHaveLength(0); + }), + ); + + it.effect("two archives that race past the read both succeed", () => Effect.gen(function* () { - // The read saw it active, but another archive won the race. reset({ archivedAt: null }); - failNextDispatch = true; + staleView = { archivedAt: null }; + const outcomes = yield* Effect.all([run("archive"), run("archive")], { + concurrency: "unbounded", + }); + expect(outcomes.map((outcome) => outcome.changed).toSorted()).toEqual([false, true]); + expect(dispatched.map((command) => command.type)).toEqual(["thread.archive"]); + + // Unarchive has the same race. + staleView = { archivedAt: ARCHIVED_AT }; + const back = yield* Effect.all([run("unarchive"), run("unarchive")], { + concurrency: "unbounded", + }); + expect(back.map((outcome) => outcome.changed).toSorted()).toEqual([false, true]); + expect(dispatched.map((command) => command.type)).toEqual([ + "thread.archive", + "thread.unarchive", + ]); + }), + ); + + it.effect("an unrelated refusal stays a failure with the orchestrator's own reason", () => + Effect.gen(function* () { + reset({ archivedAt: null }); + failWith = refusal("thread.archive", `Thread ${threadId} is no longer empty.`); const error = yield* Effect.flip(run("archive")); expect(error._tag).toBe("T3ControlToolError"); - expect(error.message).toBe(`Thread ${threadId} is already archived.`); + expect(error.message).toBe(`Thread ${threadId} is no longer empty.`); }), ); }); it("rejectionReason finds a string cause through nested errors, and nothing otherwise", () => { - expect(rejectionReason(alreadyArchivedFailure())).toBe(`Thread ${threadId} is already archived.`); + expect(rejectionReason(refusal("thread.archive", "Thread t is already archived."))).toBe( + "Thread t is already archived.", + ); expect(rejectionReason(new Error("plain"))).toBeUndefined(); expect(rejectionReason({ cause: " " })).toBeUndefined(); expect(rejectionReason("text")).toBeUndefined(); diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index dd4ae1699e7ad..65d30b3221076 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -58,6 +58,7 @@ import { ClerkDirectory } from "../../../auth/ClerkDirectory.ts"; import { ServerConfig } from "../../../config.ts"; import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; +import * as CommandReceiptStore from "../../../orchestration-v2/CommandReceiptStore.ts"; import { resolveParentWorkspaceInheritance } from "../../../workspace-groups/parentWorkspaceInheritance.ts"; import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; @@ -830,6 +831,53 @@ interface SessionLifecycleInput { * nothing is dispatched, so a retried archive does not fail. A caller may pass * its own `commandId` (a retry with the same id is then the same command). */ +/** The orchestrator's refusal for an archive or unarchive already in its target state. */ +const isAlreadyInStateRefusal = (action: SessionLifecycleAction, reason: string | undefined) => + reason !== undefined && + ((action === "archive" && / is already archived\.$/.test(reason)) || + (action === "unarchive" && / is not archived\.$/.test(reason))); + +const NO_OP_RESULT_SEQUENCE = 0; + +/** + * The success for an archive or unarchive that found the session already in + * its target state. With a caller's command id it is written as an accepted + * command receipt with sequence 0, so a retry with that id replays this + * outcome, and a later dispatch of that id replays instead of acting, even + * after the session's state changed in between. + */ +const noOpLifecycle = Effect.fn("T3ControlToolkit.noOpLifecycle")(function* ( + input: SessionLifecycleInput, +) { + if (input.commandId !== undefined) { + const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); + if (Option.isSome(receipts)) { + const acceptedAt = yield* DateTime.now; + yield* receipts.value + .insertIfAbsent({ + commandId: input.commandId, + threadId: input.sessionId, + commandType: input.action === "archive" ? "thread.archive" : "thread.unarchive", + acceptedAt, + resultSequence: NO_OP_RESULT_SEQUENCE, + status: "accepted", + error: null, + }) + .pipe(mapControlError(input.operation)); + } + } + return { + accepted: true, + changed: false, + action: input.action, + sessionId: input.sessionId, + message: + input.action === "archive" + ? `Session ${input.sessionId} is already archived. Nothing to do.` + : `Session ${input.sessionId} is already active (not archived). Nothing to do.`, + }; +}); + export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecycle")(function* ( input: SessionLifecycleInput, ) { @@ -838,21 +886,29 @@ export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecyc const dispatcher = yield* TurnStartBootstrap; const crypto = yield* Crypto.Crypto; if (input.action === "archive" || input.action === "unarchive") { + // A retry of a caller's command id replays its first no-op outcome rather + // than acting on a state that changed since. + if (input.commandId !== undefined) { + const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); + const receipt = Option.isSome(receipts) + ? yield* receipts.value.getByCommandId(input.commandId).pipe(mapControlError(operation)) + : Option.none(); + // Only a no-op writes sequence 0; a dispatched command has a later one, + // and a refused one replays as refused through the dispatch below. + if ( + Option.isSome(receipt) && + receipt.value.status === "accepted" && + receipt.value.resultSequence === NO_OP_RESULT_SEQUENCE + ) { + return yield* noOpLifecycle(input); + } + } // One indexed row read; the shell query includes archived threads. const shell = yield* query.getThreadShellById(sessionId).pipe(mapControlError(operation)); if (Option.isSome(shell)) { const archived = shell.value.archivedAt !== null; if (input.action === "archive" ? archived : !archived) { - return { - accepted: true, - changed: false, - action: input.action, - sessionId, - message: - input.action === "archive" - ? `Session ${sessionId} is already archived. Nothing to do.` - : `Session ${sessionId} is already active (not archived). Nothing to do.`, - }; + return yield* noOpLifecycle(input); } } } @@ -927,7 +983,19 @@ export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecyc command = { type: "thread.delete", commandId, threadId: sessionId }; break; } - const result = yield* dispatcher.dispatch(command).pipe(mapControlError(operation)); + const dispatched = yield* dispatcher.dispatch(command).pipe( + Effect.map((result) => ({ sequence: result.sequence })), + // Two archives can both pass the read above; the one that loses the race + // is refused as already archived, which is the state it asked for. + Effect.catch((error) => + isAlreadyInStateRefusal(input.action, rejectionReason(error)) + ? Effect.succeed(undefined) + : Effect.fail(error), + ), + mapControlError(operation), + ); + if (dispatched === undefined) return yield* noOpLifecycle(input); + const result = dispatched; return { accepted: true, changed: true, From 6c6785abf564e52fdb6efd148c7b244c4ccbe059 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Wed, 28 Oct 2026 18:30:56 +0000 Subject: [PATCH 11/14] TEC-1819: fix test type narrowing Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts index b3a329783ee7f..cd83ed1de8b40 100644 --- a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts @@ -185,7 +185,7 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { .sessionLifecycle({ session: "me", action: "archive" }) .pipe(provideAs(providerScope)); expect(own.data).toMatchObject({ sessionId: ownSessionId, changed: true }); - expect(dispatched.map((command) => command.threadId)).toEqual([ownSessionId]); + expect(dispatched).toMatchObject([{ type: "thread.archive", threadId: ownSessionId }]); }), ); }); From 18b6876ad9dba280002d64103c5d12d8a8882484 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Thu, 29 Oct 2026 06:17:45 +0000 Subject: [PATCH 12/14] fix(server): storage cleanup treats newer git's broken-gitfile error like a missing repository Newer git (on the GitHub runners since 2026-10-28) prints "fatal: gitfile does not point to a valid repository" where older git printed "not a git repository". The branch check then reported the worktree as failed instead of kept, and storageCleanup.test.ts "compares the checked-out branch without a full status" failed on every stage branch. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a196321e38098..44ed4ca292b92 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -515,6 +515,9 @@ const make = Effect.gen(function* () { if (result === null) return null; if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; if (result.stderr.toLowerCase().includes("not a git repository")) return null; + // T3-CUSTOM(expbkt3): newer git reports a broken .git file this way instead. + if (result.stderr.toLowerCase().includes("gitfile does not point to a valid repository")) + return null; return yield* failure( result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", ); From 9ee59f7b5d28732ad589308886b9b2b3361e439d Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Thu, 29 Oct 2026 06:22:16 +0000 Subject: [PATCH 13/14] TEC-1819: review round 2 - production receipt store, durable race no-op - The lifecycle paths now require CommandReceiptStoreV2. The control and agent toolkit registrations provide CommandReceiptStore .layerFromApplicationReceipts over the receipt repository the server already exposes, so no-op receipts persist in production. - When this command loses a race to the state it asked for, the rejected receipt the orchestrator wrote for its command id is replaced by an accepted no-op (upsert), so a retry after an inverse action replays the no-op instead of failing as previously rejected. - Tests: rejected-receipt race path, and a replay through the production agent registration layer. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.ts | 6 + .../agent/sessionLifecycle.expbkt3.test.ts | 18 ++ ...sionLifecycle.registration.expbkt3.test.ts | 178 ++++++++++++++++++ apps/server/src/mcp/toolkits/agent/tools.ts | 8 +- .../handlers.sessionLifecycle.expbkt3.test.ts | 52 ++++- .../src/mcp/toolkits/control/handlers.ts | 42 ++--- .../planSubmissionPolicy.expbkt3.test.ts | 5 + apps/server/src/mcp/toolkits/control/tools.ts | 4 +- 8 files changed, 286 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 4ee47f30941b1..b506757b5fc3d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -2,6 +2,8 @@ import { SessionWebhookHandlers, SessionWebhookToolkit } from "../session-webhooks/mcp.ts"; // T3-CUSTOM(expbkt3): native MCP commands carry the authenticated sender. import { CurrentOrchestrationActorUserId } from "../orchestration-v2/forkActor.expbkt3.ts"; +// T3-CUSTOM(expbkt3): session lifecycle no-op receipts for the fork toolkits (TEC-1819). +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; // T3-CUSTOM(expbkt3): BEGIN — T3 MCP control plane: authenticated external operators alongside // scoped native agent sessions. /** @@ -1141,6 +1143,8 @@ const t3ControlToolkitRegistered = t3ControlToolkitRegistration as Layer.Layer< >; export const T3ControlToolkitRegistrationLive = t3ControlToolkitRegistered.pipe( Layer.provide(McpToolAccess.HandlersLayer.layer(T3ControlToolkitHandlers)), + // Session lifecycle no-op receipts (TEC-1819); the runtime keeps its store private. + Layer.provide(CommandReceiptStore.layerFromApplicationReceipts), ); const layerSessionWebhookRegistration = toolkitRegistration( @@ -1274,6 +1278,8 @@ const registerAgentToolkit = Effect.fn("McpHttpServer.registerAgentToolkit")(fun export const layerAgentToolkitRegistration = Layer.effectDiscard(registerAgentToolkit()).pipe( Layer.provide(McpToolAccess.HandlersLayer.layer(AgentToolkitHandlers)), + // Session lifecycle no-op receipts (TEC-1819); the runtime keeps its store private. + Layer.provide(CommandReceiptStore.layerFromApplicationReceipts), ); // T3-CUSTOM(expbkt3): END diff --git a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts index cd83ed1de8b40..519163101934e 100644 --- a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.expbkt3.test.ts @@ -15,6 +15,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Tool } from "effect/ai"; +import * as CommandReceiptStore from "../../../orchestration-v2/CommandReceiptStore.ts"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; @@ -104,6 +105,21 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { }), createThread: () => Effect.die("createThread should not be called"), }); + const receipts = new Map(); + const receiptStore = { + insertIfAbsent: (receipt: CommandReceiptStore.CommandReceiptV2) => + Effect.sync(() => { + if (receipts.has(receipt.commandId)) return false; + receipts.set(receipt.commandId, receipt); + return true; + }), + upsert: (receipt: CommandReceiptStore.CommandReceiptV2) => + Effect.sync(() => { + receipts.set(receipt.commandId, receipt); + }), + getByCommandId: (commandId: string) => + Effect.sync(() => Option.fromNullishOr(receipts.get(commandId))), + } as unknown as CommandReceiptStore.CommandReceiptStoreV2["Service"]; const provideAs = (caller: McpInvocationContext.McpInvocationScope) => (effect: Effect.Effect) => @@ -112,12 +128,14 @@ it.layer(NodeServices.layer)("t3_session_lifecycle", (it) => { Effect.provideService(ProjectionSnapshotQuery, query), Effect.provideService(TurnStartBootstrap, dispatcher), Effect.provideService(OrchestrationAccessControl, accessControl), + Effect.provideService(CommandReceiptStore.CommandReceiptStoreV2, receiptStore), Effect.provideService(McpInvocationContext.McpInvocationContext, caller), ); const provide = provideAs(scope); const reset = () => { archivedAt = null; dispatched.length = 0; + receipts.clear(); }; it.effect("archives, and a retry is a success that dispatches nothing", () => diff --git a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts new file mode 100644 index 0000000000000..f423da49ec369 --- /dev/null +++ b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts @@ -0,0 +1,178 @@ +// T3-CUSTOM(expbkt3): TEC-1819 — t3_session_lifecycle through the production +// agent toolkit registration. The registration itself provides the command +// receipt store (from the receipt repository the server exposes), so a no-op +// archive retried with the same clientRequestId replays as a no-op even after +// the session was unarchived in between. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ThreadId, + type OrchestrationCommand, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { McpSchema, McpServer } from "effect/ai"; + +import { ClerkDirectory } from "../../../auth/ClerkDirectory.ts"; +import { ServerConfig } from "../../../config.ts"; +import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; +import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; +import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; +import { + OrchestrationCommandReceiptRepository, + type OrchestrationCommandReceipt, +} from "../../../persistence/OrchestrationCommandReceipts.ts"; +import { PlanReviewService } from "../../../planreview/PlanReviewService.ts"; +import * as Project from "../../../project/ProjectService.ts"; +import { ProviderRegistry } from "../../../provider/ProviderRegistry.ts"; +import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; +import { ServerSettingsService } from "../../../serverSettings.ts"; +import { layerAgentToolkitRegistration } from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; + +const sessionId = ThreadId.make("thread-agent-lifecycle-registration"); +const ARCHIVED_AT = "2026-10-28T10:00:00.000Z"; + +const invocation: McpInvocationContext.McpInvocationScope = { + principal: "external-user", + actorUserId: null, + environmentId: EnvironmentId.make("environment-agent-lifecycle-registration"), + requestNamespace: "external-user:agent-lifecycle-registration", + thread: undefined, + client: { + sessionId: "external-user:agent-lifecycle-registration", + label: "OAuth client", + access: "full-access", + }, + capabilities: new Set(["orchestration", "t3.read", "t3.control"]), + issuedAt: 1, +}; + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "agent-lifecycle-registration-test", version: "1.0.0" }, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "agent-lifecycle-registration-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +let archivedAt: string | null = ARCHIVED_AT; +const dispatched: OrchestrationCommand[] = []; +// The persisted receipts, as the receipt repository stores them. +const stored = new Map(); + +const dependencies = Layer.mergeAll( + Layer.mock(OrchestrationCommandReceiptRepository)({ + insertIfAbsent: (receipt) => + Effect.sync(() => { + if (stored.has(receipt.commandId)) return false; + stored.set(receipt.commandId, receipt); + return true; + }), + upsert: (receipt) => + Effect.sync(() => { + stored.set(receipt.commandId, receipt); + }), + getByCommandId: ({ commandId }) => + Effect.sync(() => Option.fromNullishOr(stored.get(commandId))), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => + Effect.sync(() => + id === sessionId + ? { + ...liveThreadShell(id, { activeRunId: null }), + ...(archivedAt === null ? {} : { archivedAt: DateTime.makeUnsafe(archivedAt) }), + } + : null, + ), + }), + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: (id) => + Effect.sync(() => + id === sessionId + ? Option.some({ id, archivedAt } as unknown as OrchestrationThreadShell) + : Option.none(), + ), + }), + Layer.mock(TurnStartBootstrap)({ + dispatch: (command) => + Effect.sync(() => { + // A command id with a receipt replays instead of acting. + const receipt = stored.get(command.commandId); + if (receipt !== undefined) return { sequence: receipt.resultSequence }; + dispatched.push(command); + if (command.type === "thread.archive") archivedAt = ARCHIVED_AT; + if (command.type === "thread.unarchive") archivedAt = null; + return { sequence: dispatched.length }; + }), + }), + Layer.mock(OrchestrationAccessControl)({ actorFor: () => Option.none() }), + Layer.mock(ClerkDirectory)({ enabled: false, descriptor: null }), + Layer.mock(ServerConfig)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(GitWorkflowService)({}), + Layer.mock(PlanReviewService)({}), + Layer.mock(Project.ProjectService)({}), + Layer.mock(ProviderRegistry)({}), + Layer.mock(ScheduledTaskService)({}), + Layer.mock(ServerSettingsService)({}), + NodeServices.layer, +); + +const TestLayer = layerAgentToolkitRegistration.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(dependencies), +); + +const withInvocation = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + +const textOf = (result: { readonly content: ReadonlyArray }) => + JSON.stringify(result.content); + +it.effect("replays a no-op archive after an unarchive, through the production registration", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = withInvocation( + server.callTool({ + name: "t3_session_lifecycle", + arguments: { session: sessionId, action: "archive", clientRequestId: "archive-1" }, + }), + ); + + const first = yield* call; + expect(first.isError).toBe(false); + expect(textOf(first)).toContain("Nothing to do"); + // The no-op went into the receipt repository, not a test double of the store. + expect([...stored.values()]).toMatchObject([ + { aggregateId: sessionId, status: "accepted", resultSequence: 0 }, + ]); + + // Someone unarchives the session; the same request is retried. + archivedAt = null; + const retry = yield* call; + expect(retry.isError).toBe(false); + expect(textOf(retry)).toContain("Nothing to do"); + expect(archivedAt).toBeNull(); + expect(dispatched).toHaveLength(0); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/toolkits/agent/tools.ts b/apps/server/src/mcp/toolkits/agent/tools.ts index f6464ccc419ed..58d7be10a0bdb 100644 --- a/apps/server/src/mcp/toolkits/agent/tools.ts +++ b/apps/server/src/mcp/toolkits/agent/tools.ts @@ -24,6 +24,7 @@ import { ServerConfig } from "../../../config.ts"; import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; +import * as CommandReceiptStore from "../../../orchestration-v2/CommandReceiptStore.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; @@ -641,7 +642,12 @@ export const SessionLifecycleTool = destructive( }), success: AgentToolResult, failure: AgentToolFailure, - dependencies: [...dependencies, ProjectionSnapshotQuery, TurnStartBootstrap], + dependencies: [ + ...dependencies, + ProjectionSnapshotQuery, + TurnStartBootstrap, + CommandReceiptStore.CommandReceiptStoreV2, + ], }).annotate(Tool.Title, "Archive, stop, snooze or delete a session"), ); diff --git a/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts b/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts index 4e5458458df60..7797274380e88 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.sessionLifecycle.expbkt3.test.ts @@ -9,6 +9,7 @@ import { ThreadId, type OrchestrationCommand, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -56,19 +57,36 @@ it.layer(NodeServices.layer)("runSessionLifecycle", (it) => { : Option.none(); }), } as unknown as ProjectionSnapshotQuery["Service"]; - // Behaves like the V2 orchestrator: a known command id replays its receipt, - // and archive/unarchive refuse a session already in the target state. + /** As the V2 orchestrator does: a refused command leaves a rejected receipt. */ + const refuse = (command: OrchestrationCommand, reason: string) => { + receipts.set(command.commandId, { + commandId: command.commandId, + threadId, + commandType: command.type, + acceptedAt: DateTime.makeUnsafe(ARCHIVED_AT), + resultSequence: dispatched.length, + status: "rejected", + error: `Failed to dispatch orchestration command ${command.type} (${command.commandId}).`, + }); + return Effect.fail(refusal(command.type, reason)); + }; + // Behaves like the V2 orchestrator: a known command id replays its receipt + // (a rejected one fails again), and archive/unarchive refuse a session + // already in the target state. const dispatcher = TurnStartBootstrap.of({ dispatch: (command) => Effect.suspend(() => { const receipt = receipts.get(command.commandId); + if (receipt?.status === "rejected") { + return Effect.fail(refusal(command.type, "Previously rejected.")); + } if (receipt !== undefined) return Effect.succeed({ sequence: receipt.resultSequence }); if (failWith !== undefined) return Effect.fail(failWith); if (command.type === "thread.archive" && archivedAt !== null) { - return Effect.fail(refusal(command.type, `Thread ${threadId} is already archived.`)); + return refuse(command, `Thread ${threadId} is already archived.`); } if (command.type === "thread.unarchive" && archivedAt === null) { - return Effect.fail(refusal(command.type, `Thread ${threadId} is not archived.`)); + return refuse(command, `Thread ${threadId} is not archived.`); } dispatched.push(command); if (command.type === "thread.archive") archivedAt = ARCHIVED_AT; @@ -84,6 +102,10 @@ it.layer(NodeServices.layer)("runSessionLifecycle", (it) => { receipts.set(receipt.commandId, receipt); return true; }), + upsert: (receipt: CommandReceiptStore.CommandReceiptV2) => + Effect.sync(() => { + receipts.set(receipt.commandId, receipt); + }), getByCommandId: (commandId: CommandId) => Effect.sync(() => Option.fromNullishOr(receipts.get(commandId))), } as unknown as CommandReceiptStore.CommandReceiptStoreV2["Service"]; @@ -187,6 +209,28 @@ it.layer(NodeServices.layer)("runSessionLifecycle", (it) => { }), ); + it.effect("a race refusal with a caller's id replays as a no-op after the inverse action", () => + Effect.gen(function* () { + reset({ archivedAt: ARCHIVED_AT }); + // The read saw it active; the orchestrator refuses and records a rejection. + staleView = { archivedAt: null }; + const first = yield* run("archive", "mcp:agent:archive:race"); + expect(first).toMatchObject({ changed: false }); + expect(receipts.get("mcp:agent:archive:race")).toMatchObject({ + status: "accepted", + resultSequence: 0, + }); + + // Someone unarchives; the same request retries. + archivedAt = null; + staleView = undefined; + const retry = yield* run("archive", "mcp:agent:archive:race"); + expect(retry).toMatchObject({ changed: false }); + expect(archivedAt).toBeNull(); + expect(dispatched).toHaveLength(0); + }), + ); + it.effect("an unrelated refusal stays a failure with the orchestrator's own reason", () => Effect.gen(function* () { reset({ archivedAt: null }); diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index 65d30b3221076..5996af95209f1 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -844,27 +844,28 @@ const NO_OP_RESULT_SEQUENCE = 0; * its target state. With a caller's command id it is written as an accepted * command receipt with sequence 0, so a retry with that id replays this * outcome, and a later dispatch of that id replays instead of acting, even - * after the session's state changed in between. + * after the session's state changed in between. `replace` overwrites the + * rejected receipt the orchestrator wrote when this same command lost a race + * to the state it asked for; that receipt belongs to this command id alone. */ const noOpLifecycle = Effect.fn("T3ControlToolkit.noOpLifecycle")(function* ( input: SessionLifecycleInput, + options: { readonly replace: boolean } = { replace: false }, ) { if (input.commandId !== undefined) { - const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); - if (Option.isSome(receipts)) { - const acceptedAt = yield* DateTime.now; - yield* receipts.value - .insertIfAbsent({ - commandId: input.commandId, - threadId: input.sessionId, - commandType: input.action === "archive" ? "thread.archive" : "thread.unarchive", - acceptedAt, - resultSequence: NO_OP_RESULT_SEQUENCE, - status: "accepted", - error: null, - }) - .pipe(mapControlError(input.operation)); - } + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const acceptedAt = yield* DateTime.now; + const receipt: CommandReceiptStore.CommandReceiptV2 = { + commandId: input.commandId, + threadId: input.sessionId, + commandType: input.action === "archive" ? "thread.archive" : "thread.unarchive", + acceptedAt, + resultSequence: NO_OP_RESULT_SEQUENCE, + status: "accepted", + error: null, + }; + const write = options.replace ? receipts.upsert(receipt) : receipts.insertIfAbsent(receipt); + yield* write.pipe(mapControlError(input.operation)); } return { accepted: true, @@ -889,10 +890,9 @@ export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecyc // A retry of a caller's command id replays its first no-op outcome rather // than acting on a state that changed since. if (input.commandId !== undefined) { - const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); - const receipt = Option.isSome(receipts) - ? yield* receipts.value.getByCommandId(input.commandId).pipe(mapControlError(operation)) - : Option.none(); + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const lookup = receipts.getByCommandId(input.commandId); + const receipt = yield* lookup.pipe(mapControlError(operation)); // Only a no-op writes sequence 0; a dispatched command has a later one, // and a refused one replays as refused through the dispatch below. if ( @@ -994,7 +994,7 @@ export const runSessionLifecycle = Effect.fn("T3ControlToolkit.runSessionLifecyc ), mapControlError(operation), ); - if (dispatched === undefined) return yield* noOpLifecycle(input); + if (dispatched === undefined) return yield* noOpLifecycle(input, { replace: true }); const result = dispatched; return { accepted: true, diff --git a/apps/server/src/mcp/toolkits/control/planSubmissionPolicy.expbkt3.test.ts b/apps/server/src/mcp/toolkits/control/planSubmissionPolicy.expbkt3.test.ts index 3aa41afe4b6dc..f866f15e82e24 100644 --- a/apps/server/src/mcp/toolkits/control/planSubmissionPolicy.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/control/planSubmissionPolicy.expbkt3.test.ts @@ -24,7 +24,9 @@ import { AgentUiService } from "../../../agentui/AgentUiService.ts"; import { ClerkDirectory } from "../../../auth/ClerkDirectory.ts"; import * as ServerConfig from "../../../config.ts"; import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; +import * as CommandReceiptStore from "../../../orchestration-v2/CommandReceiptStore.ts"; import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; +import { OrchestrationCommandReceiptRepository } from "../../../persistence/OrchestrationCommandReceipts.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; import { UserPresenceService } from "../../../presence/UserPresenceService.ts"; @@ -140,6 +142,9 @@ const makeFixture = Effect.gen(function* () { Layer.mock(GitWorkflowService)({}), // McpToolAccess reads the calling thread before a write. liveThreadsLayer, + // TEC-1819: session lifecycle no-op receipts (unused by the plan tools). + Layer.mock(OrchestrationCommandReceiptRepository)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), NodeServices.layer, ); const setCurrent = (enabled: boolean) => diff --git a/apps/server/src/mcp/toolkits/control/tools.ts b/apps/server/src/mcp/toolkits/control/tools.ts index c5562775b5b0b..4aebe95afd2f6 100644 --- a/apps/server/src/mcp/toolkits/control/tools.ts +++ b/apps/server/src/mcp/toolkits/control/tools.ts @@ -30,6 +30,7 @@ import { ClerkDirectory } from "../../../auth/ClerkDirectory.ts"; import { ServerConfig } from "../../../config.ts"; import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import { TurnStartBootstrap } from "../../../orchestration-v2/turnStartBootstrap.expbkt3.ts"; +import * as CommandReceiptStore from "../../../orchestration-v2/CommandReceiptStore.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import { OrchestrationAccessControl } from "../../../orchestration-v2/Services/AccessControl.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration-v2/Services/ProjectionSnapshotQuery.ts"; @@ -338,7 +339,8 @@ export const T3SessionActionTool = destructiveTool( }), success: JsonToolResult, failure: T3ControlFailure, - dependencies, + // TEC-1819: archive/unarchive record no-op outcomes as command receipts. + dependencies: [...dependencies, CommandReceiptStore.CommandReceiptStoreV2], }).annotate(Tool.Title, "Control T3 session lifecycle"), ); From 9e4199b79bc631e299fe7245da4bb97ee9c3f07b Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Thu, 29 Oct 2026 06:30:03 +0000 Subject: [PATCH 14/14] TEC-1819: type the ServerConfig stub in the registration test Co-Authored-By: Claude Opus 5.5 --- .../agent/sessionLifecycle.registration.expbkt3.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts index f423da49ec369..d9c7137073bbd 100644 --- a/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts +++ b/apps/server/src/mcp/toolkits/agent/sessionLifecycle.registration.expbkt3.test.ts @@ -122,7 +122,8 @@ const dependencies = Layer.mergeAll( }), Layer.mock(OrchestrationAccessControl)({ actorFor: () => Option.none() }), Layer.mock(ClerkDirectory)({ enabled: false, descriptor: null }), - Layer.mock(ServerConfig)({}), + // Only the shape matters: the lifecycle tool reads no server config. + Layer.succeed(ServerConfig, {} as unknown as ServerConfig["Service"]), Layer.mock(ServerEnvironment.ServerEnvironment)({}), Layer.mock(GitWorkflowService)({}), Layer.mock(PlanReviewService)({}),