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
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import IconStar from "@tabler/icons-react-native/IconStar";
import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";
import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
import IconSun from "@tabler/icons-react-native/IconSun";
import IconTarget from "@tabler/icons-react-native/IconTarget";
import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";
Expand Down Expand Up @@ -215,6 +216,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"star.fill": IconStarFilled,
"sun.max": IconSun,
"stop.fill": IconPlayerStopFilled,
target: IconTarget,
terminal: IconTerminal2,
"text.alignleft": IconAlignLeft,
"text.bubble": IconMessage,
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import {
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
import { presentPendingBackgroundWork } from "@t3tools/client-runtime/state/thread-execution";
import {
presentPendingBackgroundWork,
presentProviderGoal,
} from "@t3tools/client-runtime/state/thread-execution";
import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import {
formatModelSelectionEffort,
Expand Down Expand Up @@ -484,6 +487,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
waiting: pendingBackgroundWork.waiting,
};
}
if (props.selectedThread.goal !== null && contentPresentationKind === "ready") {
const goal = presentProviderGoal(props.selectedThread.goal, false);
return {
kind: "goal",
label: goal.title,
accessibilityLabel: `${goal.title}: ${goal.objective}`,
};
}
return null;
})();
const showWorkingControl = floatingStatus !== null;
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,21 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "goal") {
return (
<StatusLabelRow
key="goal"
accessibilityLabel={props.status.accessibilityLabel}
className="gap-2"
onLayout={props.onLayout}
>
<SymbolView name="target" size={13} tintColorClassName="foreground" type="monochrome" />
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
{props.status.label}
</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "preparing") {
return (
<StatusLabelRow
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type FloatingWorkingStatus =
readonly accessibilityLabel: string;
readonly waiting: boolean;
}
// A native /goal on an idle thread: paused, blocked, complete, or set.
| { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
// A task whose thread the server has not created yet: the worktree may
// still be checking out, so there is no turn to time.
| { readonly kind: "preparing"; readonly label: string }
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -587,8 +587,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
// label as the web sidebar, sourced from the server-side visited watermark
// so checking a thread on any device clears it everywhere.
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const statusLabel =
STATUS_LABEL_BY_STATUS[status] ??
// A native /goal keeps the agent going across turns until it is met.
(status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
? { ...workingLabel, label: "Goal" }
: workingLabel) ??
(isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
// The timestamp is precomputed on the list item (same stamps the settled
// tail sorts by) so a minute tick only re-renders rows that draw it.
Expand Down
149 changes: 149 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7824,6 +7824,155 @@ describe("ClaudeAdapterV2 background wake turns", () => {
}).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
),
);

describe("native goals", () => {
const syntheticFrame = (uuid: string, text: string) =>
claudeSdkFrame({
type: "assistant",
message: {
model: "<synthetic>",
id: uuid,
type: "message",
role: "assistant",
content: [{ type: "text", text }],
stop_reason: "end_turn",
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
parent_tool_use_id: null,
uuid,
session_id: WAKE_NATIVE_SESSION,
});
const stopHookFeedback = (uuid: string, condition: string, reason: string) =>
claudeSdkFrame({
type: "user",
message: {
role: "user",
content: [{ type: "text", text: `Stop hook feedback:\n[${condition}]: ${reason}` }],
},
parent_tool_use_id: null,
isSynthetic: true,
uuid,
session_id: WAKE_NATIVE_SESSION,
});
const goalStatuses = (events: ReadonlyArray<ProviderAdapterV2Event>) =>
events.flatMap((event) =>
event.type === "provider_thread.updated" && event.providerThread.goal != null
? [event.providerThread.goal]
: [],
);

it.effect("tracks a /goal through unmet checks until Claude stops on its own", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const condition = "all tests pass";
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("goal-attempt"),
text: `/goal ${condition}`,
attachments: [],
}),
);
for (const frame of [
syntheticFrame("goal-set", `Goal set: ${condition}`),
makeAssistantTextFrame({ uuid: "goal-work-1", text: "Fixing the first test." }),
stopHookFeedback("goal-check-1", condition, "One test still fails."),
makeAssistantTextFrame({ uuid: "goal-work-2", text: "All tests pass now." }),
makeResultFrame({ uuid: "goal-result", result: "All tests pass now." }),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
const terminal = yield* Queue.take(harness.terminalReceipts);
assert.equal(terminal.status, "completed");
assert.deepEqual(goalStatuses(harness.events), [
{ objective: condition, status: "active", checks: 0 },
{
objective: condition,
status: "active",
checks: 1,
lastCheck: "One test still fails.",
},
{ objective: condition, status: "complete", checks: 1 },
]);
}).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
);

it.effect("keeps a goal active when a hook stops the turn before the goal passes", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const condition = "the deploy succeeds";
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("goal-hook-stop-attempt"),
text: `/goal ${condition}`,
attachments: [],
}),
);
for (const frame of [
syntheticFrame("goal-hook-set", `Goal set: ${condition}`),
makeAssistantTextFrame({ uuid: "goal-hook-work", text: "Deploying." }),
makeResultFrame({
uuid: "goal-hook-result",
result: "Deploying.",
terminalReason: "hook_stopped",
}),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
yield* Queue.take(harness.terminalReceipts);
assert.deepEqual(goalStatuses(harness.events).at(-1), {
objective: condition,
status: "active",
checks: 0,
});
}).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
);

it.effect("keeps a goal active after a command turn with no model output", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const condition = "the build is green";
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: {
...harness.providerThread,
goal: { objective: condition, status: "active", checks: 2 },
},
now: yield* DateTime.now,
attemptId: RunAttemptId.make("goal-show-attempt"),
text: "/goal",
attachments: [],
}),
);
yield* Queue.offer(
harness.sdkMessages,
syntheticFrame("goal-show", `Goal active: ${condition} (2 turns)`),
);
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({ uuid: "goal-show-result", result: "", numTurns: 0 }),
);
yield* Queue.take(harness.terminalReceipts);
assert.deepEqual(goalStatuses(harness.events).at(-1), {
objective: condition,
status: "active",
checks: 2,
});
}).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
);
});
});

describe("ClaudeAdapterV2 query message stream", () => {
Expand Down
Loading
Loading