Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 6 additions & 5 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,19 +100,20 @@ describe("resolveThreadListV2Status", () => {
});

it.each([
{ kind: "command", status: "ready" },
{ kind: "monitor", status: "waiting" },
{ roster: "dev server", task: { kind: "command" }, status: "ready" },
{ roster: "dev server watchdog", task: { kind: "monitor" }, status: "ready" },
{ roster: "awaited command", task: { kind: "command", awaited: true }, status: "waiting" },
] as const)(
"presents an unseen completion with a $kind roster as $status",
({ kind, status }) => {
"presents an unseen completion with a $roster roster as $status",
({ task, status }) => {
const thread = presentThreadShell(
environmentId,
makeRawThreadShell({
latestRunId: RunId.make("run-background-completion"),
status: "completed",
latestRunCompletedAt: DateTime.makeUnsafe(NOW),
lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"),
pendingBackgroundTasks: [{ taskId: "background-work", kind }],
pendingBackgroundTasks: [{ taskId: "background-work", ...task }],
}),
);

Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/cli/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ const liveProcess = Effect.acquireRelease(

const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState));

const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =>
Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe(
// Bound the fixture to its provided services so tsc-rs keeps the full CLI's requirements concrete.
const runCliWithServices = <const Name extends string, Input, E, ContextInput>(
command: Command.Command<
Name,
Input,
ContextInput,
E,
NodeServices.NodeServices | NetService.NetService
>,
args: ReadonlyArray<string>,
env: Record<string, string>,
) =>
Command.runWith(command, { version: "0.0.0" })(args).pipe(
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Expand All @@ -51,6 +62,9 @@ const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =
),
);

const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =>
runCliWithServices(makeCli(), args, env);

const pathExists = (path: string) =>
Effect.promise(() =>
NodeFSP.stat(path).then(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,7 @@ const make = Effect.gen(function* () {
type: "thread.stop",
commandId,
threadId: current.childThreadId,
stopDelegatedTasks: false,
...(reason === undefined ? {} : { reason }),
})
.pipe(
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/mcp/toolkits/orchestrator/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ const handlers = {
);
}),
),
t3_background_wait: McpToolAccess.actsAsCaller((input) =>
Effect.gen(function* () {
const { scope, threads } = yield* readCaller();
const caller = yield* McpInvocationContext.requireThreadScope(scope, "t3_background_wait");
return yield* threads
.awaitBackgroundTasks({
threadId: caller.thread.threadId,
taskIds: input.taskIds,
awaited: input.waiting ?? true,
})
.pipe(
Effect.mapError(
(cause) =>
new OrchestratorMcpFailure({
code: cause.reason === "unsupported" ? "invalid_request" : "orchestration_error",
message: cause.message,
}),
),
);
}),
),
schedule_task: McpToolAccess.startsThreads(
() => ({}),
(input) =>
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
OrchestratorMcpThreadWaitResult,
ThreadMetadataMcpUpdateInput,
ThreadMetadataMcpUpdateResult,
TrimmedNonEmptyString,
} from "@t3tools/contracts";
import { Tool, Toolkit } from "effect/ai";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -116,6 +117,25 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", {
.annotate(Tool.Title, "Stop a workflow")
.annotate(Tool.Destructive, true);

const BackgroundWaitTool = Tool.make("t3_background_wait", {
description:
"Keep this thread in the user's Working list and out of their inbox while you wait for background work. Start a background command or Monitor first. Call this before you end your turn if you will act on its result when it wakes you. In taskIds, give the ID shown when the task started. The thread stays in Working until the task ends. The user is not told the work is done. Do not mark work you leave running, such as a dev server or watcher. Unmarked commands and monitors never keep the thread in Working. Pass waiting=false to remove the mark. This does not stop the task. IDs that are not running return in unmatched and change nothing. Only Claude threads support this tool. Other providers return an error.",
parameters: Schema.Struct({
taskIds: Schema.Array(TrimmedNonEmptyString).check(
Schema.isMinLength(1),
Schema.isMaxLength(20),
),
waiting: Schema.optional(Schema.Boolean),
}),
success: Schema.Struct({
matched: Schema.Array(Schema.String),
unmatched: Schema.Array(Schema.String),
}),
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies,
}).annotate(Tool.Title, "Wait on background work");

const ScheduleTaskTool = Tool.make("schedule_task", {
description:
"Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
Expand Down Expand Up @@ -279,6 +299,7 @@ export const OrchestratorToolkit = Toolkit.make(
TaskStatusTool,
TaskCancelTool,
WorkflowStopTool,
BackgroundWaitTool,
ScheduleTaskTool,
ListScheduledTasksTool,
UpdateScheduledTaskTool,
Expand Down
Loading
Loading