Skip to content

Commit 015a3f6

Browse files
committed
fix(lineage): gate native subagent stops on server capability
1 parent ac1c00d commit 015a3f6

8 files changed

Lines changed: 80 additions & 4 deletions

File tree

‎apps/server/src/environment/ServerEnvironment.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
169169
expect(first.environmentId).toBe(second.environmentId);
170170
expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION);
171171
expect(second.capabilities.repositoryIdentity).toBe(true);
172+
expect(second.capabilities.subagentInterrupt).toBe(true);
172173
expect(second.capabilities.connectionProbe).toBe(true);
173174
expect(second.capabilities.attachmentUploads).toBe(true);
174175
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export const make = Effect.gen(function* () {
216216
capabilities: {
217217
repositoryIdentity: true,
218218
connectionProbe: true,
219+
subagentInterrupt: true,
219220
attachmentUploads: true,
220221
questionAttachments: true,
221222
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },

‎apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,19 @@ it("stops only active subagents from lineage without opening their thread", asyn
104104

105105
state.command.mockClear();
106106
state.projection = { ...projection, subagents: [{ ...agent, origin: "provider_native" }] };
107-
await act(async () => renderer.update(cloneElement(panel)));
107+
for (const supported of [undefined, false, true]) {
108+
state.configs.set("test", {
109+
environment: {
110+
capabilities: supported === undefined ? {} : { subagentInterrupt: supported },
111+
},
112+
});
113+
await act(async () => renderer.update(cloneElement(panel)));
114+
expect(
115+
renderer.root.findAll(
116+
(node) => node.type === "button" && node.props["aria-label"] === "Stop subagent Worker",
117+
),
118+
).toHaveLength(supported === true ? 1 : 0);
119+
}
108120
await act(async () => stopButton().props.onClick());
109121
expect(state.command).toHaveBeenCalledWith({
110122
environmentId: "test",
@@ -289,6 +301,7 @@ it("shows readable models and only differing workspace details in agent tooltips
289301
];
290302
state.shells = [{ environmentId: "test", source: child }];
291303
state.configs.set("test", {
304+
environment: { capabilities: {} },
292305
providers: [
293306
{
294307
instanceId: "codex",

‎apps/web/src/components/chat/ThreadRelationshipsControl.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ export function ThreadRelationshipsPanel(props: {
203203
}) {
204204
const ref = scopeThreadRef(props.environmentId, props.threadId);
205205
const projection = useThreadProjection(ref)?.projection ?? null;
206-
const providers = useServerConfigs().get(props.environmentId)?.providers;
206+
const config = useServerConfigs().get(props.environmentId);
207+
const providers = config?.providers;
208+
const supportsSubagentInterrupt = config?.environment.capabilities.subagentInterrupt === true;
207209
const subagentsByThreadId = useMemo(
208210
() =>
209211
new Map(
@@ -403,7 +405,8 @@ export function ThreadRelationshipsPanel(props: {
403405
agent?.startedAt &&
404406
((agent.origin === "app_owned" &&
405407
["pending", "running", "waiting"].includes(agent.status)) ||
406-
(agent.origin === "provider_native" &&
408+
(supportsSubagentInterrupt &&
409+
agent.origin === "provider_native" &&
407410
agent.driver === "codex" &&
408411
agent.status === "running"));
409412
const threadTitle = relationshipThreadTitle({

‎packages/client-runtime/src/operations/commands.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as Crypto from "effect/Crypto";
2525
import * as Effect from "effect/Effect";
2626
import * as Layer from "effect/Layer";
2727
import * as Option from "effect/Option";
28+
import * as Result from "effect/Result";
2829
import * as SubscriptionRef from "effect/SubscriptionRef";
2930

3031
import {
@@ -44,6 +45,7 @@ import {
4445
editQueuedRun,
4546
forkThreadFromRun,
4647
interruptThreadTurn,
48+
interruptSubagent,
4749
mergeThreadBack,
4850
promoteQueuedRun,
4951
reorderActiveThread,
@@ -78,6 +80,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
7880
readonly projection?: OrchestrationV2ThreadProjection;
7981
readonly projectionRequests?: ThreadId[];
8082
readonly advertiseServerResolvedCommandContext?: boolean;
83+
readonly subagentInterrupt?: boolean;
8184
}) {
8285
const client = {
8386
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
@@ -125,6 +128,9 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
125128
environment: {
126129
capabilities: {
127130
repositoryIdentity: true,
131+
...(input.subagentInterrupt === undefined
132+
? {}
133+
: { subagentInterrupt: input.subagentInterrupt }),
128134
...(input.advertiseServerResolvedCommandContext === false
129135
? {}
130136
: { serverResolvedCommandContext: true }),
@@ -148,6 +154,35 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
148154
});
149155

150156
describe("V2 environment commands", () => {
157+
it.effect.each([undefined, false, true])(
158+
"dispatches a subagent interrupt only when support is advertised as %s",
159+
(supported) =>
160+
Effect.gen(function* () {
161+
const commands: OrchestrationV2Command[] = [];
162+
const supervisor = yield* makeSupervisor({
163+
commands,
164+
projects: [],
165+
...(supported === undefined ? {} : { subagentInterrupt: supported }),
166+
});
167+
const commandId = CommandId.make("subagent-interrupt");
168+
const subagentId = NodeId.make("subagent-1");
169+
const result = yield* interruptSubagent({
170+
commandId,
171+
threadId: v2ThreadId,
172+
subagentId,
173+
}).pipe(
174+
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
175+
Effect.result,
176+
);
177+
expect(Result.isSuccess(result)).toBe(supported === true);
178+
expect(commands).toEqual(
179+
supported === true
180+
? [{ type: "subagent.interrupt", commandId, threadId: v2ThreadId, subagentId }]
181+
: [],
182+
);
183+
}).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
184+
);
185+
151186
it.effect("routes projects through the event-sourced project transport", () =>
152187
Effect.gen(function* () {
153188
const projects: ProjectMutation[] = [];

‎packages/client-runtime/src/operations/commands.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CheckpointScopeId,
77
ORCHESTRATION_V2_WS_METHODS,
88
OrchestrationV2CheckpointUnavailableError,
9+
OrchestrationV2DispatchCommandError,
910
WS_METHODS,
1011
type ChatAttachment,
1112
type MessageId,
@@ -813,9 +814,18 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea
813814
export const interruptSubagent = Effect.fn("EnvironmentCommands.interruptSubagent")(function* (
814815
input: InterruptSubagentInput,
815816
) {
817+
const config = yield* getInitialServerConfig();
818+
const commandId = yield* allocateCommandId(input);
819+
if (config.environment.capabilities.subagentInterrupt !== true) {
820+
return yield* new OrchestrationV2DispatchCommandError({
821+
commandId,
822+
commandType: "subagent.interrupt",
823+
message: "This server does not support stopping native subagents.",
824+
});
825+
}
816826
return yield* dispatch({
817827
type: "subagent.interrupt",
818-
commandId: yield* allocateCommandId(input),
828+
commandId,
819829
threadId: input.threadId,
820830
subagentId: input.subagentId,
821831
});

‎packages/contracts/src/environment.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ const descriptor = {
1414
} as const;
1515

1616
describe("ExecutionEnvironmentDescriptor", () => {
17+
it.each([undefined, false, true])("preserves subagent interrupt support as %s", (supported) => {
18+
expect(
19+
decodeDescriptor({
20+
...descriptor,
21+
capabilities: {
22+
...descriptor.capabilities,
23+
...(supported === undefined ? {} : { subagentInterrupt: supported }),
24+
},
25+
}).capabilities.subagentInterrupt,
26+
).toBe(supported);
27+
});
28+
1729
it("requires an advertised required-worktree bootstrap capability", () => {
1830
expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined();
1931
expect(

‎packages/contracts/src/environment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type;
9191
export const ExecutionEnvironmentCapabilities = Schema.Struct({
9292
repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
9393
connectionProbe: Schema.optionalKey(Schema.Boolean),
94+
subagentInterrupt: Schema.optionalKey(Schema.Boolean),
9495
/** Missing on older servers, which still accept inline image attachments. */
9596
attachmentUploads: Schema.optionalKey(Schema.Boolean),
9697
/** Uploaded files may accompany question answers. */

0 commit comments

Comments
 (0)