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
29 changes: 28 additions & 1 deletion apps/server/src/actionResume/ActionResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import * as Deferred from "effect/Deferred";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import {
ACTION_EVENT_TOKEN_ENV,
ACTION_RUN_ID_ENV,
actionProtocolFrame,
} from "@t3tools/shared/actionResumeProtocol";

import { ProjectionThreadActivityRepository } from "../persistence/Services/ProjectionThreadActivities.ts";
import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
Expand Down Expand Up @@ -253,10 +258,26 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
);
assert.match(written.at(-1)?.data ?? "", /vp test run/);
assert.match(written.at(-1)?.data ?? "", /exit \$__t3_action_status/);
assert.equal(opened.at(-1)?.env?.[ACTION_RUN_ID_ENV], running.runId);
const eventToken = opened.at(-1)?.env?.[ACTION_EVENT_TOKEN_ENV];
assert.isString(eventToken);

assert.isDefined(terminalListener);
const startMarker = ActionResume.actionOutputMarker(running.runId, "start");
const endMarker = ActionResume.actionOutputMarker(running.runId, "end");
const resultFrame = actionProtocolFrame({
runId: running.runId,
token: eventToken!,
event: {
kind: "result",
report: {
version: 1,
outcome: "attention",
reason: "test-failure",
summary: "One test needs attention",
},
},
});
yield* terminalListener!({
type: "output",
threadId,
Expand All @@ -267,7 +288,7 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
type: "output",
threadId,
terminalId: running.terminalId,
data: `${startMarker.slice(-2)}QA failed: \u001b[31mexpected 2, received 3\u001b[0m\n${endMarker}prompt`,
data: `${startMarker.slice(-2)}QA failed: \u001b[31mexpected 2, received 3\u001b[0m\n${resultFrame}${endMarker}prompt`,
});
yield* terminalListener!({
type: "exited",
Expand Down Expand Up @@ -304,6 +325,12 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
assert.deepInclude(registry.getLatest(threadId), {
outcome: "succeeded",
delivery: "delivered",
report: {
version: 1,
outcome: "attention",
reason: "test-failure",
summary: "One test needs attention",
},
});

const deleting = yield* service.runProjectActionAndResume(
Expand Down
81 changes: 76 additions & 5 deletions apps/server/src/actionResume/ActionResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import {
} from "@t3tools/contracts";
import { projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume";
import {
ACTION_EVENT_TOKEN_ENV,
ACTION_RUN_ID_ENV,
createActionProtocolDecoder,
type ActionProtocolDecoder,
} from "@t3tools/shared/actionResumeProtocol";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -67,6 +73,11 @@ interface FinishActionInput {
readonly deliver?: boolean;
}

interface ActionProtocolCapture {
readonly decoder: ActionProtocolDecoder;
report?: ActionResumeState["report"];
}

export class ActionResume extends Context.Service<
ActionResume,
{
Expand Down Expand Up @@ -272,6 +283,7 @@ const make = Effect.gen(function* () {
const mutex = yield* Semaphore.make(1);
const decodeState = Schema.decodeUnknownEffect(ActionResumeState);
const outputCaptureByRunId = new Map<string, ActionOutputCapture>();
const protocolCaptureByRunId = new Map<string, ActionProtocolCapture>();

const providerSupportsActionResume = Effect.fn("ActionResume.providerSupportsActionResume")(
function* (providerInstanceId: ProviderInstanceId) {
Expand Down Expand Up @@ -377,6 +389,7 @@ const make = Effect.gen(function* () {
});
yield* persistState({ ...state, delivery: "delivered" });
outputCaptureByRunId.delete(state.runId);
protocolCaptureByRunId.delete(state.runId);
});

const attemptDeliverPending = (threadId: ThreadId) =>
Expand Down Expand Up @@ -411,6 +424,8 @@ const make = Effect.gen(function* () {
(input.outcome === "succeeded" ||
input.outcome === "failed" ||
input.outcome === "cancelled_by_user");
const report =
input.outcome === "succeeded" ? protocolCaptureByRunId.get(current.runId)?.report : undefined;
const next: ActionResumeState = {
...current,
outcome: input.outcome,
Expand All @@ -422,9 +437,13 @@ const make = Effect.gen(function* () {
finishedAt,
exitCode: input.exitCode ?? null,
exitSignal: input.exitSignal ?? null,
...(report === undefined ? {} : { report }),
};
yield* persistState(next);
if (next.delivery === "disposed") outputCaptureByRunId.delete(next.runId);
if (next.delivery === "disposed") {
outputCaptureByRunId.delete(next.runId);
protocolCaptureByRunId.delete(next.runId);
}
});

const finish = (input: FinishActionInput) =>
Expand Down Expand Up @@ -465,6 +484,7 @@ const make = Effect.gen(function* () {
) {
yield* persistState({ ...latest, delivery: "disposed" });
outputCaptureByRunId.delete(latest.runId);
protocolCaptureByRunId.delete(latest.runId);
}
}),
);
Expand Down Expand Up @@ -536,6 +556,7 @@ const make = Effect.gen(function* () {
const { thread, project } = yield* resolveProjectContext(invocation.threadId);
const runId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
const terminalId = `action-${runId}`;
const eventToken = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
const startedAt = yield* nowIso;
const state: ActionResumeState = {
runId,
Expand All @@ -556,6 +577,10 @@ const make = Effect.gen(function* () {
const env = projectScriptRuntimeEnv({
project: { cwd: project.workspaceRoot },
worktreePath: thread.worktreePath,
extraEnv: {
[ACTION_RUN_ID_ENV]: runId,
[ACTION_EVENT_TOKEN_ENV]: eventToken,
},
});
const launch = Effect.gen(function* () {
const terminal = yield* terminals.open({
Expand All @@ -572,6 +597,9 @@ const make = Effect.gen(function* () {
}
yield* persistState(state);
outputCaptureByRunId.set(runId, createActionOutputCapture(runId));
protocolCaptureByRunId.set(runId, {
decoder: createActionProtocolDecoder({ runId, token: eventToken }),
});
yield* terminals.write({
threadId: invocation.threadId,
terminalId,
Expand Down Expand Up @@ -670,6 +698,7 @@ const make = Effect.gen(function* () {
}
yield* persistState({ ...current, delivery: "disposed" });
outputCaptureByRunId.delete(current.runId);
protocolCaptureByRunId.delete(current.runId);
}),
);
});
Expand All @@ -680,16 +709,43 @@ const make = Effect.gen(function* () {
return Effect.void;
}
if (event.type === "output") {
return Effect.sync(() => {
return Effect.gen(function* () {
const capture =
outputCaptureByRunId.get(state.runId) ?? createActionOutputCapture(state.runId);
outputCaptureByRunId.set(state.runId, capture);
consumeActionTerminalOutput(capture, event.data);
const protocol = protocolCaptureByRunId.get(state.runId);
if (!protocol) {
consumeActionTerminalOutput(capture, event.data);
return;
}
const decoded = protocol.decoder.push(event.data);
consumeActionTerminalOutput(capture, decoded.output);
for (const actionEvent of decoded.events) {
if (actionEvent.kind !== "result") continue;
if (protocol.report === undefined) protocol.report = actionEvent.report;
else {
yield* Effect.logWarning("Action emitted more than one terminal result", {
threadId: state.threadId,
runId: state.runId,
});
}
}
if (decoded.invalidFrames > 0) {
yield* Effect.logWarning("Action emitted malformed protocol frames", {
threadId: state.threadId,
runId: state.runId,
count: decoded.invalidFrames,
});
}
});
}
if (event.type === "exited") {
const protocol = protocolCaptureByRunId.get(state.runId);
const capture = outputCaptureByRunId.get(state.runId);
if (capture) finishActionOutputCapture(capture);
if (capture) {
if (protocol) consumeActionTerminalOutput(capture, protocol.decoder.finish());
finishActionOutputCapture(capture);
}
return finish({
threadId: state.threadId,
outcome: event.exitCode === 0 ? "succeeded" : "failed",
Expand All @@ -698,15 +754,25 @@ const make = Effect.gen(function* () {
});
}
if (event.type === "closed") {
const protocol = protocolCaptureByRunId.get(state.runId);
const capture = outputCaptureByRunId.get(state.runId);
if (capture) finishActionOutputCapture(capture);
if (capture) {
if (protocol) consumeActionTerminalOutput(capture, protocol.decoder.finish());
finishActionOutputCapture(capture);
}
return finish({
threadId: state.threadId,
outcome: "cancelled_by_user",
deliver: !event.deleteHistory,
});
}
if (event.type === "error") {
const protocol = protocolCaptureByRunId.get(state.runId);
const capture = outputCaptureByRunId.get(state.runId);
if (capture) {
if (protocol) consumeActionTerminalOutput(capture, protocol.decoder.finish());
finishActionOutputCapture(capture);
}
return finish({ threadId: state.threadId, outcome: "failed" });
}
return Effect.void;
Expand Down Expand Up @@ -751,7 +817,12 @@ const make = Effect.gen(function* () {
const threadId = event.aggregateId as ThreadId;
if (event.type === "thread.archived") return cancel(threadId, "cancelled_by_archive");
if (event.type === "thread.deleted") {
const state = registry.getLatest(threadId);
registry.clear(threadId);
if (state !== null) {
outputCaptureByRunId.delete(state.runId);
protocolCaptureByRunId.delete(state.runId);
}
return Effect.void;
}
return deliverPending(threadId);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,7 @@ it.layer(
process.emitData("prompt ");
process.emitData("\u001b[32mok\u001b[0m ");
process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
process.emitData("\u001b]777;T3ActionEvent;run-1;token;payload\u0007");
process.emitData("\u001b[1;1R");
process.emitData("done\n");

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ function shouldStripDcsSequence(content: string): boolean {
}

function shouldStripOscSequence(content: string): boolean {
return /^(10|11|12);(?:\?|rgb:)/.test(content);
return /^(10|11|12);(?:\?|rgb:)/.test(content) || content.startsWith("777;T3ActionEvent;");
}

function stripStringTerminator(value: string): string {
Expand Down
45 changes: 43 additions & 2 deletions docs/internals/resumable-project-actions-for-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,53 @@ A useful Action has these properties:
an external mutation. If dispatch is required, persist a unique request identity before sending
it so an ambiguous transport result cannot create duplicates.
- **Low-noise output.** Print changes in state rather than the same status on every poll.
- **One final summary line.** The compact result card shows the last output line. Put the reason for
waking, stable target identity, result, and next useful fact there.
- **One compact structured result.** Protocol-aware Actions report a canonical outcome, concise
summary, and stable target identity through the Action reporter. Existing unstructured Actions
should keep one useful final output line while they migrate.

The Action process may poll or wait internally. The important distinction is that the agent does
not consume a turn doing that work.

## Report through the Action kit

Repository-owned LastCode Actions use `scripts/lib/lastcode-action-kit.ts`. It writes framed,
schema-validated events during a resumable run and readable ordinary output when the command is
run directly:

```ts
import { lastCodeAction } from "./lib/lastcode-action-kit.ts";

lastCodeAction.progress({
state: "waiting",
phase: "review",
summary: "Waiting for Codex review",
});

lastCodeAction.result({
outcome: "attention",
reason: "review-findings",
summary: "Two review findings need changes",
subject: {
type: "pull-request",
id: "42",
revision: headCommit,
url: pullRequestUrl,
},
facts: { findings: "2", ci: "passed" },
artifacts: [{ label: "Pull request", url: pullRequestUrl }],
});
```

Use `success` when the intended condition was reached, `attention` when the Action observed work or
a decision for the caller, and `blocked` when progress needs a user or external-state change. A
script crash, nonzero exit, cancellation, or lost process remains a host lifecycle outcome; do not
misreport it as an Action-authored result.

Keep `summary` sufficient for the normal next decision. Put only small stable facts and useful
links in the report. Ordinary stdout/stderr remains the verbose diagnostic record; LastCode can
retain it separately from the compact report. Treat report fields as untrusted command output even
after their shape is validated.

## Put the workflow in the repository

The command should live in a reviewed project script instead of a long inline `t3.json` command.
Expand Down
18 changes: 12 additions & 6 deletions docs/internals/resumable-project-actions-wait-for-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,27 @@ Those operations remain visible in the agent turn.

## 6. Make the compact result useful

Long terminal output is available after expansion, but the compact card shows the final output
line. End with one machine-readable or consistently structured summary containing:
Long terminal output remains available for inspection, but a protocol-aware Action should send one
compact result through the Action reporter containing:

- why the Action stopped;
- the exact target identity;
- the final external state; and
- a URL or other useful artifact identifier.

For example:
For example, LastCode repository scripts use:

```text
[wait-for-pr] Summary: {"reason":"ready","pr":42,"head":"abc123","ci":"passed","review":"complete"}
```ts
lastCodeAction.result({
outcome: "success",
reason: "ready",
summary: "CI and review are ready for pull request 42",
subject: { type: "pull-request", id: "42", revision: "abc123", url: pullRequestUrl },
facts: { ci: "passed", review: "complete" },
});
```

Avoid putting a progress line after the summary, including cleanup messages from shell traps.
Existing unstructured Actions should retain their useful final summary line while they migrate.

## 7. Declare and configure the Action

Expand Down
Loading