Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
422a1b5
feat(server): remind the agent of open chat comments on every turn
bk-agent-01 Oct 26, 2026
3ad3864
style(server): format the deliveryState test
bk-agent-01 Oct 26, 2026
2da7373
Merge pull request #353 from iamtushar324/feat/thread-comments-open-r…
bk-agent-01 Oct 26, 2026
ebb4147
feat(server): scheduled and webhook prompts steer a busy session by d…
bk-agent-01 Oct 28, 2026
5b930a7
fix(server): steer scheduled prompts only where the provider can steer
bk-agent-01 Oct 28, 2026
0feffb8
fix(server): keep a scheduled prompt queued during a Stop and limit s…
bk-agent-01 Oct 28, 2026
f3ae002
Merge pull request #354 from iamtushar324/t3/schedule-if-busy-steer
bk-agent-01 Oct 28, 2026
2de0a30
TEC-1818: compact agent-toolkit session output and full ids in next-c…
bk-agent-01 Oct 28, 2026
4647cf8
TEC-1819: MCP errors that say what to send, idempotent archive, lifec…
bk-agent-01 Oct 28, 2026
63f4153
TEC-1819: fix test casts and formatting
bk-agent-01 Oct 28, 2026
a9cc2d4
TEC-1818: strict keyset cursor for t3_list_sessions; t3_wait hints us…
bk-agent-01 Oct 28, 2026
b34d819
TEC-1819: review round 1 - own-session policy, durable no-op, archive…
bk-agent-01 Oct 28, 2026
6c6785a
TEC-1819: fix test type narrowing
bk-agent-01 Oct 28, 2026
18b6876
fix(server): storage cleanup treats newer git's broken-gitfile error …
bk-agent-01 Oct 29, 2026
9ee59f7
TEC-1819: review round 2 - production receipt store, durable race no-op
bk-agent-01 Oct 29, 2026
98b49a6
Merge pull request #357 from iamtushar324/t3/storage-cleanup-gitfile-…
bk-agent-01 Oct 29, 2026
d0a97be
Merge remote-tracking branch 'origin/stage' into t3/tec-1818-compact-…
bk-agent-01 Oct 29, 2026
9e4199b
TEC-1819: type the ServerConfig stub in the registration test
bk-agent-01 Oct 29, 2026
9ce0f56
Merge remote-tracking branch 'origin/stage' into t3/tec-1819-actionab…
bk-agent-01 Oct 29, 2026
50ecac6
Merge pull request #355 from iamtushar324/t3/tec-1818-compact-session…
bk-agent-01 Oct 29, 2026
fb99711
Merge pull request #356 from iamtushar324/t3/tec-1819-actionable-erro…
bk-agent-01 Oct 29, 2026
0ee004a
Merge bkmain into the 2026-10-29 stage promotion
bk-agent-01 Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
20 changes: 16 additions & 4 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)),
),
);

/**
Expand Down Expand Up @@ -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")(
Expand Down Expand Up @@ -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);
},
),
Expand Down
196 changes: 196 additions & 0 deletions apps/server/src/mcp/toolkits/agent/compactOutput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// 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,
waitAgainHint,
} 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<Record<string, unknown>>): 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");
});

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"}`');
});
});
Loading
Loading