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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
SANDBOX_BRIDGE_BACKGROUND_WORK_METHOD,
SANDBOX_BRIDGE_CANCEL_META,
SANDBOX_BRIDGE_INSTANCE_ID,
SANDBOX_BRIDGE_MESSAGE_META,
SANDBOX_BRIDGE_STEER_CANCEL,
SANDBOX_BRIDGE_WAKE_METHOD,
ThreadId,
} from "@t3tools/contracts";
import { pendingBackgroundTurnItems } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
Expand Down Expand Up @@ -1427,6 +1429,102 @@ describe("AcpRegistryAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("starts a run when the sandbox bridge reports one the sandbox began itself", () =>
Effect.gen(function* () {
const notification = {
source: { kind: "subagent" },
outcome: "completed",
summary: "Review the diff finished",
} as const;
const wake = {
type: "emit_inbound",
frame: {
kind: "notification",
method: SANDBOX_BRIDGE_WAKE_METHOD,
params: {
sessionId: CLAUDE_ROOT_SESSION,
messageId: "sandbox-wake-1",
text: "Background task completed.",
notification,
},
},
};
const gate = makeProviderReplayGate(["wake"]);
const session = yield* openClaudeReplaySession(
[
...claudeFrames.open,
{
type: "expect_outbound",
frame: { kind: "request", method: "session/set_config_option", params: "<any>" },
},
{
type: "emit_inbound",
frame: {
kind: "response",
method: "session/set_config_option",
result: { configOptions: [] },
},
},
...claudeFrames.prompt(1),
claudeFrames.idle("end_turn"),
// Repeated while one is pending, it asks for nothing more.
{ ...wake, label: "wake" },
wake,
claudeMarker("woke"),
// The bridge follows the sandbox's run by the message id its prompt carries.
{
type: "expect_outbound",
frame: {
kind: "request",
method: "session/prompt",
params: {
sessionId: CLAUDE_ROOT_SESSION,
prompt: "<any>",
_meta: {
[SANDBOX_BRIDGE_MESSAGE_META]: {
messageId: "sandbox-wake-1",
createdBy: "agent",
creationSource: "provider",
},
},
},
},
},
{
type: "emit_inbound",
frame: { kind: "response", method: "session/prompt", result: {} },
},
claudeFrames.idle("end_turn"),
claudeFrames.closeRequest,
claudeFrames.closed,
],
gate,
true,
"fixture-agent",
SANDBOX_BRIDGE_INSTANCE_ID,
);
yield* session.runtime.startTurn(session.turnInput(1));
yield* session.terminal;
gate.release("wake");
yield* Queue.take(session.offered);
yield* session.throughMarker("woke");
assert.lengthOf(session.offers, 1);
const [offer] = session.offers;
assert.strictEqual(offer?.threadId, session.turnInput(1).threadId);
assert.strictEqual(offer?.providerThreadId, session.providerThread.id);
assert.strictEqual(offer?.messageId, "sandbox-wake-1");
assert.strictEqual(offer?.detail, "Background task completed.");
assert.deepEqual(offer?.notification, notification);
// The server dispatches the request as the host thread's message, whose run reaches here.
const turn = session.turnInput(2, { continuation: true });
yield* session.runtime.startTurn({
...turn,
message: { ...turn.message, messageId: MessageId.make("sandbox-wake-1") },
});
assert.strictEqual((yield* session.terminal).status, "completed");
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("gives Copilot T3's MCP server over HTTP when it advertises HTTP", () =>
Effect.gen(function* () {
const selfInvocation = yield* resolveSelfInvocation();
Expand Down
17 changes: 14 additions & 3 deletions docs/internals/sandbox-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,25 @@ without pairing with the sandbox.
in any chat, also stops the thread's delegated tasks), else `run.interrupt`.
- **Work after the turn.** A host turn ends with the sandbox run carrying its message, but the
sandbox thread can keep working: subagents, background tasks, runs it wakes itself for. So the
bridge keeps following it between turns and sends what it still works on (its live runs, and
the pending work its own shell would hold for, by the shared pending-work rules) as
`SANDBOX_BRIDGE_BACKGROUND_WORK_METHOD`, then an empty list once it settles. The host's ACP adapter
bridge keeps following it until the next prompt or the end of its process, not only until it
settles, since a task's end and the wake it causes arrive apart. It sends what the thread still
works on (its live runs, and the pending work its own shell would hold for, by the shared
pending-work rules) as `SANDBOX_BRIDGE_BACKGROUND_WORK_METHOD`, an empty list once it settles. The host's ACP adapter
(`reportsBackgroundTasks`) makes that the mirror's provider-thread roster, so the mirror's shell
holds it Working on every client, the phone included, with no per-row subscription and no
pairing with the sandbox. The roster belongs to the bridge process: a holding one keeps the
process from idle release, and it is cleared when the process ends. Stop on the held mirror
reaches the bridge as a plain cancel between turns, which stops the sandbox's thread.
- **Wakes.** A run the sandbox's thread starts itself (on a message no user typed: a wake, a
delegated result, a scheduled task, its own continue) after the newest run a host turn followed goes to the host as
`SANDBOX_BRIDGE_WAKE_METHOD`, with the sandbox's message id, text and notification (without the
notification's sandbox ids). The host's ACP adapter offers it as a provider continuation
(`requestContinuation`), one pending at a time and none while a turn runs, so the mirror gets a
wake run as any T3 thread does, on every client. That run's prompt carries the sandbox's message
id, so the bridge follows it as a message the sandbox already has, and never sends a provider
continuation's text to the sandbox. Each wake is a host run of its own: once one ends, the
bridge follows again and asks for the next. A send during it steers or queues as on any running
thread, and a Stop stops the sandbox's thread.
- **Credential.** After the launch hand-off the provisioner runs
[`bridgeCredentialScript`](../../apps/server/src/sandbox/sandboxBridgeCredential.ts) on the
sandbox: `t3 auth session issue` with `orchestration:read`, `orchestration:operate` and
Expand Down
5 changes: 3 additions & 2 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ Connections → Sandboxes** has **Connect** and **Disconnect** for each chat's s
behind it. Once the sandbox is running and connected, the chat shows the sandbox's own thread
again.
- Attachments and images are not forwarded yet.
- The thread under **Sandboxes** shows only what was sent through it; messages sent to the sandbox
directly appear on the sandbox's own thread.
- The thread under **Sandboxes** shows what was sent through it, and the replies the sandbox's
agent gives on its own, such as when a background task or subagent finishes; messages sent to
the sandbox directly appear on the sandbox's own thread.
- The **Sandboxes** provider shows only in projects that hold a sandbox thread.

### Settle or delete a sandbox chat
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,12 @@ export const SANDBOX_BRIDGE_WITHDRAW_METHOD = "_t3code/sandbox_requests_withdraw
* ended, as the host thread's background roster; an empty list once it settles.
*/
export const SANDBOX_BRIDGE_BACKGROUND_WORK_METHOD = "_t3code/sandbox_background_work";
/**
* The bridge's ACP notification that the sandbox's thread started a run on its own between host
* turns: the host starts a provider continuation with the sandbox's message id, which the bridge
* then follows like any message the sandbox already has.
*/
export const SANDBOX_BRIDGE_WAKE_METHOD = "_t3code/sandbox_wake";

/**
* Provider instances T3 seeds and runs for itself, like the sandbox bridge. They work wherever a
Expand Down
16 changes: 15 additions & 1 deletion packages/provider-acp-registry/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import {
import {
defaultInstanceIdForDriver,
ProviderDriverKind,
OrchestrationV2Notification,
OrchestrationV2PendingBackgroundTask,
SANDBOX_BRIDGE_BACKGROUND_WORK_METHOD,
SANDBOX_BRIDGE_CANCEL_META,
SANDBOX_BRIDGE_INSTANCE_ID,
SANDBOX_BRIDGE_MESSAGE_META,
SANDBOX_BRIDGE_STEER_CANCEL,
SANDBOX_BRIDGE_WAKE_METHOD,
SANDBOX_BRIDGE_WITHDRAW_METHOD,
} from "@t3tools/contracts";
import { readCustomModelEntries } from "@t3tools/shared/model";
Expand Down Expand Up @@ -131,10 +133,17 @@ const SandboxBackgroundWork = Schema.Struct({
sessionId: Schema.String,
tasks: Schema.Array(OrchestrationV2PendingBackgroundTask),
});
const SandboxWake = Schema.Struct({
sessionId: Schema.String,
messageId: Schema.String,
text: Schema.String,
notification: Schema.optional(OrchestrationV2Notification),
});

/**
* The sandbox bridge withdraws a request once it is answered on the sandbox, so both sides close
* it, and reports what the sandbox's thread still works on after a turn as the thread's roster.
* it, reports what the sandbox's thread still works on after a turn as the thread's roster, and
* asks for a run of the host thread's own when the sandbox's thread starts one by itself.
*/
function registerSandboxBridgeExtensions(context: AcpAdapterV2ExtensionContext) {
return Effect.all(
Expand All @@ -149,6 +158,11 @@ function registerSandboxBridgeExtensions(context: AcpAdapterV2ExtensionContext)
SandboxBackgroundWork,
context.reportBackgroundTasks,
),
context.runtime.handleExtNotification(
SANDBOX_BRIDGE_WAKE_METHOD,
SandboxWake,
context.requestContinuation,
),
],
{ discard: true },
);
Expand Down
38 changes: 35 additions & 3 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import {
type ChatAttachment,
MessageId,
type ModelSelection,
type OrchestrationV2ConversationMessage,
type OrchestrationV2ExecutionNode,
type OrchestrationV2Notification,
type OrchestrationV2PlanArtifact,
type OrchestrationV2PlanStep,
type OrchestrationV2ProviderCapabilities,
Expand Down Expand Up @@ -280,6 +282,20 @@ export interface AcpAdapterV2ExtensionContext {
readonly sessionId: string;
readonly tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>;
}) => Effect.Effect<void>;
/**
* Asks for a provider continuation between turns, for an agent that starts work on its own. Its
* prompt carries the message, so the agent can tell which of its own runs to show there.
*/
readonly requestContinuation: (
notice: { readonly sessionId: string } & AcpAdapterV2ContinuationMessage,
) => Effect.Effect<void>;
}

/** The message a requested continuation starts its run with. */
export interface AcpAdapterV2ContinuationMessage {
readonly messageId: string;
readonly text: string;
readonly notification?: OrchestrationV2Notification | undefined;
}

/**
Expand Down Expand Up @@ -4723,7 +4739,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
});
});

const offerContinuationRun = Effect.fnUntraced(function* (_sessionId: string) {
const offerContinuationRun = Effect.fnUntraced(function* (
_sessionId: string,
message?: AcpAdapterV2ContinuationMessage,
) {
if (continuationRequests === undefined) {
return false;
}
Expand All @@ -4748,7 +4767,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
);
if (Option.isNone(pending)) return false;
const { route, generation, reports } = pending.value;
const notification = backgroundWorkNotification([...reports.values()]);
const notification =
message?.notification ?? backgroundWorkNotification([...reports.values()]);
yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", {
driver,
providerSessionId: input.providerSessionId,
Expand All @@ -4759,7 +4779,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
threadId: route.threadId,
providerThreadId: route.providerThreadId,
driver,
detail: null,
detail: message?.text ?? null,
...(message === undefined ? {} : { messageId: MessageId.make(message.messageId) }),
...(notification === null ? {} : { notification }),
clearIfCurrent: () =>
continuationPermit.withPermit(
Expand Down Expand Up @@ -7070,6 +7091,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
});
}),
).pipe(Effect.asVoid),
requestContinuation: ({ sessionId, ...message }) =>
runRuntimeCallbackAtGeneration(
handlerGeneration,
Effect.gen(function* () {
if ((yield* Ref.get(activeSessionId)) !== sessionId) return;
// A notice that crossed the next prompt belongs to that turn, not a new run.
const context = yield* Ref.get(activeTurn);
if (context !== null && !context.finalized) return;
yield* offerContinuationRun(sessionId, message);
}),
).pipe(Effect.asVoid),
applyBackgroundTaskMutation: (incoming) =>
runRuntimeCallbackAtGeneration(
handlerGeneration,
Expand Down
Loading