Skip to content
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import type { QueuedRunEdit } from "../../state/queued-run-edit";
import type { FollowUpBehavior } from "../../lib/followUpBehavior";
import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
import * as Haptics from "expo-haptics";
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
AppState,
Keyboard,
Expand Down
201 changes: 200 additions & 1 deletion apps/server/src/orchestration-v2/RunExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,141 @@ it("routes only enrolled workflow members after the root turn ends", () => {
);
});

it("moves a child thread to the run that shows its subagent now", () => {
const threadId = ThreadId.make("thread:handoff");
const rootProviderTurnId = ProviderTurnId.make("provider-turn:handoff");
const identity: RunExecutionService.ProviderEventRouteIdentity = {
threadId,
runId: RunId.make("run:handoff:1"),
attemptId: RunAttemptId.make("attempt:handoff:1"),
providerThreadId: ProviderThreadId.make("provider-thread:handoff"),
};
const resumedChild = ThreadId.make("thread:handoff:resumed");
const siblingChild = ThreadId.make("thread:handoff:sibling");
const providerThreadOf = (childThreadId: ThreadId) =>
ProviderThreadId.make(`provider-thread:${childThreadId}`);
const subagent = (childThreadId: ThreadId, runId: RunId): OrchestrationV2Subagent => ({
id: NodeId.make(`node:${childThreadId}`),
threadId,
runId,
parentNodeId: NodeId.make("node:handoff"),
origin: "provider_native",
createdBy: "agent",
driver,
providerInstanceId: ProviderInstanceId.make("codex"),
providerThreadId: providerThreadOf(childThreadId),
childThreadId,
nativeTaskRef: null,
prompt: "Work",
title: "Work",
model: null,
status: "running",
result: null,
startedAt: null,
completedAt: null,
updatedAt: DateTime.makeUnsafe(0),
});
const childMessage = (childThreadId: ThreadId) =>
({
type: "message.updated",
driver,
message: {
id: MessageId.make(`message:${childThreadId}`),
threadId: childThreadId,
runId: null,
},
}) as ProviderAdapter.ProviderAdapterV2Event;
const childTurn = (childThreadId: ThreadId, ordinal: number) =>
({
type: "provider_turn.updated",
driver,
providerTurn: {
id: ProviderTurnId.make(`provider-turn:${childThreadId}:${ordinal}`),
providerThreadId: providerThreadOf(childThreadId),
runAttemptId: null,
status: "running",
},
}) as ProviderAdapter.ProviderAdapterV2Event;
const ended = RunExecutionService.routeProviderEvent(
{
type: "turn.terminal",
driver,
providerThreadId: identity.providerThreadId,
providerTurnId: rootProviderTurnId,
runOrdinal: 1,
status: "completed",
failure: null,
threadDisposition: "reusable",
},
identity,
RunExecutionService.makeProviderEventRoutingState({
identity,
providerTurnId: rootProviderTurnId,
relatedThreadIds: [resumedChild, siblingChild],
relatedProviderThreadIds: [providerThreadOf(resumedChild), providerThreadOf(siblingChild)],
}),
)[1];
const [firstTurnAccepted, withChildTurn] = RunExecutionService.routeProviderEvent(
childTurn(resumedChild, 1),
identity,
ended,
);
assert.isTrue(firstTurnAccepted);
const [movedAccepted, afterMove] = RunExecutionService.routeProviderEvent(
{
type: "subagent.updated",
driver,
subagent: subagent(resumedChild, RunId.make("run:handoff:2")),
},
identity,
withChildTurn,
);
assert.isFalse(movedAccepted);
assert.isFalse(
RunExecutionService.routeProviderEvent(childMessage(resumedChild), identity, afterMove)[0],
);
assert.isTrue(
RunExecutionService.routeProviderEvent(childMessage(siblingChild), identity, afterMove)[0],
);
assert.isFalse(
RunExecutionService.routeProviderEvent(childTurn(resumedChild, 1), identity, afterMove)[0],
);
assert.isFalse(
RunExecutionService.routeProviderEvent(childTurn(resumedChild, 2), identity, afterMove)[0],
);
assert.isTrue(
RunExecutionService.routeProviderEvent(childTurn(siblingChild, 1), identity, afterMove)[0],
);
assert.isTrue(
RunExecutionService.routeProviderEvent(
{ type: "subagent.updated", driver, subagent: subagent(siblingChild, identity.runId) },
identity,
afterMove,
)[0],
);
const resuming: RunExecutionService.ProviderEventRouteIdentity = {
...identity,
runId: RunId.make("run:handoff:2"),
attemptId: RunAttemptId.make("attempt:handoff:2"),
};
const resumingState = RunExecutionService.makeProviderEventRoutingState({
identity: resuming,
providerTurnId: ProviderTurnId.make("provider-turn:handoff:2"),
});
assert.isFalse(
RunExecutionService.routeProviderEvent(childMessage(resumedChild), resuming, resumingState)[0],
);
const [moveAccepted, afterTake] = RunExecutionService.routeProviderEvent(
{ type: "subagent.updated", driver, subagent: subagent(resumedChild, resuming.runId) },
resuming,
resumingState,
);
assert.isTrue(moveAccepted);
assert.isTrue(
RunExecutionService.routeProviderEvent(childMessage(resumedChild), resuming, afterTake)[0],
);
});

it("does not route a superseded attempt through a reused provider thread", () => {
const threadId = ThreadId.make("thread:shared-runtime:restart");
const providerThreadId = ProviderThreadId.make("provider-thread:shared-runtime:restart");
Expand Down Expand Up @@ -1740,6 +1875,65 @@ it.effect("keeps ingesting a child thread's late background item completion", ()
}),
);

it.effect.each([null, "run:nested-sdk"] as const)(
"releases a settled run when its live child transfers to another run (%s)",
(nestedRunId) =>
Effect.gen(function* () {
const consumed = yield* Deferred.make<void>();
const result = yield* Effect.raceFirst(
runBackgroundItemScenario(
"child-transfer-release",
(ids) => {
const running = subagentEvent(ids, "running");
if (running.type !== "subagent.updated") throw new Error("Missing subagent");
const linked = {
...running,
subagent: {
...running.subagent,
origin: "provider_native" as const,
childThreadId: ids.childThreadId,
providerThreadId: null,
},
};
return [
childThreadCreatedEvent(ids),
linked,
{
...linked,
subagent: {
...linked.subagent,
id: NodeId.make("node:child-transfer-release:nested"),
threadId: ids.childThreadId,
runId: nestedRunId === null ? null : RunId.make(nestedRunId),
childThreadId: null,
},
},
childBackgroundTurnItemEvent(ids, "running", 1),
rootTerminalEvent(ids, "completed"),
{ ...linked, subagent: { ...linked.subagent, runId: RunId.make("run:replacement") } },
];
},
{
keepEventStreamOpen: true,
onEventsConsumed: Deferred.succeed(consumed, undefined).pipe(Effect.asVoid),
},
).pipe(Effect.map(Option.some)),
Deferred.await(consumed).pipe(Effect.as(Option.none())),
);
assert.isTrue(
Option.isSome(result),
"the transferred child must release its prior subscription",
);
if (Option.isSome(result))
assert.deepEqual(result.value, [
"subagent:running",
"subagent:running",
"turn_item:running",
"root-finalized",
]);
}),
);

it.effect("keeps ingesting until the last of several background items terminalizes", () =>
Effect.gen(function* () {
const secondItemId = TurnItemId.make("turn-item:bg-multi:second");
Expand Down Expand Up @@ -4377,6 +4571,7 @@ function runBackgroundItemScenario(
ReadonlyArray<{ readonly id: TurnItemId; readonly runId: RunId }>
>;
readonly onSubscribe?: Effect.Effect<void>;
readonly onEventsConsumed?: Effect.Effect<void>;
readonly onFinalized?: (
events: ReadonlyArray<OrchestrationV2DomainEvent>,
) => Effect.Effect<void>;
Expand Down Expand Up @@ -4444,7 +4639,11 @@ function runBackgroundItemScenario(
events: Stream.empty,
subscribeEvents: Effect.gen(function* () {
yield* options?.onSubscribe ?? Effect.void;
const events = Stream.fromIterable(makeEvents(ids));
const events = Stream.fromIterable(makeEvents(ids)).pipe(
Stream.concat(
Stream.fromEffect(options?.onEventsConsumed ?? Effect.void).pipe(Stream.drain),
),
);
return {
events:
options?.keepEventStreamOpen === true
Expand Down
Loading
Loading