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
18 changes: 5 additions & 13 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -891,9 +891,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
props.selectedThread.session?.runtimeMode === "full-access" &&
supportsSessionAgentCancel(activeSessionProviderStatus);
const canCancelAgent = useCallback(
(agent: RuntimeSubagent) =>
canCancelSessionAgent(agent, canCancelSessionAgents, props.connectionState === "connected"),
[canCancelSessionAgents, props.connectionState],
(agent: RuntimeSubagent) => canCancelSessionAgent(agent, canCancelSessionAgents),
[canCancelSessionAgents],
);
const canMessageSessionAgents =
sessionAgentReady &&
Expand Down Expand Up @@ -939,7 +938,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
agents: props.sessionAgents,
provider: activeSessionProviderStatus,
canCancel: canCancelSessionAgents,
detachedControlsAvailable: props.connectionState === "connected",
canMessage: canMessageSessionAgents,
cancellingAgentIds,
onCancel: props.onCancelSessionAgent,
Expand All @@ -950,7 +948,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
agents: props.sessionAgents,
provider: activeSessionProviderStatus,
canCancel: canCancelSessionAgents,
detachedControlsAvailable: props.connectionState === "connected",
canMessage: canMessageSessionAgents,
cancellingAgentIds,
onCancel: props.onCancelSessionAgent,
Expand Down Expand Up @@ -984,7 +981,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
if (
control.scopeKey !== expectedScopeKey ||
current === undefined ||
!canCancelSessionAgent(current, control.canCancel, control.detachedControlsAvailable) ||
!canCancelSessionAgent(current, control.canCancel) ||
control.cancellingAgentIds.has(agentId)
) {
return;
Expand Down Expand Up @@ -1076,12 +1073,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const agent = control.agents.find((candidate) => candidate.id === action.agentId);
if (!agent || !isActiveSubagentStatus(agent.status)) return;
if (action.kind === "live-activity") {
if (
!canWatchSessionAgentActivity ||
agent.watchable === false ||
agent.source === "relay" ||
agent.kind === "workflow"
)
if (!canWatchSessionAgentActivity || agent.watchable === false || agent.kind === "workflow")
return;
setLiveActivitySelection({ agentId: agent.id, scopeKey: control.scopeKey });
return;
Expand All @@ -1095,7 +1087,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
return;
}
if (
!canCancelSessionAgent(agent, control.canCancel, control.detachedControlsAvailable) ||
!canCancelSessionAgent(agent, control.canCancel) ||
control.cancellingAgentIds.has(agent.id)
)
return;
Expand Down
13 changes: 0 additions & 13 deletions apps/mobile/src/features/threads/sessionAgentMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,4 @@ describe("session agent menu", () => {
});
expect(settled).toEqual([]);
});

it("keeps detached worker stop available after the parent session and hides unsupported actions", () => {
const actions = buildSessionAgentMenuActions({
scopeKey: "remote:thread-1",
agents: [agent({ id: "relay:job-1", source: "relay", cancellable: true, watchable: false })],
canMessage: true,
canCancel: false,
canCancelAgent: (candidate) => candidate.source === "relay" && candidate.cancellable === true,
canWatchLiveActivity: true,
cancellingAgentIds: new Set(),
});
expect(actions.map((action) => action.title)).toEqual(["Stop Nested reviewer"]);
});
});
14 changes: 2 additions & 12 deletions apps/mobile/src/features/threads/sessionAgentMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,15 @@ export function buildSessionAgentMenuActions(input: {
return input.agents.flatMap((agent) => {
if (!isActiveSubagentStatus(agent.status)) return [];
const actions: SessionAgentMenuAction[] = [];
if (
input.canWatchLiveActivity &&
agent.watchable !== false &&
agent.source !== "relay" &&
agent.kind !== "workflow"
) {
if (input.canWatchLiveActivity && agent.watchable !== false && agent.kind !== "workflow") {
actions.push({
id: `${LIVE_ACTIVITY_PREFIX}${encodeURIComponent(input.scopeKey)}:${encodeURIComponent(agent.id)}`,
title: `Live activity · ${agent.title}`,
subtitle: "Live only · Assistant updates",
image: "eye",
});
}
if (
input.canMessage &&
agent.source !== "relay" &&
agent.messageable &&
agent.kind !== "workflow"
) {
if (input.canMessage && agent.messageable && agent.kind !== "workflow") {
actions.push({
id: `${MESSAGE_PREFIX}${encodeURIComponent(input.scopeKey)}:${encodeURIComponent(agent.id)}`,
title: `Message ${agent.title}`,
Expand Down
128 changes: 0 additions & 128 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,33 +290,6 @@ function makeThread(
};
}

describe("Relay ownership receipts", () => {
it("keeps internal bindings out of the mobile conversation feed", () => {
const thread = makeThread({
id: ThreadId.make("thread-relay"),
projectId: ProjectId.make("project-relay"),
title: "Relay proof",
activities: [
makeActivity({
id: EventId.make("relay-binding"),
kind: "relay.binding",
summary: "Relay binding",
createdAt: "2026-04-01T00:00:00.000Z",
payload: { id: "job-1", environmentId: "env-1", threadId: "thread-relay" },
}),
makeActivity({
id: EventId.make("relay-activation"),
kind: "relay.activation",
summary: "Relay activation",
createdAt: "2026-04-01T00:00:01.000Z",
payload: { id: "job-1", attempt: 2, toolCallId: "tool-2" },
}),
],
});
expect(buildThreadFeed(thread)).toEqual([]);
});
});

describe("buildThreadFeed", () => {
it("reuses unchanged feed and presentation rows during an assistant text update", () => {
const completedTurnId = TurnId.make("completed-turn");
Expand Down Expand Up @@ -3667,70 +3640,6 @@ describe("quiet timeline: nested agents", () => {
},
);

it("places recovered Relay cards beside their original dispatch after history pagination", () => {
const oldTime = "2026-09-24T10:00:00.000Z";
const restartTime = "2026-09-27T11:00:00.000Z";
const receipt = makeActivity({
id: EventId.make("original-dispatch"),
kind: "tool.completed",
summary: "Relay dispatch",
turnId: TurnId.make("old-turn"),
createdAt: oldTime,
sequence: 1,
payload: { toolCallId: "dispatch", status: "completed", itemType: "mcp_tool_call" },
});
const start = makeActivity({
id: EventId.make("recovered-start"),
kind: "task.started",
summary: "Relay worker started",
turnId: TurnId.make("old-turn"),
createdAt: restartTime,
sequence: 100,
payload: {
taskId: "relay:job-old",
agentKind: "agent",
source: "relay",
toolUseId: "dispatch",
timelineBypass: true,
},
});
const recent = makeActivity({
id: EventId.make("recent-work"),
kind: "tool.completed",
summary: "Recent work",
turnId: TurnId.make("new-turn"),
createdAt: "2026-09-27T10:00:00.000Z",
sequence: 2,
payload: { toolCallId: "new-dispatch", status: "completed" },
});
const rows = (activities: ReadonlyArray<OrchestrationThreadActivity>) =>
buildThreadFeed(
makeThread({
id: ThreadId.make("relay-thread"),
projectId: ProjectId.make("project-1"),
title: "Relay recovery",
activities,
}),
).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
expect(rows([start, recent]).find((entry) => entry.workEntry.agentSpawn)?.createdAt).toBe(
restartTime,
);
const recoveredRows = rows([receipt, recent, start]);
expect(recoveredRows.find((entry) => entry.workEntry.agentSpawn)?.createdAt).toBe(oldTime);
expect(recoveredRows.findIndex((entry) => entry.workEntry.agentSpawn)).toBeLessThan(
recoveredRows.findIndex((entry) => entry.id === "recent-work"),
);
expect(start.createdAt).toBe(restartTime);
const native = {
...start,
id: EventId.make("native-start"),
payload: { taskId: "native", agentKind: "agent", toolUseId: "dispatch" },
};
expect(rows([receipt, native]).find((entry) => entry.workEntry.agentSpawn)?.createdAt).toBe(
restartTime,
);
});

it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => {
const turnId = TurnId.make("turn-workflow");
const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
Expand Down Expand Up @@ -3809,43 +3718,6 @@ describe("quiet timeline: nested agents", () => {
expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
});

it("groups Relay panel members with their coordinator in one mobile spawn card", () => {
const panel = "relay-panel:panel-22222222-2222-4222-8222-222222222222";
const turnId = TurnId.make("turn-relay-panel");
const activities = [panel, `${panel}:member:0`, `${panel}:member:1`].map((taskId, index) =>
makeActivity({
id: EventId.make(`relay-panel-${index}`),
kind: "task.started",
summary: index === 0 ? "Relay panel started" : "Relay worker started",
createdAt: `2026-04-01T00:00:0${index}.000Z`,
turnId,
payload: {
taskId,
taskType: index === 0 ? "local_workflow" : "subagent",
workflowName: "Relay panel",
agentKind: "agent",
source: "relay",
status: "running",
timelineBypass: true,
},
}),
);
const thread = makeThread({
id: ThreadId.make("thread-relay-panel"),
projectId: ProjectId.make("project-1"),
title: "Relay panel",
activities,
});
const rows = buildThreadFeed(thread).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);
expect(rows).toHaveLength(1);
expect(rows[0]?.workEntry.agentSpawn).toMatchObject({
workflowId: panel,
agentTaskIds: [panel, `${panel}:member:0`, `${panel}:member:1`],
});
});

it("summarizes a spawn card from the newest member report and the batch outcome", () => {
type Member = NonNullable<WorkLogEntry["agentSpawn"]>["agents"][number];
const member = (title: string, status: Member["status"], detail: string, seconds: number) =>
Expand Down
24 changes: 1 addition & 23 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,23 +436,11 @@ function deriveWorkLogEntries(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): DerivedWorkLogEntry[] {
const ordered = Arr.sort(activities, activityOrder);
const toolOrigins = new Map<string, string>();
for (const activity of ordered) {
if (activity.kind !== "tool.started" && activity.kind !== "tool.completed") continue;
const payload = asRecord(activity.payload);
const toolCallId =
asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
if (!toolCallId) continue;
const key = `${activity.turnId ?? ""}:${toolCallId}`;
if (!toolOrigins.has(key)) toolOrigins.set(key, activity.createdAt);
}
// Resolved across the thread: a task that names a background type stays
// background even when a later bare terminal row carries the agent stamp.
const backgroundTaskIds = collectBackgroundTaskIds(ordered);
const entries: DerivedWorkLogEntry[] = [];
for (const activity of foldUserInputActivities(ordered)) {
// Ownership receipts feed Relay recovery and control routing, not the transcript.
if (activity.kind === "relay.binding" || activity.kind === "relay.activation") continue;
// The setup card owns its snapshot, including failed and cancelled outcomes.
if (
isWorktreeSetupActivity(activity.kind) &&
Expand Down Expand Up @@ -488,15 +476,7 @@ function deriveWorkLogEntries(
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity, backgroundTaskIds)) continue;
const entry = toDerivedWorkLogEntry(activity, backgroundTaskIds);
const payload = asRecord(activity.payload);
const origin =
payload?.source === "relay" && typeof payload.toolUseId === "string"
? toolOrigins.get(`${activity.turnId ?? ""}:${payload.toolUseId}`)
: undefined;
// Match web: saved Relay rows backfilled at restart belong beside their
// original dispatch, even when that receipt arrives in a later history page.
entries.push(origin && origin < entry.createdAt ? { ...entry, createdAt: origin } : entry);
entries.push(toDerivedWorkLogEntry(activity, backgroundTaskIds));
}
return collapseDerivedWorkLogEntries(entries);
}
Expand Down Expand Up @@ -699,8 +679,6 @@ function toDerivedWorkLogEntry(
*/
function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
const taskId = entry.taskId ?? "";
const relayMemberSlot = taskId.startsWith("relay-panel:") ? taskId.indexOf(":member:") : -1;
if (relayMemberSlot !== -1) return `wf:${taskId.slice(0, relayMemberSlot)}`;
const workflowSlot = taskId.indexOf(":wf:");
if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`;
if (entry.isWorkflowCoordinator) return `wf:${taskId}`;
Expand Down
1 change: 0 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,6 @@ export function useThreadComposerState() {
session?.runtimeMode === "full-access" &&
(session.status === "ready" || session.status === "running") &&
supportsSessionAgentCancel(provider),
selectedEnvironmentRuntime?.connectionState === "connected",
)
) {
return false;
Expand Down
3 changes: 0 additions & 3 deletions apps/server/src/orchestration/Layers/OrchestrationReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
import * as ProjectSettingsReactor from "../ProjectSettingsReactor.ts";
import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
import { RelayWorkerBridge } from "../RelayWorkerBridge.ts";
import * as Option from "effect/Option";

export const makeOrchestrationReactor = Effect.gen(function* () {
Expand All @@ -27,7 +26,6 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
const projectSettingsReactor = yield* ProjectSettingsReactor.ProjectSettingsReactor;
const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
const relayWorkerBridge = Option.getOrUndefined(yield* Effect.serviceOption(RelayWorkerBridge));

const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
yield* providerRuntimeIngestion.start();
Expand All @@ -39,7 +37,6 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
yield* threadSettlementReactor.start();
yield* pullRequestSyncReactor.start();
yield* agentAwarenessRelay.start();
if (relayWorkerBridge) yield* relayWorkerBridge.start;
});

return {
Expand Down
Loading
Loading