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 @@ -9,6 +9,8 @@ import {
ProviderRuntimeEvent,
ProviderSession,
ProviderInstanceId,
RuntimeItemId,
RuntimeTaskId,
} from "@t3tools/contracts";
import {
ApprovalRequestId,
Expand Down Expand Up @@ -70,6 +72,49 @@ const asThreadId = (value: string): ThreadId => ThreadId.make(value);
const asTurnId = (value: string): TurnId => TurnId.make(value);

describe("runtimeEventToActivities", () => {
it("preserves explicit external-agent and launcher classification", () => {
const createdAt = "2026-08-12T00:00:00.000Z";
const [taskActivity] = runtimeEventToActivities({
type: "task.started",
eventId: asEventId("evt-external-agent"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
payload: {
taskId: RuntimeTaskId.make("codex-1"),
description: "Review before merge",
taskType: "local_bash",
agentKind: "agent",
role: "codex",
},
});
expect(taskActivity?.payload).toMatchObject({
taskId: "codex-1",
taskType: "local_bash",
agentKind: "agent",
role: "codex",
});

const [launcherActivity] = runtimeEventToActivities({
type: "item.started",
eventId: asEventId("evt-external-agent-launcher"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: RuntimeItemId.make("tool-codex-1"),
payload: {
itemType: "command_execution",
timelineBypass: true,
},
});
expect(launcherActivity?.payload).toMatchObject({
itemType: "command_execution",
timelineBypass: true,
});
});

it("persists prompt suggestions as hidden turn-scoped composer metadata", () => {
const activities = runtimeEventToActivities({
type: "thread.metadata.updated",
Expand Down
19 changes: 15 additions & 4 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,15 +317,21 @@ function requestKindFromCanonicalRequestType(
* client folds survive activity retention; absent fields stay absent.
*/
function taskLinkageActivityFields(payload: Record<string, unknown>): Record<string, unknown> {
const explicitAgentKind =
payload.agentKind === "agent" || payload.agentKind === "background"
? payload.agentKind
: undefined;
const fields: Record<string, unknown> = {
// Server-stamped classification: persisted rows are self-describing, so
// clients trust the stamp instead of re-deriving agent-vs-background
// from taskType denylists and marker heuristics (legacy rows without a
// stamp keep the client fallback).
agentKind: classifyTaskAgentKind({
taskType: typeof payload.taskType === "string" ? payload.taskType : undefined,
agentId: typeof payload.agentId === "string" ? payload.agentId : undefined,
}),
agentKind:
explicitAgentKind ??
classifyTaskAgentKind({
taskType: typeof payload.taskType === "string" ? payload.taskType : undefined,
agentId: typeof payload.agentId === "string" ? payload.agentId : undefined,
}),
};
for (const key of [
"taskType",
Expand Down Expand Up @@ -823,6 +829,7 @@ export function runtimeEventToActivities(
...(event.payload.parentToolUseId
? { parentToolUseId: event.payload.parentToolUseId }
: {}),
...(event.payload.timelineBypass ? { timelineBypass: true } : {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
Expand All @@ -849,6 +856,7 @@ export function runtimeEventToActivities(
...(event.payload.parentToolUseId
? { parentToolUseId: event.payload.parentToolUseId }
: {}),
...(event.payload.timelineBypass ? { timelineBypass: true } : {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
Expand All @@ -874,6 +882,7 @@ export function runtimeEventToActivities(
...(event.payload.parentToolUseId
? { parentToolUseId: event.payload.parentToolUseId }
: {}),
...(event.payload.timelineBypass ? { timelineBypass: true } : {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
Expand Down Expand Up @@ -1992,13 +2001,15 @@ const make = Effect.gen(function* () {
taskType?: string;
status?: string;
agentId?: string;
agentKind?: "agent" | "background";
};
threadBackgroundLiveness.recordTaskLiveness({
threadId: thread.id,
taskId: payload.taskId,
taskType: payload.taskType,
status: payload.status,
agentId: payload.agentId,
agentKind: payload.agentKind,
kind:
event.type === "task.started"
? "started"
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ describe("ThreadBackgroundLiveness", () => {
expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
});

it("lets adapters promote a background shell to agent liveness", () => {
const liveness = ThreadBackgroundLiveness.make();
const threadId = "t-live-external-agent";
liveness.recordTaskLiveness({
threadId,
taskId: "codex-1",
taskType: "local_bash",
agentKind: "agent",
status: undefined,
kind: "started",
});
expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working");
liveness.recordTaskLiveness({
threadId,
taskId: "codex-1",
taskType: "local_bash",
agentKind: "agent",
status: "completed",
kind: "completed",
});
expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
});

it("nested agents (agentId + agent taskType) still count toward liveness", () => {
const liveness = ThreadBackgroundLiveness.make();
const threadId = "t-live-nested";
Expand Down
28 changes: 15 additions & 13 deletions apps/server/src/orchestration/ThreadBackgroundLiveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*
* @module ThreadBackgroundLivenessService
*/
import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts";
import { classifyTaskAgentKind, INERT_TASK_TYPES } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -27,11 +27,10 @@ interface ThreadLivenessState {
readonly monitors: Set<string>;
}

// Classification sets are the shared contracts copies (MONITOR_TASK_TYPES:
// watch loops — monitor tasks plus background shells, which in practice are
// PR babysitting/log tails since pacing sleeps complete inside the turn;
// INERT_TASK_TYPES: plan-mode bookkeeping) so this registry, ingestion's
// agentKind stamp, and the client fold can never drift apart.
// Classification comes from the shared contracts helper so this registry,
// ingestion's agentKind stamp, and the client fold can never drift apart.
// INERT_TASK_TYPES remains useful here because those tasks should disappear
// from liveness entirely unless an adapter explicitly promotes one to agent.

const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
"completed",
Expand Down Expand Up @@ -59,6 +58,7 @@ export class ThreadBackgroundLivenessService extends Context.Service<
readonly status: string | undefined;
readonly kind: "started" | "progress" | "updated" | "completed";
readonly agentId?: string | undefined;
readonly agentKind?: "agent" | "background" | undefined;
}) => void;

/** Session death orphans all of a thread's background work. */
Expand Down Expand Up @@ -104,17 +104,20 @@ export function make(): ThreadBackgroundLivenessService["Service"] {
return {
recordTaskLiveness: (input) => {
const taskType = input.taskType;
if (taskType !== undefined && INERT_TASK_TYPES.has(taskType)) {
const agentKind =
input.agentKind ??
classifyTaskAgentKind({
taskType,
agentId: input.agentId,
});
if (agentKind !== "agent" && taskType !== undefined && INERT_TASK_TYPES.has(taskType)) {
drop(input.threadId, input.taskId);
return;
}
// A subagent's internal non-agent work (its own shells/monitors) is
// covered by the owning agent's liveness. Nested agents fall through:
// they can outlive their parent (review finding).
if (
input.agentId !== undefined &&
(taskType === undefined || MONITOR_TASK_TYPES.has(taskType))
) {
if (input.agentId !== undefined && agentKind === "background") {
drop(input.threadId, input.taskId);
return;
}
Expand All @@ -132,8 +135,7 @@ export function make(): ThreadBackgroundLivenessService["Service"] {

drop(input.threadId, input.taskId);
const state = stateFor(input.threadId);
const bucket =
taskType !== undefined && MONITOR_TASK_TYPES.has(taskType) ? state.monitors : state.agents;
const bucket = agentKind === "agent" ? state.agents : state.monitors;
bucket.add(input.taskId);
},

Expand Down
157 changes: 157 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1906,6 +1906,163 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("promotes background codex exec tasks without promoting ordinary Bash tasks", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const relevantEventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.type === "item.started" || event.type.startsWith("task.")),
Stream.take(6),
Stream.runCollect,
Effect.forkChild,
);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: session.threadId,
input: "Run checks, then have Codex review the result",
attachments: [],
});

harness.query.emit({
type: "stream_event",
session_id: "sdk-session-external-agent",
uuid: "ordinary-bash-tool",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-ordinary-bash",
name: "Bash",
input: {
command: "vp test run src/example.test.ts",
run_in_background: true,
},
},
},
} as unknown as SDKMessage);
harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "ordinary-bash",
description: "Run focused tests",
task_type: "local_bash",
tool_use_id: "tool-ordinary-bash",
uuid: "ordinary-bash-task",
session_id: "sdk-session-external-agent",
} as unknown as SDKMessage);

harness.query.emit({
type: "stream_event",
session_id: "sdk-session-external-agent",
uuid: "codex-bash-tool",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 1,
content_block: {
type: "tool_use",
id: "tool-codex",
name: "Bash",
input: {
command:
'command codex exec --yolo -C /repo -m gpt-5.6-sol -c model_reasoning_effort="high" -',
run_in_background: true,
},
},
},
} as unknown as SDKMessage);
harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "codex-review",
description: "Review before merge",
task_type: "local_bash",
tool_use_id: "tool-codex",
uuid: "codex-task-started",
session_id: "sdk-session-external-agent",
} as unknown as SDKMessage);
harness.query.emit({
type: "system",
subtype: "task_progress",
task_id: "codex-review",
description: "Reviewing the changes",
uuid: "codex-task-progress",
session_id: "sdk-session-external-agent",
} as unknown as SDKMessage);
harness.query.emit({
type: "system",
subtype: "task_notification",
task_id: "codex-review",
status: "completed",
summary: "Review complete",
uuid: "codex-task-completed",
session_id: "sdk-session-external-agent",
} as unknown as SDKMessage);

const events = Array.from(yield* Fiber.join(relevantEventsFiber));
const ordinaryLauncher = events.find(
(event) => event.type === "item.started" && String(event.itemId) === "tool-ordinary-bash",
);
assert.equal(ordinaryLauncher?.type, "item.started");
if (ordinaryLauncher?.type === "item.started") {
assert.equal(ordinaryLauncher.payload.timelineBypass, undefined);
}
const ordinaryTask = events.find(
(event) =>
event.type === "task.started" && String(event.payload.taskId) === "ordinary-bash",
);
assert.equal(ordinaryTask?.type, "task.started");
if (ordinaryTask?.type === "task.started") {
assert.equal(ordinaryTask.payload.agentKind, undefined);
}

const codexLauncher = events.find(
(event) => event.type === "item.started" && String(event.itemId) === "tool-codex",
);
assert.equal(codexLauncher?.type, "item.started");
if (codexLauncher?.type === "item.started") {
assert.equal(codexLauncher.payload.timelineBypass, true);
}
const codexStarted = events.find(
(event) => event.type === "task.started" && String(event.payload.taskId) === "codex-review",
);
const codexProgress = events.find(
(event) =>
event.type === "task.progress" && String(event.payload.taskId) === "codex-review",
);
const codexCompleted = events.find(
(event) =>
event.type === "task.completed" && String(event.payload.taskId) === "codex-review",
);
for (const event of [codexStarted, codexProgress, codexCompleted]) {
assert.isDefined(event);
if (
event?.type !== "task.started" &&
event?.type !== "task.progress" &&
event?.type !== "task.completed"
) {
continue;
}
assert.equal(event.payload.agentKind, "agent");
assert.equal(event.payload.taskType, "local_bash");
assert.equal(event.payload.role, "codex");
assert.equal(event.payload.model, "gpt-5.6-sol");
assert.equal(event.payload.effort, "high");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
Loading
Loading