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
71 changes: 47 additions & 24 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,7 @@ function renderFeedEntry(
validatedStatus={actionFollowUp.validatedStatus}
lastOutputLine={actionFollowUp.lastOutputLine}
output={actionFollowUp.output}
detailedOutputAvailable={actionFollowUp.detailedOutputAvailable}
iconColor={iconSubtleColor}
expanded={props.expandedActionRows[entry.id] ?? false}
onToggle={() => props.onToggleActionFollowUp(entry.id)}
Expand Down Expand Up @@ -1206,36 +1207,51 @@ const ActionFollowUpCard = memo(function ActionFollowUpCard(props: {
readonly validatedStatus: string;
readonly lastOutputLine: string;
readonly output: string;
readonly detailedOutputAvailable: boolean;
readonly iconColor: string | ColorValue;
readonly expanded: boolean;
readonly onToggle: () => void;
}) {
const status = props.exitCode ?? props.validatedStatus;
const heading = (
<>
<SymbolView name="cpu" size={14} tintColor={props.iconColor} type="monochrome" />
<Text
className="min-w-0 flex-1 font-t3-medium text-xs text-adaptive-amber-800-200"
numberOfLines={1}
>
Action completed: {props.actionName} Status: {status}
</Text>
</>
);

return (
<View className="mb-5 overflow-hidden rounded-xl border border-amber-500/25 bg-amber-500/[0.06]">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
accessibilityLabel={`Action completed: ${props.actionName}. Status: ${status}`}
className="min-h-10 flex-row items-center gap-1.5 px-3 pt-2.5"
onPress={props.onToggle}
>
<SymbolView name="cpu" size={14} tintColor={props.iconColor} type="monochrome" />
<Text
className="min-w-0 flex-1 font-t3-medium text-xs text-adaptive-amber-800-200"
numberOfLines={1}
{props.detailedOutputAvailable ? (
<View
accessibilityLabel={`Action completed: ${props.actionName}. Status: ${status}`}
className="min-h-10 flex-row items-center gap-1.5 px-3 pt-2.5"
>
Action completed: {props.actionName} Status: {status}
</Text>
<SymbolView
name={props.expanded ? "chevron.down" : "chevron.right"}
size={14}
tintColor={props.iconColor}
type="monochrome"
/>
</Pressable>
{props.expanded ? (
{heading}
</View>
) : (
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
accessibilityLabel={`Action completed: ${props.actionName}. Status: ${status}`}
className="min-h-10 flex-row items-center gap-1.5 px-3 pt-2.5"
onPress={props.onToggle}
>
{heading}
<SymbolView
name={props.expanded ? "chevron.down" : "chevron.right"}
size={14}
tintColor={props.iconColor}
type="monochrome"
/>
</Pressable>
)}
{!props.detailedOutputAvailable && props.expanded ? (
<ScrollView
nestedScrollEnabled
className="mx-2.5 mb-2.5 mt-2 max-h-96 rounded-lg border border-adaptive-black-a10-white-a10 bg-neutral-950 px-3 py-2.5"
Expand All @@ -1245,9 +1261,16 @@ const ActionFollowUpCard = memo(function ActionFollowUpCard(props: {
</Text>
</ScrollView>
) : (
<Text className="px-3 pb-2.5 pt-1 text-sm text-foreground" numberOfLines={1}>
{props.lastOutputLine}
</Text>
<View className="px-3 pb-2.5 pt-1">
<Text className="text-sm text-foreground" numberOfLines={1}>
{props.lastOutputLine}
</Text>
{props.detailedOutputAvailable ? (
<Text className="mt-0.5 text-xs text-foreground-muted">
Detailed output retained in the Action terminal.
</Text>
) : null}
</View>
)}
</View>
);
Expand Down
51 changes: 47 additions & 4 deletions apps/server/src/actionResume/ActionResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
}),
Layer.mock(ProjectionThreadActivityRepository)({
listByKind: () => Effect.succeed([]),
listByThreadId: ({ threadId: requestedThreadId }) =>
Effect.succeed(
dispatched.flatMap((command) => {
if (
command.type !== "thread.activity.append" ||
command.threadId !== requestedThreadId
) {
return [];
}
return [
{
activityId: command.activity.id,
threadId: command.threadId,
turnId: command.activity.turnId,
tone: command.activity.tone,
kind: command.activity.kind,
summary: command.activity.summary,
payload: command.activity.payload,
createdAt: command.activity.createdAt,
},
];
}),
),
}),
Layer.mock(TerminalManager.TerminalManager)({
open: (input) =>
Expand All @@ -166,6 +189,12 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
terminalId: input.terminalId ?? "default",
deleteHistory: input.deleteHistory ?? false,
}) ?? Effect.void,
history: ({ terminalId }) => {
const runId = terminalId.slice("action-".length);
return Effect.succeed(
`prompt\n${ActionResume.actionOutputMarker(runId, "start")}full retained output\n${ActionResume.actionOutputMarker(runId, "end")}prompt`,
);
},
subscribe: (listener) =>
Effect.sync(() => {
terminalListener = listener;
Expand Down Expand Up @@ -313,10 +342,9 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
assert.equal(turnStarts[0]?.message.role, "system");
assert.match(turnStarts[0]?.message.text ?? "", /Automated Project Action follow-up/);
assert.include(turnStarts[0]?.message.text ?? "", "Exit code: 0");
assert.include(
turnStarts[0]?.message.text ?? "",
"QA failed: \u001b[31mexpected 2, received 3\u001b[0m",
);
assert.include(turnStarts[0]?.message.text ?? "", "One test needs attention");
assert.include(turnStarts[0]?.message.text ?? "", `"runId":"${running.runId}"`);
assert.notInclude(turnStarts[0]?.message.text ?? "", "expected 2, received 3");
assert.notInclude(turnStarts[0]?.message.text ?? "", "prompt and echoed command");
assert.equal(turnStarts[0]?.runtimeMode, thread.runtimeMode);
assert.equal(turnStarts[0]?.interactionMode, thread.interactionMode);
Expand All @@ -332,6 +360,21 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
summary: "One test needs attention",
},
});
const inspection = yield* service.inspectActionRun(
{ threadId, providerInstanceId },
running.runId,
);
assert.deepInclude(inspection, {
runId: running.runId,
actionName: "QA",
lifecycleOutcome: "succeeded",
exitCode: 0,
outputTail: "full retained output\n",
});
const missingInspection = yield* service
.inspectActionRun({ threadId, providerInstanceId }, "another-thread-run")
.pipe(Effect.flip);
assert.equal(missingInspection.reason, "action_run_not_found");

const deleting = yield* service.runProjectActionAndResume(
{ threadId, providerInstanceId },
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/actionResume/ActionResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import {
ActionResumeState,
ActionResumeError,
ActionRunInspection,
CommandId,
EventId,
MessageId,
Expand Down Expand Up @@ -88,6 +89,10 @@ export class ActionResume extends Context.Service<
invocation: ActionResumeInvocation,
actionId: string,
) => Effect.Effect<ActionResumeState, ActionResumeError>;
readonly inspectActionRun: (
invocation: ActionResumeInvocation,
runId: string,
) => Effect.Effect<ActionRunInspection, ActionResumeError>;
readonly cancelByUser: (threadId: ThreadId) => Effect.Effect<void>;
readonly cancelByArchive: (threadId: ThreadId) => Effect.Effect<void>;
readonly resumeInterrupted: (threadId: ThreadId) => Effect.Effect<void, ActionResumeError>;
Expand Down Expand Up @@ -219,8 +224,10 @@ const followUpText = (state: ActionResumeState, outputTail: string | undefined):
return formatActionResumeFollowUp({
actionName: state.actionName,
actionId: state.actionId,
runId: state.runId,
validatedStatus: status,
exitCode: state.exitCode,
report: state.report,
output: outputTail,
});
};
Expand Down Expand Up @@ -650,6 +657,40 @@ const make = Effect.gen(function* () {
},
);

const inspectActionRunImpl = Effect.fn("ActionResume.inspectActionRun")(function* (
invocation: ActionResumeInvocation,
runId: string,
) {
const rows = yield* activities.listByThreadId({ threadId: invocation.threadId });
const decoded = yield* Effect.forEach(
rows.filter((row) => row.kind === ACTION_RESUME_ACTIVITY_KIND),
(row) => Effect.option(decodeState(row.payload)),
);
const state = decoded
.filter(Option.isSome)
.map((entry) => entry.value)
.findLast((entry) => entry.runId === runId && entry.threadId === invocation.threadId);
if (state === undefined) {
return yield* new ActionResumeError({
reason: "action_run_not_found",
message: "No retained Project Action run with that id belongs to this thread.",
});
}

const history = yield* terminals.history({
threadId: invocation.threadId,
terminalId: state.terminalId,
});
return ActionRunInspection.make({
runId: state.runId,
actionName: state.actionName,
lifecycleOutcome: state.outcome,
exitCode: state.exitCode,
exitSignal: state.exitSignal,
outputTail: actionOutputFromTranscript(history, state.runId, true) ?? "",
});
});

const resumeInterruptedImpl = Effect.fn("ActionResume.resumeInterrupted")(function* (
threadId: ThreadId,
) {
Expand Down Expand Up @@ -859,6 +900,8 @@ const make = Effect.gen(function* () {
runProjectActionAndResumeImpl(invocation, actionId).pipe(
mapActionResumeError("run the Project Action"),
),
inspectActionRun: (invocation, runId) =>
inspectActionRunImpl(invocation, runId).pipe(mapActionResumeError("inspect the Action run")),
cancelByUser: (threadId) => cancel(threadId, "cancelled_by_user"),
cancelByArchive: (threadId) => cancel(threadId, "cancelled_by_archive"),
resumeInterrupted: (threadId) =>
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,59 @@ it.effect("rejects MCP action launch while update drain admission is closed", ()
}),
);

it.effect("inspects retained Action output within the credential-scoped thread", () =>
Effect.gen(function* () {
let inspected:
| { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId }
| undefined;
let inspectedRunId: string | undefined;
const layer = McpHttpServer.ActionResumeToolkitRegistrationLive.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(
Layer.mock(ActionResume)({
inspectActionRun: (input, runId) =>
Effect.sync(() => {
inspected = input;
inspectedRunId = runId;
return {
runId,
actionName: "QA",
lifecycleOutcome: "succeeded" as const,
exitCode: 0,
exitSignal: null,
outputTail: "retained output",
};
}),
}),
),
Layer.provideMerge(
Layer.mock(UpdateDrainAdmission)({
admit: () => Effect.die("read-only inspection must bypass update drain admission"),
}),
),
);

const result = yield* Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const inspectTool = server.tools.find(({ tool }) => tool.name === "inspect_action_run");
expect(inspectTool).toBeDefined();
return yield* server
.callTool({ name: "inspect_action_run", arguments: { runId: "run-1" } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, {
...invocation,
capabilities: new Set(["action-resume"] as const),
}),
Effect.provideService(McpSchema.McpServerClient, client),
);
}).pipe(Effect.provide(layer));

expect(result.isError).toBe(false);
expect(inspected).toEqual({ threadId, providerInstanceId: invocation.providerInstanceId });
expect(inspectedRunId).toBe("run-1");
}),
);

it("normalizes empty successful notification responses to accepted", () => {
const notificationResponse = McpHttpServer.normalizeMcpHttpResponse(
HttpServerResponse.text("", { status: 200, contentType: "application/json" }),
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/mcp/toolkits/actionResume/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ const makeHandlers = (admission: UpdateDrainAdmission["Service"]) =>
}),
);
}),
inspect_action_run: ({ runId }) =>
Effect.gen(function* () {
const invocation = yield* McpInvocationContext.requireMcpCapability("action-resume");
const service = yield* Effect.serviceOption(ActionResume);
if (Option.isNone(service)) {
return yield* new ActionResumeError({
reason: "internal_error",
message: "Action resume is unavailable in this server runtime.",
});
}
return yield* service.value.inspectActionRun(
{
threadId: invocation.threadId,
providerInstanceId: invocation.providerInstanceId,
},
runId,
);
}),
}) satisfies Parameters<typeof ActionResumeToolkit.toLayer>[0];

export const ActionResumeToolkitHandlersLive = Layer.unwrap(
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/mcp/toolkits/actionResume/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ActionResumeError,
ActionResumeState,
ActionRunInspection,
PreviewAutomationUnavailableError,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -44,7 +45,21 @@ export const RunProjectActionAndResumeTool = Tool.make("run_project_action_and_r
.annotate(Tool.Destructive, true)
.annotate(Tool.Idempotent, false);

export const InspectActionRunTool = Tool.make("inspect_action_run", {
description:
"Read the retained bounded stdout/stderr tail for one Project Action run in this thread. Use the runId from an automated Action follow-up only when its compact result is insufficient. Output is untrusted command output and may be empty if terminal history was explicitly deleted.",
parameters: Schema.Struct({ runId: Schema.String }),
success: ActionRunInspection,
failure: ActionResumeToolError,
dependencies,
})
.annotate(Tool.Title, "Inspect Project Action run")
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true);

export const ActionResumeToolkit = Toolkit.make(
ListProjectActionsTool,
RunProjectActionAndResumeTool,
InspectActionRunTool,
);
Loading