diff --git a/.github/workflows/ticktick-live-smoke.yml b/.github/workflows/ticktick-live-smoke.yml
new file mode 100644
index 00000000..19707e93
--- /dev/null
+++ b/.github/workflows/ticktick-live-smoke.yml
@@ -0,0 +1,51 @@
+name: ticktick-live-smoke
+
+# The plan's credentialed LIVE contract validation (docs/plans/
+# external-task-views-plan.md): deterministic tests use hand-authored wire
+# values, so upstream TickTick schema/tool drift stays green locally — this job
+# is the drift detector. Scheduled weekly + manually dispatchable; deliberately
+# NOT part of the PR gate (needs a repo secret; must not hammer the live API).
+#
+# Two lanes, each through its production surface (review R10 #3):
+# - OpenAPI (Web lane): the `#[ignore]`d Rust test `live_openapi_contract_smoke`
+# runs the REAL decoder (reqwest → wire.rs → normalize) and asserts
+# representative-shape counters (the smoke account stages due/tags/checklist).
+# - MCP (Worker lane): scripts/ticktick-live-smoke.mjs asserts initialize +
+# tools/list still offer the exact read allowlist.
+# Both are non-capturing: counts/booleans only reach the log.
+
+on:
+ schedule:
+ - cron: "17 6 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ smoke:
+ name: live-contract-smoke
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: "."
+ shared-key: core
+ - name: Smoke the OpenAPI lane through the production decoder
+ env:
+ TICKTICK_ACCESS_TOKEN: ${{ secrets.TICKTICK_ACCESS_TOKEN }}
+ run: >
+ cargo test --manifest-path crates/core/Cargo.toml
+ live_openapi_contract_smoke -- --ignored --nocapture
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - name: Smoke the MCP lane (initialize + tools/list allowlist)
+ env:
+ TICKTICK_ACCESS_TOKEN: ${{ secrets.TICKTICK_ACCESS_TOKEN }}
+ run: node scripts/ticktick-live-smoke.mjs
diff --git a/Cargo.lock b/Cargo.lock
index 2e1c5715..fe1bb4c6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -186,6 +186,7 @@ dependencies = [
"axum",
"base64",
"futures-util",
+ "libc",
"reqwest",
"rust-embed",
"schemars",
@@ -203,6 +204,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
+ "url",
"uuid",
]
diff --git a/apps/web/src/components/ToolActivity.tsx b/apps/web/src/components/ToolActivity.tsx
index fca6a759..442b44ab 100644
--- a/apps/web/src/components/ToolActivity.tsx
+++ b/apps/web/src/components/ToolActivity.tsx
@@ -1,12 +1,16 @@
+import { EXTERNAL_TOOL_PREFIX, isExternalToolName } from "@inkstone/protocol";
import {
AlertTriangle,
BookOpen,
Check,
+ ChevronRight,
+ ListChecks,
type LucideIcon,
Search,
Sparkles,
Wrench,
} from "lucide-react";
+import { useState } from "react";
import { cn } from "@/lib/utils.js";
import type { ToolCall } from "@/store/chat";
@@ -47,6 +51,12 @@ function humanize(name: string): string {
function presentation(name: string): ToolPresentation {
const known = TOOL_PRESENTATION[name];
if (known) return known;
+ if (isExternalToolName(name)) {
+ // An external (Worker-executed MCP) tool: label it by its TickTick verb
+ // (external-task-views A3/A4) — read-only by the dual allowlist.
+ const label = `TickTick · ${name.slice(EXTERNAL_TOOL_PREFIX.length).replace(/[_-]+/g, " ")}`;
+ return { active: label, done: label, Icon: ListChecks, access: "read" };
+ }
const label = humanize(name);
return { active: label, done: label, Icon: Wrench };
}
@@ -58,21 +68,26 @@ const MAX_VISIBLE_ARGS = 3;
* into one row, except errored calls — each is its own group so the failed arg
* is never buried in a survivors' row. `status` is the aggregate (running if any
* member is in flight). `args` is capped at {@link MAX_VISIBLE_ARGS}; `overflow`
- * is how many more were folded away. `key` is stable across renders. */
+ * is how many more were folded away. `key` is stable across renders. `call` is
+ * set ONLY for external (`ticktick_*`) break-outs (external-task-views A4): the
+ * single call the row represents, whose `result` the expansion reveals. */
export type ToolCallGroup = {
key: string;
name: string;
status: ToolCall["status"];
args: string[];
overflow: number;
+ call?: ToolCall;
};
/** Collapse a turn's tool calls into grouped rows (ADR-0043). Non-errored calls
* of the same tool merge (args deduped + joined, in first-seen order, status
- * running-if-any); each errored call breaks out into its own row. Groups are
- * ordered by first occurrence; a tool's errored break-out sorts at the position
- * of its first errored call. Shared by the live and rehydrated paths, so both
- * render identically. */
+ * running-if-any); each errored call breaks out into its own row. EXTERNAL
+ * (`ticktick_*`) calls NEVER group (external-task-views A4): one expandable row
+ * per call, keyed by `tool_call_id`, so two same-name calls keep their distinct
+ * results. Groups are ordered by first occurrence; a tool's errored break-out
+ * sorts at the position of its first errored call. Shared by the live and
+ * rehydrated paths, so both render identically. */
export function groupToolCalls(
toolCalls: readonly ToolCall[],
): ToolCallGroup[] {
@@ -83,6 +98,20 @@ export function groupToolCalls(
for (const call of toolCalls) {
const arg = call.arg?.trim() ? call.arg.trim() : undefined;
+ // External calls never merge (A4): per-call identity is the point — the
+ // row expands to THIS call's model-received result.
+ if (isExternalToolName(call.name)) {
+ groups.push({
+ key: call.id,
+ name: call.name,
+ status: call.status,
+ args: [],
+ overflow: 0,
+ call,
+ });
+ continue;
+ }
+
// Errored calls never merge — each is its own row showing the failed arg.
if (call.status === "error") {
groups.push({
@@ -140,13 +169,109 @@ export function ToolActivity({
aria-live="polite"
className="flex w-full flex-col gap-1.5"
>
- {groups.map((group) => (
-
- ))}
+ {groups.map((group) =>
+ group.call === undefined ? (
+
+ ) : (
+
+ ),
+ )}
);
}
+/** One EXTERNAL (`ticktick_*`) call (external-task-views A4): a collapsed
+ * name + status row that expands on demand to the normalized
+ * `TranscriptToolResult.content` the model received — errors identically
+ * (collapsed error row → expanded error content). Never grouped; never shows
+ * credentials or raw MCP metadata (the result IS the normalized content). */
+function ExternalToolRow({ call }: { call: ToolCall }) {
+ const [expanded, setExpanded] = useState(false);
+ const { active, done, Icon } = presentation(call.name);
+ const running = call.status === "running";
+ const errored = call.status === "error";
+ const label = running ? active : done;
+ const contentText = (call.result?.content ?? [])
+ .map((block) => block.text)
+ .join("\n");
+ const expandable = !running && call.result !== undefined;
+
+ const srText = running
+ ? `${active}, read-only, in progress`
+ : errored
+ ? `${done} failed`
+ : `${done}, read-only, done`;
+
+ return (
+
+
+ {expanded && expandable && (
+
+ {contentText}
+
+ )}
+
+ );
+}
+
function ToolCallRow({ group }: { group: ToolCallGroup }) {
const { active, done, Icon, access } = presentation(group.name);
const running = group.status === "running";
diff --git a/apps/web/src/components/library/TasksView.tsx b/apps/web/src/components/library/TasksView.tsx
new file mode 100644
index 00000000..060ec00c
--- /dev/null
+++ b/apps/web/src/components/library/TasksView.tsx
@@ -0,0 +1,220 @@
+import type { TickTickTaskRow } from "@inkstone/protocol";
+import { AlertTriangle, ListTodo, RefreshCw } from "lucide-react";
+import { useState } from "react";
+import { cn } from "@/lib/utils.js";
+
+// The Web Tasks surface (external-task-views A2/S2). Presentational — the route
+// owns the `useTickTick` hook (status-first + reconnect protocol) and passes
+// the resolved view here, so this renders without a runtime and is unit-tested
+// directly. NOT linked in nav (dev flag): reachable only at /library/tasks.
+
+/** A task's due tuple rendered for display: an all-day date, or the timed
+ * instant with its zone (S1a: one due tuple, never a bare instant). BOTH use
+ * `due.time_zone` — an all-day date rendered in UTC would show the previous
+ * calendar day for a positive-offset zone (e.g. Asia/Shanghai), so the local
+ * zone is the only correct frame. */
+function DueLabel({ due }: { due: NonNullable }) {
+ // TickTick's wire values are looser than Intl/Date accept (CodeRabbit #336):
+ // offsets come as non-ISO `+0000` (normalize to `+00:00` for `Date`) and the
+ // zone can be absent/empty (pass `undefined`, not `""` — an empty zone throws).
+ const date = new Date(due.date.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"));
+ const timeZone = due.time_zone || undefined;
+ const text = due.is_all_day
+ ? date.toLocaleDateString(undefined, { timeZone })
+ : date.toLocaleString(undefined, { timeZone });
+ return {text};
+}
+
+function TaskRow({ task }: { task: TickTickTaskRow }) {
+ const done = task.checklist_items.filter((i) => i.done).length;
+ return (
+
+ );
+}
+
+export interface TasksViewProps {
+ readonly connected: boolean;
+ readonly statusResolved: boolean;
+ readonly statusError: boolean;
+ readonly rows: readonly TickTickTaskRow[];
+ readonly sourceLimitReached: boolean;
+ /** A first task read that failed with NO rows to show → the error state. */
+ readonly tasksInitialError: boolean;
+ /** A background refetch failed but the last-good rows are still held → keep
+ * rendering them with a stale indicator (A2 failure semantics). */
+ readonly tasksStaleError: boolean;
+ readonly tasksLoading: boolean;
+ /** Manual refresh (A2, review R12 #4): re-resolves status + re-reads tasks.
+ * Doubles as the retry affordance on the error states. */
+ readonly refresh: () => void;
+ readonly refreshing: boolean;
+}
+
+/** The Tasks surface body. Renders the not-connected / status-error /
+ * initial-error / loading states, the truncation warning when TickTick returned
+ * its 200-item ceiling, a STALE indicator when a background refetch failed (the
+ * rows are still shown), and the rows — with a LOCAL title filter (A2: any
+ * filtering the UI offers is display-only over the one fetched result). */
+export function TasksView({
+ connected,
+ statusResolved,
+ statusError,
+ rows,
+ sourceLimitReached,
+ tasksInitialError,
+ tasksStaleError,
+ tasksLoading,
+ refresh,
+ refreshing,
+}: TasksViewProps) {
+ const [filter, setFilter] = useState("");
+
+ const retry = (
+
+ );
+
+ // A status READ that failed (couldn't reach Core) is an error — distinct from
+ // a resolved `not_connected` state and from an empty task list.
+ if (statusError) {
+ return (
+
+ Couldn't reach TickTick. Check that Inkstone is running, then retry.
+ {retry}
+
+ TickTick is not connected. Provision a credential file and restart
+ Inkstone.
+
+ );
+ }
+ // A FIRST task read that failed with nothing cached → the error state. A
+ // failed BACKGROUND refetch (rows present) falls through and keeps the rows.
+ if (tasksInitialError) {
+ return (
+
+ Couldn't reach TickTick. Check that Inkstone is running, then retry.
+ {retry}
+
+
+ TickTick returned its 200-item limit; this view may be incomplete.
+
+ )}
+
+ {tasksStaleError && (
+
+
+ Couldn't refresh from TickTick; showing the last-loaded tasks.
+
+ )}
+
+ {tasksLoading || !statusResolved ? (
+ // `!statusResolved` here is the STATUS-PENDING phase (status-error and
+ // resolved-disconnected are handled above): show loading, never a false
+ // "No tasks." while the first status read is still in flight (review M5).
+
Loading tasks…
+ ) : visible.length === 0 ? (
+
No tasks.
+ ) : (
+
+ {visible.map((task) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/hooks/useTickTick.ts b/apps/web/src/lib/hooks/useTickTick.ts
new file mode 100644
index 00000000..3a2b1cb5
--- /dev/null
+++ b/apps/web/src/lib/hooks/useTickTick.ts
@@ -0,0 +1,176 @@
+import type {
+ TickTickStatusResult,
+ TickTickTaskRow,
+ TickTickTasksListResult,
+} from "@inkstone/protocol";
+import { type ConnectionStatus, WsClient } from "@inkstone/ui-sdk";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Effect, Fiber, Stream } from "effect";
+import { useCallback, useEffect, useState } from "react";
+import { useRuntime } from "@/runtime";
+
+// The Web lane's TanStack integration (external-task-views A2). The connection
+// ID is the SOLE task-query key: the fixed Core read (`{"status":[0]}` + kind
+// filtering) means one task query per connection, and any list/tag/date
+// filtering the Tasks UI offers is display-only, applied locally over that one
+// result. The reconnect purge is app-lifetime in `TickTickReconnectSync` (F2);
+// the hook here only reads.
+
+/** `["ticktick","tasks",]` — the task query key. The id alone
+ * keys it; a restart mints a new id (A5), so account B's rows can never land
+ * under account A's key. */
+const tasksKey = (connectionId: string) =>
+ ["ticktick", "tasks", connectionId] as const;
+
+const TASKS_KEY_PREFIX = ["ticktick", "tasks"] as const;
+const STATUS_KEY = ["ticktick", "status"] as const;
+
+/** Split a failed task read into an INITIAL failure (no successful fetch yet —
+ * `data` is `undefined`) vs. a STALE-refetch failure (a prior fetch's data,
+ * possibly an empty list, is retained by TanStack through the error). Keyed on
+ * `data === undefined`, NOT row count (review #4): a successful fetch of a
+ * genuinely empty account retains `{tasks: []}`, so its later refetch failure is
+ * STALE — keeping the empty view with an error hint — never a full initial-error
+ * screen. */
+export function classifyTasksError(
+ isError: boolean,
+ data: TickTickTasksListResult | undefined,
+): { tasksInitialError: boolean; tasksStaleError: boolean } {
+ return {
+ tasksInitialError: isError && data === undefined,
+ tasksStaleError: isError && data !== undefined,
+ };
+}
+
+/** App-lifetime WS-reconnect sync for the Web Tasks lane (A2, review F2). It MUST
+ * mount at the app ROOT, not inside the route: a Core restart (account swap) can
+ * happen while the Tasks view is UNMOUNTED, and a route-local effect would miss
+ * it — the remount would then trust the infinitely-fresh stale status/tasks cache
+ * and render account A under connection B. On a real RECONNECT edge
+ * (`(reconnecting|disconnected) → connected`, NOT a mount's replayed `connected`
+ * — ADR-0051 `.changes`) it drops every cached task query and `resetQueries`
+ * status, so the next status read mints the fresh id and the task read re-keys to
+ * the current account. Renders nothing. */
+export function TickTickReconnectSync(): null {
+ const runtime = useRuntime();
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ const program = Effect.flatMap(WsClient, (client) => {
+ let prev: ConnectionStatus | undefined;
+ return Stream.runForEach(client.connectionStatus(), (state) =>
+ Effect.sync(() => {
+ const reconnected =
+ prev !== undefined && prev !== "connected" && state === "connected";
+ prev = state;
+ if (reconnected) {
+ queryClient.removeQueries({ queryKey: TASKS_KEY_PREFIX });
+ void queryClient.resetQueries({ queryKey: STATUS_KEY });
+ }
+ }),
+ );
+ });
+ const fiber = runtime.runFork(program);
+ return () => {
+ runtime.runFork(Fiber.interrupt(fiber));
+ };
+ }, [runtime, queryClient]);
+
+ return null;
+}
+
+/** The Web Tasks surface's data (A2). Status is its own query on the global
+ * staleTime; its `connection_id` is the SOLE task-query key. The reconnect purge
+ * lives app-lifetime in {@link TickTickReconnectSync} (a route-local one would
+ * miss a reconnect while Tasks is unmounted), so this hook only READS — a plain
+ * focus refetches the task list (60s staleTime), never status, and a plain mount
+ * purges nothing. After a reconnect reset, `connectionId` is `undefined` until the
+ * fresh id resolves, so the task read can never fire under a stale account's key. */
+export function useTickTick() {
+ const runtime = useRuntime();
+ const queryClient = useQueryClient();
+ const [manualRefreshing, setManualRefreshing] = useState(false);
+
+ const readStatus = useCallback(
+ () =>
+ runtime.runPromise(
+ Effect.flatMap(WsClient, (client) => client.tickTickStatus()),
+ ),
+ [runtime],
+ );
+ const readTasks = useCallback(
+ () =>
+ runtime.runPromise(
+ Effect.flatMap(WsClient, (client) => client.tickTickTasksList()),
+ ),
+ [runtime],
+ );
+
+ const status = useQuery({
+ queryKey: STATUS_KEY,
+ // Uses the global `staleTime: Infinity` (main.tsx). The account can only
+ // change across a Core restart, and the WS `connected` transition below
+ // RESETS this query then — NOT every window focus (review M4). A plain
+ // focus must never refetch status and blank the Tasks surface.
+ queryFn: readStatus,
+ });
+
+ // The connection ID keys the task read. It is `undefined` until status
+ // resolves `connected`; the app-lifetime `TickTickReconnectSync` reset clears
+ // `status.data` (→ `undefined` here → the task read disables) while a reconnect
+ // re-resolves, so a task fetch can never run under a stale id. No `isFetching`
+ // term — that dropped the id on every focus refetch and thrashed the cache (M4).
+ const connectionId =
+ status.data?.state === "connected" ? status.data.connection_id : undefined;
+
+ const tasks = useQuery({
+ queryKey: connectionId ? tasksKey(connectionId) : TASKS_KEY_PREFIX,
+ enabled: connectionId !== undefined,
+ staleTime: 60_000,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ queryFn: readTasks,
+ });
+
+ const refresh = useCallback(() => {
+ setManualRefreshing(true);
+ queryClient.removeQueries({ queryKey: TASKS_KEY_PREFIX });
+
+ void queryClient
+ .resetQueries({ queryKey: STATUS_KEY, exact: true })
+ .then(() => {
+ const fresh =
+ queryClient.getQueryData(STATUS_KEY);
+ if (fresh?.state !== "connected") return;
+ return queryClient.fetchQuery({
+ queryKey: tasksKey(fresh.connection_id),
+ queryFn: readTasks,
+ staleTime: 0,
+ });
+ })
+ .catch(() => undefined)
+ .finally(() => {
+ setManualRefreshing(false);
+ });
+ }, [queryClient, readTasks]);
+
+ // #4: a FAILED background refetch keeps the last-good rows (TanStack retains
+ // `data` on error), so distinguish it from an initial failure with no rows.
+ const rows = (tasks.data?.tasks ?? []) as readonly TickTickTaskRow[];
+ return {
+ connected: status.data?.state === "connected",
+ statusResolved: status.isSuccess,
+ // A status read that FAILED (not merely disconnected) — the surface shows
+ // an error, not an empty "No tasks." list.
+ statusError: status.isError,
+ rows,
+ sourceLimitReached: tasks.data?.source_limit_reached ?? false,
+ // Initial-vs-stale failure split (review #4): see `classifyTasksError`.
+ ...classifyTasksError(tasks.isError, tasks.data),
+ tasksLoading: tasks.isLoading && connectionId !== undefined,
+ // Status-first manual refresh: task cache purge → fresh status → fresh-key
+ // task read. A disabled task observer is never refetched.
+ refresh,
+ refreshing: manualRefreshing || status.isFetching || tasks.isFetching,
+ };
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 793f7966..0cdb3297 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -4,6 +4,7 @@ import { AlertTriangle, Compass } from "lucide-react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { EmptyState } from "./components/ui/empty-state.tsx";
+import { TickTickReconnectSync } from "./lib/hooks/useTickTick.ts";
import { routeTree } from "./routeTree.gen";
import { RuntimeProvider } from "./runtime.tsx";
import "./index.css";
@@ -89,6 +90,9 @@ createRoot(root).render(
+ {/* App-lifetime: observes WS reconnects even while Tasks is unmounted
+ (review F2), so an account swap can't survive in a stale cache. */}
+
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index a020b33e..4e206448 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -16,6 +16,7 @@ import { Route as LibraryIndexRouteImport } from './routes/library/index'
import { Route as ChatIndexRouteImport } from './routes/_chat/index'
import { Route as SettingsModelsRouteImport } from './routes/settings/models'
import { Route as LibraryTimelineRouteImport } from './routes/library/timeline'
+import { Route as LibraryTasksRouteImport } from './routes/library/tasks'
import { Route as LibraryMediaRouteImport } from './routes/library/media'
import { Route as LibraryHealthRouteImport } from './routes/library/health'
import { Route as LibraryGtdRouteImport } from './routes/library/gtd'
@@ -57,6 +58,11 @@ const LibraryTimelineRoute = LibraryTimelineRouteImport.update({
path: '/timeline',
getParentRoute: () => LibraryRouteRoute,
} as any)
+const LibraryTasksRoute = LibraryTasksRouteImport.update({
+ id: '/tasks',
+ path: '/tasks',
+ getParentRoute: () => LibraryRouteRoute,
+} as any)
const LibraryMediaRoute = LibraryMediaRouteImport.update({
id: '/media',
path: '/media',
@@ -97,6 +103,7 @@ export interface FileRoutesByFullPath {
'/library/gtd': typeof LibraryGtdRoute
'/library/health': typeof LibraryHealthRoute
'/library/media': typeof LibraryMediaRoute
+ '/library/tasks': typeof LibraryTasksRoute
'/library/timeline': typeof LibraryTimelineRoute
'/settings/models': typeof SettingsModelsRoute
'/library/': typeof LibraryIndexRoute
@@ -109,6 +116,7 @@ export interface FileRoutesByTo {
'/library/gtd': typeof LibraryGtdRoute
'/library/health': typeof LibraryHealthRoute
'/library/media': typeof LibraryMediaRoute
+ '/library/tasks': typeof LibraryTasksRoute
'/library/timeline': typeof LibraryTimelineRoute
'/settings/models': typeof SettingsModelsRoute
'/': typeof ChatIndexRoute
@@ -125,6 +133,7 @@ export interface FileRoutesById {
'/library/gtd': typeof LibraryGtdRoute
'/library/health': typeof LibraryHealthRoute
'/library/media': typeof LibraryMediaRoute
+ '/library/tasks': typeof LibraryTasksRoute
'/library/timeline': typeof LibraryTimelineRoute
'/settings/models': typeof SettingsModelsRoute
'/_chat/': typeof ChatIndexRoute
@@ -142,6 +151,7 @@ export interface FileRouteTypes {
| '/library/gtd'
| '/library/health'
| '/library/media'
+ | '/library/tasks'
| '/library/timeline'
| '/settings/models'
| '/library/'
@@ -154,6 +164,7 @@ export interface FileRouteTypes {
| '/library/gtd'
| '/library/health'
| '/library/media'
+ | '/library/tasks'
| '/library/timeline'
| '/settings/models'
| '/'
@@ -169,6 +180,7 @@ export interface FileRouteTypes {
| '/library/gtd'
| '/library/health'
| '/library/media'
+ | '/library/tasks'
| '/library/timeline'
| '/settings/models'
| '/_chat/'
@@ -233,6 +245,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LibraryTimelineRouteImport
parentRoute: typeof LibraryRouteRoute
}
+ '/library/tasks': {
+ id: '/library/tasks'
+ path: '/tasks'
+ fullPath: '/library/tasks'
+ preLoaderRoute: typeof LibraryTasksRouteImport
+ parentRoute: typeof LibraryRouteRoute
+ }
'/library/media': {
id: '/library/media'
path: '/media'
@@ -283,6 +302,7 @@ interface LibraryRouteRouteChildren {
LibraryGtdRoute: typeof LibraryGtdRoute
LibraryHealthRoute: typeof LibraryHealthRoute
LibraryMediaRoute: typeof LibraryMediaRoute
+ LibraryTasksRoute: typeof LibraryTasksRoute
LibraryTimelineRoute: typeof LibraryTimelineRoute
LibraryIndexRoute: typeof LibraryIndexRoute
}
@@ -292,6 +312,7 @@ const LibraryRouteRouteChildren: LibraryRouteRouteChildren = {
LibraryGtdRoute: LibraryGtdRoute,
LibraryHealthRoute: LibraryHealthRoute,
LibraryMediaRoute: LibraryMediaRoute,
+ LibraryTasksRoute: LibraryTasksRoute,
LibraryTimelineRoute: LibraryTimelineRoute,
LibraryIndexRoute: LibraryIndexRoute,
}
diff --git a/apps/web/src/routes/library/tasks.tsx b/apps/web/src/routes/library/tasks.tsx
new file mode 100644
index 00000000..93e98614
--- /dev/null
+++ b/apps/web/src/routes/library/tasks.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { TasksView } from "@/components/library/TasksView";
+import { useTickTick } from "@/lib/hooks/useTickTick";
+
+// The hidden Tasks surface (external-task-views S2). Reachable only by URL
+// (/library/tasks), deliberately NOT linked in TopicNav — the dev flag until
+// the S4 cutover promotes GTD → Tasks. The route owns the reconnect protocol
+// (via useTickTick); TasksView is the pure presentation.
+function TasksRoute() {
+ const view = useTickTick();
+ return ;
+}
+
+export const Route = createFileRoute("/library/tasks")({
+ component: TasksRoute,
+});
diff --git a/apps/web/src/store/bridge.ts b/apps/web/src/store/bridge.ts
index 17cedb48..12e94d8b 100644
--- a/apps/web/src/store/bridge.ts
+++ b/apps/web/src/store/bridge.ts
@@ -420,18 +420,21 @@ export async function awaitRun(
}
/**
- * Stop a Run from the chat surface (ADR-0014). Fires `run/cancel`, then settles
- * the UI off the authoritative response: interrupt the subscribe fiber, apply a
- * synthetic `cancelled` event to settle the bubble, and drop any pending Proposal.
+ * Stop a Run from the chat surface (ADR-0014). Fires `run/cancel`, then EITHER
+ * lets the live subscribe stream deliver the real terminal, OR settles the UI off
+ * the response — interrupt the fiber, apply a synthetic `cancelled` to settle the
+ * bubble, drop any pending Proposal.
*
- * We settle here for every (outcome, state) EXCEPT the one case whose terminal is
- * owned elsewhere — `already_terminal` on a *running* Run, where the live subscribe
- * stream already delivered (or will deliver) the real `done`/`error`/`cancelled`.
- * `accepted` settles (Core committed the cancel; for a running Run its real
- * `cancelled` is idempotent with the synthetic one). `unknown_run` settles (Core has
- * no run/hub, so NO stream event will ever come — bailing would leak the fiber). A
- * *parked* Run (awaiting a Proposal decision) has no live tail, so it settles on any
- * outcome rather than wedge the Stop control. See docs/design/web-store.md.
+ * The live stream OWNS the terminal (we return, settling nothing) in exactly two
+ * cases: `accepted` WITH a live tail — a running Run, whose real interrupted
+ * `tool_call`s + `cancelled` arrive on the hub this tab subscribes to (applying
+ * them is what keeps live == reload) — and `already_terminal` on a *running* Run
+ * (its real `done`/`error`/`cancelled` already came or will). Every other
+ * (outcome, state) settles off THIS response because no stream event will come: a
+ * *parked* Run (no live tail, any outcome), an `accepted` running Run WITHOUT a
+ * hub (the resume window), or `unknown_run` (no hub — bailing would leak the
+ * fiber). The inline conditions below are the authoritative table. See
+ * docs/design/web-store.md.
*/
export async function cancelRun(
runtime: WsRuntime,
@@ -440,8 +443,11 @@ export async function cancelRun(
const program = Effect.flatMap(WsClient, (client) => client.cancelRun(runId));
let outcome: "accepted" | "already_terminal" | "unknown_run";
+ let liveTail: boolean;
try {
- outcome = (await runtime.runPromise(program)).outcome;
+ const result = await runtime.runPromise(program);
+ outcome = result.outcome;
+ liveTail = result.live_tail;
} catch {
// Cancel is best-effort; a failed request leaves the Run as-is.
return;
@@ -452,33 +458,47 @@ export async function cancelRun(
return;
}
- // Parked-ness is a record field read, not re-derived from Proposal status: the
- // record stays `parked` from the moment a Proposal attaches through `deciding`
- // and a failed decide, flipping back to `running` only when the resume stream
- // re-subscribes (which then owns the terminal). A racing cancel during deciding
- // clears the Proposal here, and decideProposal's currency guard then bails.
+ // Parked-ness is a record field read (not re-derived from Proposal status):
+ // the record stays `parked` from proposal-attach through `deciding` and a
+ // failed decide, flipping to `running` only when the resume stream
+ // re-subscribes (which then owns the terminal).
const parked = isRunParked(runId);
- // The ONLY outcome whose terminal is owned elsewhere is `already_terminal` on a
- // non-parked Run: its live subscribe stream already delivered (or will deliver)
- // the real done/error/cancelled, which settles the bubble and reaps the fiber.
- // Every other case must settle here: `accepted` (Core committed the cancel),
- // and `unknown_run` (Core has no run/hub, so NO stream event will ever come —
- // bailing would leak the fiber and wedge Stop forever). `parked` always settles
- // since a parked Run has no live tail regardless of outcome.
- if (outcome === "already_terminal" && !parked) {
+ // `live_tail` (external-task-views A4) is Core's authoritative answer to "will
+ // a terminal reach me on the live stream?" — no timer guess. Leave the bubble
+ // to the live stream in exactly two cases:
+ // - `accepted` with `live_tail`: Core published (or will publish) the
+ // interrupted `tool_call` event(s) then `cancelled` on the hub this tab is
+ // subscribed to. The stream must APPLY them (interrupting here would drop an
+ // interrupted external call's result and diverge live from reload); its
+ // takeUntil(cancelled) reaps the fiber and fires onRunSettled.
+ // - `already_terminal` on a RUNNING Run: its live stream already delivered (or
+ // will deliver) the real done/error/cancelled.
+ if (
+ (outcome === "accepted" && liveTail) ||
+ (outcome === "already_terminal" && !parked)
+ ) {
return;
}
- // Interrupt first so the fiber's takeUntil can't race a real terminal event,
- // then settle deterministically off the authoritative cancel response.
+ // Everything else settles off this response — no stream event will come:
+ // parked (no live tail, any outcome), an `accepted` running-without-hub
+ // resume window, or `unknown_run` (no hub — bailing would leak the fiber).
+ settleCancelledLocally(runtime, threadId, runId);
+}
+
+/** The synthetic cancel settle: interrupt the fiber (so its takeUntil can't
+ * race), apply a local `cancelled`, drop any Proposal, and refresh the
+ * recent-Runs feed from this authoritative settle point (the interrupted
+ * fiber's own onRunSettled is identity-gated off). */
+function settleCancelledLocally(
+ runtime: WsRuntime,
+ threadId: string,
+ runId: RunId,
+): void {
interruptRun(runtime, runId);
applyEvent(threadId, runId, { kind: "cancelled" });
clearProposal(runId);
- // A cancel settles the Run HERE, not via the stream finalizer (which we just
- // interrupted — its onRunSettled is gated off precisely so resume/unmount
- // teardowns don't fire it). So refresh the recent-Runs feed from this
- // authoritative settle point, else a user-stopped Run lingers as Running/Waiting.
onRunSettled?.();
}
diff --git a/apps/web/src/store/chat.ts b/apps/web/src/store/chat.ts
index 47e277fb..2db81b81 100644
--- a/apps/web/src/store/chat.ts
+++ b/apps/web/src/store/chat.ts
@@ -2,45 +2,29 @@ import type { ProposalReviewContext, ResolvedNode } from "@inkstone/protocol";
import type { RunEventValue } from "@inkstone/ui-sdk";
import { useStore } from "zustand";
import { createStore } from "zustand/vanilla";
-
-/** A tool call surfaced live within an assistant turn (ADR-0006 tool_call Run Event). */
-export interface ToolCall {
- readonly id: string;
- readonly name: string;
- readonly status: "running" | "completed" | "error";
- /** The tool's display argument (ADR-0043), e.g. a search query; absent for argless tools. */
- readonly arg?: string;
-}
-
-/**
- * One item in an assistant turn's ordered timeline (ADR-0045): a contiguous run
- * of text, a tool-call boundary, a positional marker for the Proposal card, or a
- * `reasoning` (thinking) trace. The `proposal` segment carries ONLY `runId` — the
- * {@link PendingProposal} map stays the source of interactive state; this segment
- * just says "the card renders HERE in the timeline". The `reasoning` kind (ADR-0045
- * amendment, #202) is now realized — the model's thinking, default-collapsed, with
- * an optional `durationMs` (web-clocked live open→seal, Core-computed on reload). It
- * is EXCLUDED from {@link concatText}, so the trace never leaks into the reply text.
- * The `attachment` kind (ADR-0058) is an image on a user Message — the bytes live
- * at `GET /media/{mediaId}`; `width`/`height` are pixel dims when known. It carries
- * no text, so {@link concatText} excludes it by construction.
- */
-export type Segment =
- | { readonly kind: "text"; readonly text: string }
- | { readonly kind: "tool_call"; readonly call: ToolCall }
- | { readonly kind: "proposal"; readonly runId: string }
- | {
- readonly kind: "reasoning";
- readonly text: string;
- readonly durationMs?: number;
- }
- | {
- readonly kind: "attachment";
- readonly mediaId: string;
- readonly mime: string;
- readonly width?: number;
- readonly height?: number;
- };
+import {
+ appendProposalSegment,
+ appendReasoningSegment,
+ appendTextSegment,
+ concatText,
+ type Segment,
+ sealOpenReasoning,
+ settleRunningToolSegments,
+ type ToolCall,
+ toSegment,
+ upsertToolSegment,
+} from "./timeline.js";
+
+// The timeline model + its pure reducers live in ./timeline.ts (review F3);
+// re-export the public surface so existing `@/store/chat` importers (ToolCall,
+// Segment, concatText, toSegment, …) are unaffected by the split.
+export {
+ concatText,
+ type Segment,
+ type ToolCall,
+ toSegment,
+ type WireSegment,
+} from "./timeline.js";
/** The canonical live UI message; mirrors the wire `MessageView` shape. */
export interface Message {
@@ -71,17 +55,6 @@ export interface Message {
readonly cancelled?: boolean;
}
-/** Concatenate the text of every `text` segment in order — the single source for the
- * flat reply text the copy button, ⌘K search-match, typing-indicator, and retry read
- * (ADR-0045: there is no denormalized flat `text`; it derives from segments). */
-export function concatText(segments: readonly Segment[]): string {
- let text = "";
- for (const seg of segments) {
- if (seg.kind === "text") text += seg.text;
- }
- return text;
-}
-
/**
* Reactive hydrate-on-focus lifecycle (replaces the old non-reactive Set):
* `loading` while `thread/get` is in flight, `error` on a transient failed fetch
@@ -464,123 +437,6 @@ export function resetMessageForRetry(threadId: string, runId: string): void {
// The web mirror of Core's run_steps sequencer: each Run Event extends the ordered
// `segments[]` so the live render is the same shape the reload will read (slice 3).
-/**
- * Thread a `text_delta` into the timeline (ADR-0045), mirroring the flat-text
- * SET-vs-APPEND rule (ADR-0022) so `concatText(segments) === flat text` always holds:
- *
- * - **APPEND** (disarmed tail): extend the OPEN trailing text segment; if the trailing
- * segment is non-text (a tool/proposal just sealed the run) or the timeline is empty,
- * OPEN a fresh text segment — the web mirror of Core's open-on-first-delta.
- * - **SET** (armed cumulative snapshot): the delta is the cumulative concat of ALL the
- * turn's text so far (`group_concat`, no boundary markers — `select_run_snapshot`), so
- * it replaces EVERY existing text segment, not just the trailing one. Collapse all text
- * segments into ONE carrying the snapshot at the position of the FIRST text segment, and
- * drop the rest — PRESERVING the interleaved tool_call/proposal segments' order. If no
- * text segment exists yet, OPEN one at the end. Replacing only the last text segment (the
- * prior rule) left earlier text segments in place, so a post-park resume snapshot that
- * re-includes pre-park prose DUPLICATED it (concatText = "A" + "A B" ≠ flat "A B").
- */
-function appendTextSegment(
- segments: readonly Segment[],
- delta: string,
- armed: boolean,
-): readonly Segment[] {
- if (armed) {
- return setCumulativeText(segments, delta);
- }
- const last = segments[segments.length - 1];
- if (last?.kind === "text") {
- return [
- ...segments.slice(0, -1),
- { kind: "text", text: last.text + delta },
- ];
- }
- return [...segments, { kind: "text", text: delta }];
-}
-
-/**
- * Reconcile a cumulative-snapshot SET into the timeline: the snapshot is the WHOLE
- * turn's text, so the result has exactly one text segment carrying it (at the first
- * existing text position) and keeps every non-text segment in its place. With no text
- * segment yet, the snapshot opens one at the end. This is what makes
- * `concatText(segments) === snapshot` hold even when the turn had multiple pre-snapshot
- * text runs (text→tool→text→park→resume) — the duplicated-prefix case the prior
- * last-text-only rule missed.
- */
-function setCumulativeText(
- segments: readonly Segment[],
- snapshot: string,
-): readonly Segment[] {
- const firstTextIndex = segments.findIndex((seg) => seg.kind === "text");
- if (firstTextIndex === -1) {
- return [...segments, { kind: "text", text: snapshot }];
- }
- const result: Segment[] = [];
- for (let i = 0; i < segments.length; i++) {
- const seg = segments[i];
- if (i === firstTextIndex) {
- result.push({ kind: "text", text: snapshot });
- } else if (seg.kind !== "text") {
- result.push(seg);
- }
- // Drop every other text segment — its content is already in the snapshot.
- }
- return result;
-}
-
-/**
- * Thread a `reasoning_delta` into the timeline (ADR-0045 amendment): APPEND-ONLY,
- * the disarmed twin of {@link appendTextSegment}. There is NO armed cumulative-SET
- * path — the resume snapshot is text-only (`type='text'` SQL filter), so a reasoning
- * segment never receives a snapshot delta. If the trailing segment is `reasoning`,
- * extend its text (`opened: false`); else OPEN a fresh reasoning segment (`opened:
- * true`, the web mirror of Core's open-on-first-delta). A text/tool/proposal between
- * two reasoning runs correctly opens a new one. The `opened` flag is the single source
- * of "did a fresh block start here" — `applyEvent` uses it to (re)stamp the block's
- * open-time, rather than re-deriving the trailing-segment check separately.
- */
-function appendReasoningSegment(
- segments: readonly Segment[],
- delta: string,
-): { segments: readonly Segment[]; opened: boolean } {
- const last = segments[segments.length - 1];
- if (last?.kind === "reasoning") {
- return {
- segments: [
- ...segments.slice(0, -1),
- { kind: "reasoning", text: last.text + delta },
- ],
- opened: false,
- };
- }
- return {
- segments: [...segments, { kind: "reasoning", text: delta }],
- opened: true,
- };
-}
-
-/** Seal the OPEN trailing reasoning segment with a web-clocked `durationMs` when a Run
- * terminates (ADR-0045 amendment: live clocks its own open→seal). No-op if the trailing
- * segment is not reasoning, already sealed, or no open-time was recorded — the reloaded
- * path carries Core's authoritative `duration_ms`, so live is a nicety. */
-function sealOpenReasoning(
- segments: readonly Segment[],
- openedAt: number | undefined,
- now: number,
-): readonly Segment[] {
- if (openedAt === undefined) {
- return segments;
- }
- const last = segments[segments.length - 1];
- if (last?.kind !== "reasoning" || last.durationMs !== undefined) {
- return segments;
- }
- return [
- ...segments.slice(0, -1),
- { kind: "reasoning", text: last.text, durationMs: now - openedAt },
- ];
-}
-
/**
* A timeline boundary arrived for `runId` (a text delta, a new tool call, or the Run
* terminal): seal the open reasoning block with its web-clocked `durationMs` AND clear
@@ -616,51 +472,6 @@ function sealReasoningAtBoundary(
};
}
-/** Upsert a `tool_call` segment by call id (ADR-0045): a new id appends a fresh
- * segment at the end of the timeline; a known id flips its call's status in place. */
-function upsertToolSegment(
- segments: readonly Segment[],
- call: ToolCall,
-): readonly Segment[] {
- const found = segments.some(
- (seg) => seg.kind === "tool_call" && seg.call.id === call.id,
- );
- if (!found) {
- return [...segments, { kind: "tool_call", call }];
- }
- return segments.map((seg) =>
- seg.kind === "tool_call" && seg.call.id === call.id
- ? { kind: "tool_call", call: { ...seg.call, status: call.status } }
- : seg,
- );
-}
-
-/** Settle any `running` tool_call SEGMENT to `terminal` when its Run ends (the
- * segment-aware twin of {@link settleRunningToolCalls}; the lost-boundary case). */
-function settleRunningToolSegments(
- segments: readonly Segment[],
- terminal: "completed" | "error",
-): readonly Segment[] {
- return segments.map((seg) =>
- seg.kind === "tool_call" && seg.call.status === "running"
- ? { kind: "tool_call", call: { ...seg.call, status: terminal } }
- : seg,
- );
-}
-
-/** Append a `proposal` segment for `runId` at the current end of the timeline,
- * unless one is already present (skip-if-present): the seam where a Proposal enters
- * the timeline (it does NOT flow through {@link applyEvent}) — see {@link setPendingProposal}. */
-function appendProposalSegment(
- segments: readonly Segment[],
- runId: string,
-): readonly Segment[] {
- if (segments.some((seg) => seg.kind === "proposal")) {
- return segments;
- }
- return [...segments, { kind: "proposal", runId }];
-}
-
/** Attach a `proposal` segment (skip-if-present) to the assistant message owning
* `runId` within its thread — the {@link setPendingProposal} / {@link
* rehydrateDecidedProposal} timeline seam, shared so both enter the timeline identically.
@@ -766,6 +577,42 @@ export function applyEvent(
return s;
}
+ if (event.kind === "snapshot") {
+ // Full-timeline snapshot (review P1 #2): atomically REPLACE the run's
+ // segments with the ordered wire timeline (text / reasoning / tool_call
+ // in run_steps order, incl. a still-running call), then DISARM the
+ // cumulative-text bit so subsequent tail `text_delta`s APPEND to the
+ // snapshot rather than SET over it. This is the reconnect authority —
+ // it supersedes whatever `thread/get` painted, in true order.
+ const segments = event.segments.map((seg) => toSegment(runId, seg));
+ const next = updateRunMessage(s, threadId, runId, (m) => ({
+ ...m,
+ segments,
+ }));
+ const run = next.runs[runId];
+ if (run === undefined) {
+ return next;
+ }
+ // If the snapshot's timeline ENDS with an OPEN reasoning block
+ // (streaming, no `durationMs`), re-anchor `reasoningOpenedAt` so the next
+ // boundary can seal it — the wire snapshot carries no open-time, so
+ // without this the block stays unsealed forever (review F4). Any other
+ // trailing segment clears it. Re-anchoring to `now` times a post-reconnect
+ // seal from the snapshot — the best available once the true open-time is lost.
+ const last = segments[segments.length - 1];
+ const reasoningOpenedAt =
+ last?.kind === "reasoning" && last.durationMs === undefined
+ ? now
+ : undefined;
+ return {
+ ...next,
+ runs: {
+ ...next.runs,
+ [runId]: { ...run, snapshotArmed: false, reasoningOpenedAt },
+ },
+ };
+ }
+
if (event.kind === "text_delta") {
// A text delta means the model finished any open reasoning block: seal it
// with a web-clocked duration NOW (not at terminal) so the disclosure reads
@@ -830,13 +677,15 @@ export function applyEvent(
? sealReasoningAtBoundary(s, threadId, runId, now)
: s;
// Upsert into the timeline: `started` appends a `running` segment, a
- // terminal status flips the matching one in place (ADR-0045).
+ // terminal status flips the matching one in place (ADR-0045), merging
+ // the model-received `result` when the event carries one (A4).
const status = event.status === "started" ? "running" : event.status;
const call: ToolCall = {
id: event.tool_call_id,
name: event.name,
status,
arg: event.arg,
+ result: event.result,
};
return updateRunMessage(sealed, threadId, runId, (m) => ({
...m,
diff --git a/apps/web/src/store/hydrate.ts b/apps/web/src/store/hydrate.ts
index bbe27ed1..30df5c69 100644
--- a/apps/web/src/store/hydrate.ts
+++ b/apps/web/src/store/hydrate.ts
@@ -18,67 +18,9 @@ import {
rehydrateDecidedProposal,
type Segment,
setHydrationStatus,
+ toSegment,
} from "./chat.js";
-type WireSegment = ThreadGetResult["messages"][number]["segments"][number];
-
-/** A persisted tool call's wire status maps to a live `tool_call` segment status:
- * `error` keeps its spelling, anything else (a rehydrated call is `completed`)
- * settles to `completed`. A rehydrated call is never `running`. */
-function toToolCallStatus(status: string): "completed" | "error" {
- return status === "error" ? "error" : "completed";
-}
-
-/** Map one wire `Segment` to a live store {@link Segment} (ADR-0045), preserving
- * its timeline position. A `tool_call` segment carries no id (the durable record
- * has one, but the live row keys only on render order), so synthesize a stable
- * `:seg:` id from its index, keeping React keys distinct. The wire
- * `proposal` segment becomes a positional `{kind:"proposal", runId}` marker — the
- * decided card's interactive state lives in the `proposals` map, seeded separately
- * by {@link rehydrateDecidedProposals}. */
-function toSegment(
- messageId: string,
- runId: string,
- segment: WireSegment,
- index: number,
-): Segment {
- switch (segment.kind) {
- case "text":
- return { kind: "text", text: segment.text };
- case "tool_call":
- return {
- kind: "tool_call",
- call: {
- id: `${messageId}:seg:${index}`,
- name: segment.name,
- status: toToolCallStatus(segment.status),
- arg: segment.arg,
- },
- };
- case "proposal":
- return { kind: "proposal", runId };
- case "reasoning":
- // The model's thinking trace (ADR-0045 amendment): the wire carries Core's
- // computed `duration_ms`; store it as `durationMs`. Excluded from concatText.
- return {
- kind: "reasoning",
- text: segment.text,
- durationMs: segment.duration_ms,
- };
- case "attachment":
- // An image on a user Message (ADR-0058): the bytes live at
- // `GET /media/{media_id}`; `width`/`height` are omitted (not null) when the
- // upload didn't supply them. Carries no text, so concatText excludes it.
- return {
- kind: "attachment",
- mediaId: segment.media_id,
- mime: segment.mime,
- width: segment.width,
- height: segment.height,
- };
- }
-}
-
/** Map a wire `MessageView` to the live {@link Message}, narrowing role/status via
* defensive guards. The ordered `segments[]` is consumed VERBATIM (ADR-0045): the
* wire already carries the true `run_steps` order, so the reload renders the same
@@ -99,8 +41,8 @@ export function toMessage(view: ThreadGetResult["messages"][number]): Message {
// it must never be flagged.
const cancelled =
status === "incomplete" && view.terminal_reason === "cancelled";
- const segments: Segment[] = view.segments.map((segment, i) =>
- toSegment(view.id, view.run_id, segment, i),
+ const segments: Segment[] = view.segments.map((segment) =>
+ toSegment(view.run_id, segment),
);
return {
id: view.id,
diff --git a/apps/web/src/store/timeline.ts b/apps/web/src/store/timeline.ts
new file mode 100644
index 00000000..deb84679
--- /dev/null
+++ b/apps/web/src/store/timeline.ts
@@ -0,0 +1,289 @@
+import type { ThreadGetResult, TranscriptToolResult } from "@inkstone/protocol";
+
+// The assistant-turn TIMELINE model + its pure reducers (ADR-0045), extracted
+// from the Zustand store module (review F3): the wire→segment mapping and the
+// segment-array transforms that build a turn's ordered timeline. Every function
+// here is PURE over `Segment[]` — no store/`ChatState` dependency — so the store
+// module (`chat.ts`) owns only state wiring, and these reducers are unit-testable
+// in isolation. `chat.ts` re-exports the public types + `toSegment`/`concatText`
+// so existing `@/store/chat` importers are unaffected.
+
+/** A tool call surfaced live within an assistant turn (ADR-0006 tool_call Run Event). */
+export interface ToolCall {
+ readonly id: string;
+ readonly name: string;
+ readonly status: "running" | "completed" | "error";
+ /** The tool's display argument (ADR-0043), e.g. a search query; absent for argless tools. */
+ readonly arg?: string;
+ /** The normalized result the model received (external-task-views A4):
+ * carried on terminal events of external (`ticktick_*`) calls so the
+ * collapsed row can expand to it — identically live and after reload. */
+ readonly result?: TranscriptToolResult;
+}
+
+/**
+ * One item in an assistant turn's ordered timeline (ADR-0045): a contiguous run
+ * of text, a tool-call boundary, a positional marker for the Proposal card, or a
+ * `reasoning` (thinking) trace. The `proposal` segment carries ONLY `runId` — the
+ * {@link PendingProposal} map stays the source of interactive state; this segment
+ * just says "the card renders HERE in the timeline". The `reasoning` kind (ADR-0045
+ * amendment, #202) is now realized — the model's thinking, default-collapsed, with
+ * an optional `durationMs` (web-clocked live open→seal, Core-computed on reload). It
+ * is EXCLUDED from {@link concatText}, so the trace never leaks into the reply text.
+ * The `attachment` kind (ADR-0058) is an image on a user Message — the bytes live
+ * at `GET /media/{mediaId}`; `width`/`height` are pixel dims when known. It carries
+ * no text, so {@link concatText} excludes it by construction.
+ */
+export type Segment =
+ | { readonly kind: "text"; readonly text: string }
+ | { readonly kind: "tool_call"; readonly call: ToolCall }
+ | { readonly kind: "proposal"; readonly runId: string }
+ | {
+ readonly kind: "reasoning";
+ readonly text: string;
+ readonly durationMs?: number;
+ }
+ | {
+ readonly kind: "attachment";
+ readonly mediaId: string;
+ readonly mime: string;
+ readonly width?: number;
+ readonly height?: number;
+ };
+
+/** One wire `Segment` — from a `thread/get` result OR a `run/subscribe`
+ * `snapshot` Run Event (both carry the same `protocol::Segment`). */
+export type WireSegment =
+ ThreadGetResult["messages"][number]["segments"][number];
+
+/** A wire tool-call status → the live `tool_call` segment status. `running` (a
+ * live `snapshot`'s in-flight call, review P1 #2) and `error` keep their
+ * spelling; anything else (a rehydrated `thread/get` call is `completed`)
+ * settles to `completed`. */
+function toToolCallStatus(status: string): "running" | "completed" | "error" {
+ if (status === "running") return "running";
+ if (status === "error") return "error";
+ return "completed";
+}
+
+/** Map one wire `Segment` to a live store {@link Segment} (ADR-0045), preserving
+ * its timeline position — SHARED by `thread/get` rehydration and the
+ * `run/subscribe` `snapshot` event (review P1 #2). A `tool_call` keys on the
+ * durable `tool_call_id` (external-task-views A4) and carries the model-received
+ * `result`; a wire `proposal` becomes a positional `{kind:"proposal", runId}`
+ * marker (its interactive state lives in the `proposals` map, seeded separately). */
+export function toSegment(runId: string, segment: WireSegment): Segment {
+ switch (segment.kind) {
+ case "text":
+ return { kind: "text", text: segment.text };
+ case "tool_call":
+ return {
+ kind: "tool_call",
+ call: {
+ id: segment.tool_call_id,
+ name: segment.name,
+ status: toToolCallStatus(segment.status),
+ arg: segment.arg,
+ result: segment.result,
+ },
+ };
+ case "proposal":
+ return { kind: "proposal", runId };
+ case "reasoning":
+ return {
+ kind: "reasoning",
+ text: segment.text,
+ durationMs: segment.duration_ms,
+ };
+ case "attachment":
+ return {
+ kind: "attachment",
+ mediaId: segment.media_id,
+ mime: segment.mime,
+ width: segment.width,
+ height: segment.height,
+ };
+ }
+}
+
+/** Concatenate the text of every `text` segment in order — the single source for the
+ * flat reply text the copy button, ⌘K search-match, typing-indicator, and retry read
+ * (ADR-0045: there is no denormalized flat `text`; it derives from segments). */
+export function concatText(segments: readonly Segment[]): string {
+ let text = "";
+ for (const seg of segments) {
+ if (seg.kind === "text") text += seg.text;
+ }
+ return text;
+}
+
+/**
+ * Thread a `text_delta` into the timeline (ADR-0045), mirroring the flat-text
+ * SET-vs-APPEND rule (ADR-0022) so `concatText(segments) === flat text` always holds:
+ *
+ * - **APPEND** (disarmed tail): extend the OPEN trailing text segment; if the trailing
+ * segment is non-text (a tool/proposal just sealed the run) or the timeline is empty,
+ * OPEN a fresh text segment — the web mirror of Core's open-on-first-delta.
+ * - **SET** (armed cumulative snapshot): the delta is the cumulative concat of ALL the
+ * turn's text so far (`group_concat`, no boundary markers — `select_run_snapshot`), so
+ * it replaces EVERY existing text segment, not just the trailing one. Collapse all text
+ * segments into ONE carrying the snapshot at the position of the FIRST text segment, and
+ * drop the rest — PRESERVING the interleaved tool_call/proposal segments' order. If no
+ * text segment exists yet, OPEN one at the end. Replacing only the last text segment (the
+ * prior rule) left earlier text segments in place, so a post-park resume snapshot that
+ * re-includes pre-park prose DUPLICATED it (concatText = "A" + "A B" ≠ flat "A B").
+ */
+export function appendTextSegment(
+ segments: readonly Segment[],
+ delta: string,
+ armed: boolean,
+): readonly Segment[] {
+ if (armed) {
+ return setCumulativeText(segments, delta);
+ }
+ const last = segments[segments.length - 1];
+ if (last?.kind === "text") {
+ return [
+ ...segments.slice(0, -1),
+ { kind: "text", text: last.text + delta },
+ ];
+ }
+ return [...segments, { kind: "text", text: delta }];
+}
+
+/**
+ * Reconcile a cumulative-snapshot SET into the timeline: the snapshot is the WHOLE
+ * turn's text, so the result has exactly one text segment carrying it (at the first
+ * existing text position) and keeps every non-text segment in its place. With no text
+ * segment yet, the snapshot opens one at the end. This is what makes
+ * `concatText(segments) === snapshot` hold even when the turn had multiple pre-snapshot
+ * text runs (text→tool→text→park→resume) — the duplicated-prefix case the prior
+ * last-text-only rule missed.
+ */
+function setCumulativeText(
+ segments: readonly Segment[],
+ snapshot: string,
+): readonly Segment[] {
+ const firstTextIndex = segments.findIndex((seg) => seg.kind === "text");
+ if (firstTextIndex === -1) {
+ return [...segments, { kind: "text", text: snapshot }];
+ }
+ const result: Segment[] = [];
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i];
+ if (i === firstTextIndex) {
+ result.push({ kind: "text", text: snapshot });
+ } else if (seg.kind !== "text") {
+ result.push(seg);
+ }
+ // Drop every other text segment — its content is already in the snapshot.
+ }
+ return result;
+}
+
+/**
+ * Thread a `reasoning_delta` into the timeline (ADR-0045 amendment): APPEND-ONLY,
+ * the disarmed twin of {@link appendTextSegment}. There is NO armed cumulative-SET
+ * path — the resume snapshot is text-only (`type='text'` SQL filter), so a reasoning
+ * segment never receives a snapshot delta. If the trailing segment is `reasoning`,
+ * extend its text (`opened: false`); else OPEN a fresh reasoning segment (`opened:
+ * true`, the web mirror of Core's open-on-first-delta). A text/tool/proposal between
+ * two reasoning runs correctly opens a new one. The `opened` flag is the single source
+ * of "did a fresh block start here" — `applyEvent` uses it to (re)stamp the block's
+ * open-time, rather than re-deriving the trailing-segment check separately.
+ */
+export function appendReasoningSegment(
+ segments: readonly Segment[],
+ delta: string,
+): { segments: readonly Segment[]; opened: boolean } {
+ const last = segments[segments.length - 1];
+ if (last?.kind === "reasoning") {
+ return {
+ segments: [
+ ...segments.slice(0, -1),
+ { kind: "reasoning", text: last.text + delta },
+ ],
+ opened: false,
+ };
+ }
+ return {
+ segments: [...segments, { kind: "reasoning", text: delta }],
+ opened: true,
+ };
+}
+
+/** Seal the OPEN trailing reasoning segment with a web-clocked `durationMs` when a Run
+ * terminates (ADR-0045 amendment: live clocks its own open→seal). No-op if the trailing
+ * segment is not reasoning, already sealed, or no open-time was recorded — the reloaded
+ * path carries Core's authoritative `duration_ms`, so live is a nicety. */
+export function sealOpenReasoning(
+ segments: readonly Segment[],
+ openedAt: number | undefined,
+ now: number,
+): readonly Segment[] {
+ if (openedAt === undefined) {
+ return segments;
+ }
+ const last = segments[segments.length - 1];
+ if (last?.kind !== "reasoning" || last.durationMs !== undefined) {
+ return segments;
+ }
+ return [
+ ...segments.slice(0, -1),
+ { kind: "reasoning", text: last.text, durationMs: now - openedAt },
+ ];
+}
+
+/** Upsert a `tool_call` segment by call id (ADR-0045): a new id appends a fresh
+ * segment at the end of the timeline; a known id merges the terminal `status`
+ * AND `result` into the existing call in place (external-task-views A4 — the
+ * started row keeps its fields; the terminal event settles them). */
+export function upsertToolSegment(
+ segments: readonly Segment[],
+ call: ToolCall,
+): readonly Segment[] {
+ const found = segments.some(
+ (seg) => seg.kind === "tool_call" && seg.call.id === call.id,
+ );
+ if (!found) {
+ return [...segments, { kind: "tool_call", call }];
+ }
+ return segments.map((seg) =>
+ seg.kind === "tool_call" && seg.call.id === call.id
+ ? {
+ kind: "tool_call",
+ call: {
+ ...seg.call,
+ status: call.status,
+ ...(call.result === undefined ? {} : { result: call.result }),
+ },
+ }
+ : seg,
+ );
+}
+
+/** Settle any `running` tool_call SEGMENT to `terminal` when its Run ends (the
+ * segment-aware twin of `settleRunningToolCalls`; the lost-boundary case). */
+export function settleRunningToolSegments(
+ segments: readonly Segment[],
+ terminal: "completed" | "error",
+): readonly Segment[] {
+ return segments.map((seg) =>
+ seg.kind === "tool_call" && seg.call.status === "running"
+ ? { kind: "tool_call", call: { ...seg.call, status: terminal } }
+ : seg,
+ );
+}
+
+/** Append a `proposal` segment for `runId` at the current end of the timeline,
+ * unless one is already present (skip-if-present): the seam where a Proposal enters
+ * the timeline (it does NOT flow through `applyEvent`) — see `setPendingProposal`. */
+export function appendProposalSegment(
+ segments: readonly Segment[],
+ runId: string,
+): readonly Segment[] {
+ if (segments.some((seg) => seg.kind === "proposal")) {
+ return segments;
+ }
+ return [...segments, { kind: "proposal", runId }];
+}
diff --git a/apps/web/test/components/ChatColumn.test.tsx b/apps/web/test/components/ChatColumn.test.tsx
index 46b71c7b..26bf7cb6 100644
--- a/apps/web/test/components/ChatColumn.test.tsx
+++ b/apps/web/test/components/ChatColumn.test.tsx
@@ -14,7 +14,7 @@ import {
} from "@test/test-utils/renderWithCore";
import { act, cleanup, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { Deferred, Effect, Stream } from "effect";
+import { Deferred, Effect, Queue, Stream } from "effect";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatColumn } from "@/components/ChatColumn.js";
import {
@@ -66,7 +66,8 @@ function makeStubOverrides(opts: {
subscribeRun: () => Stream.fromIterable(opts.events),
cancelRun:
opts.cancelRun ??
- (() => Effect.succeed({ outcome: "accepted" as const })),
+ (() =>
+ Effect.succeed({ outcome: "accepted" as const, live_tail: false })),
// Fail fast on an UNEXPECTED retry path: a test that drives run/retry must
// opt in via `opts.retryRun`; an accidental retry in an unrelated test dies
// rather than silently passing on a default "accepted" (CodeRabbit #244).
@@ -857,17 +858,33 @@ describe("ChatColumn", () => {
it("shows a Stop control while a run streams and settles the bubble on cancel", async () => {
const user = userEvent.setup();
+ // The stream stays OPEN after the partial delta (a live tail), and — per
+ // the A4 cancel contract — Core delivers the real `cancelled` event right
+ // behind an accepted response; the bridge now applies THAT instead of
+ // synthesizing its own (which would clobber interrupted tool results).
+ const tail = Effect.runSync(Queue.unbounded());
const cancelRun = vi.fn(() =>
- Effect.succeed({ outcome: "accepted" as const }),
+ Effect.sync(() => {
+ Queue.unsafeOffer(tail, { kind: "cancelled" } as RunEventValue);
+ // live_tail: true — the live stream delivers the real `cancelled`.
+ return { outcome: "accepted" as const, live_tail: true };
+ }),
);
- // A partial (non-terminal) stream: the assistant turn stays active (no
- // done/error/cancelled), so activeRunId stays set and Stop is shown.
- const overrides = makeStubOverrides({
- runId: "run-stop",
- threadId: "thread-stop",
- events: [{ kind: "text_delta", delta: "echo: h" }],
- cancelRun,
- });
+ const overrides = {
+ ...makeStubOverrides({
+ runId: "run-stop",
+ threadId: "thread-stop",
+ events: [],
+ cancelRun,
+ }),
+ subscribeRun: () =>
+ Stream.concat(
+ Stream.fromIterable([
+ { kind: "text_delta", delta: "echo: h" } as RunEventValue,
+ ]),
+ Stream.fromQueue(tail),
+ ),
+ };
await renderFocused(overrides, "threadA");
diff --git a/apps/web/test/components/ToolActivity.test.tsx b/apps/web/test/components/ToolActivity.test.tsx
index 06538c02..b7784dd4 100644
--- a/apps/web/test/components/ToolActivity.test.tsx
+++ b/apps/web/test/components/ToolActivity.test.tsx
@@ -1,4 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { groupToolCalls, ToolActivity } from "@/components/ToolActivity.js";
import type { ToolCall } from "@/store/chat";
@@ -143,3 +144,115 @@ describe("ToolActivity grouped rendering", () => {
expect(row).toHaveTextContent("+1");
});
});
+
+// ── External (`ticktick_*`) calls — external-task-views A4 ──────────────────
+
+const externalResult = (text: string, isError = false) => ({
+ content: [{ type: "text" as const, text }],
+ is_error: isError,
+});
+
+describe("external calls never group", () => {
+ it("two same-name external calls stay two rows, keyed by call id, results distinct", () => {
+ const groups = groupToolCalls([
+ call({
+ id: "tc_a",
+ name: "ticktick_search_task",
+ result: externalResult("first result"),
+ }),
+ call({
+ id: "tc_b",
+ name: "ticktick_search_task",
+ result: externalResult("second result"),
+ }),
+ ]);
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.key)).toEqual(["tc_a", "tc_b"]);
+ expect(groups[0].call?.result?.content[0].text).toBe("first result");
+ expect(groups[1].call?.result?.content[0].text).toBe("second result");
+ });
+
+ it("an external call never merges into a same-position Core group", () => {
+ const groups = groupToolCalls([
+ call({ id: "1", arg: "Lev" }),
+ call({ id: "tc_ext", name: "ticktick_filter_tasks" }),
+ call({ id: "2", arg: "Acme" }),
+ ]);
+ // Core calls still merge around it; the external row keeps its slot.
+ expect(groups.map((g) => g.key)).toEqual([
+ "group:search_entities",
+ "tc_ext",
+ ]);
+ expect(groups[1].call?.name).toBe("ticktick_filter_tasks");
+ });
+});
+
+describe("external expandable row", () => {
+ it("renders collapsed, expands to the model-received content, and collapses back", async () => {
+ render(
+ ,
+ );
+ const row = screen.getByTestId("tool-call");
+ expect(row).toHaveAttribute("data-status", "completed");
+ // Collapsed by default: the content is NOT in the document.
+ expect(screen.queryByTestId("tool-call-result")).toBeNull();
+
+ const toggle = screen.getByRole("button");
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(toggle);
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByTestId("tool-call-result")).toHaveTextContent(
+ "1 task found: S1 timed",
+ );
+
+ await userEvent.click(toggle);
+ expect(screen.queryByTestId("tool-call-result")).toBeNull();
+ });
+
+ it("an errored external call renders the error row and expands to the error content — identically", async () => {
+ render(
+ ,
+ );
+ const row = screen.getByTestId("tool-call");
+ expect(row).toHaveAttribute("data-status", "error");
+ expect(row).toHaveTextContent("failed");
+
+ await userEvent.click(screen.getByRole("button"));
+ expect(screen.getByTestId("tool-call-result")).toHaveTextContent(
+ "interrupted",
+ );
+ });
+
+ it("a running external row is not expandable yet (no result)", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button")).toBeDisabled();
+ expect(screen.queryByTestId("tool-call-result")).toBeNull();
+ });
+});
diff --git a/apps/web/test/components/library/TasksView.test.tsx b/apps/web/test/components/library/TasksView.test.tsx
new file mode 100644
index 00000000..9141cd6d
--- /dev/null
+++ b/apps/web/test/components/library/TasksView.test.tsx
@@ -0,0 +1,228 @@
+import type { TickTickTaskRow } from "@inkstone/protocol";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it } from "vitest";
+import { TasksView, type TasksViewProps } from "@/components/library/TasksView";
+
+afterEach(cleanup);
+
+function row(
+ partial: Partial & { id: string },
+): TickTickTaskRow {
+ return {
+ title: "a task",
+ kind: "TEXT",
+ priority: 0,
+ tags: [],
+ checklist_items: [],
+ ...partial,
+ };
+}
+
+function view(overrides: Partial = {}): TasksViewProps {
+ return {
+ connected: true,
+ statusResolved: true,
+ statusError: false,
+ rows: [],
+ sourceLimitReached: false,
+ tasksInitialError: false,
+ tasksStaleError: false,
+ tasksLoading: false,
+ refresh: () => {},
+ refreshing: false,
+ ...overrides,
+ };
+}
+
+describe("TasksView", () => {
+ it("shows the not-connected notice when status resolved disconnected", () => {
+ render();
+ expect(screen.getByTestId("ticktick-disconnected")).toBeInTheDocument();
+ });
+
+ it("shows the error state on a status-read failure (not 'No tasks.')", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("ticktick-error")).toBeInTheDocument();
+ expect(screen.queryByText("No tasks.")).toBeNull();
+ });
+
+ it("an INITIAL task failure (no rows) shows the error state", () => {
+ render();
+ expect(screen.getByTestId("ticktick-error")).toBeInTheDocument();
+ });
+
+ it("offers a manual refresh that fires the command (A2, review R12 #4)", async () => {
+ let refreshed = 0;
+ render(
+ {
+ refreshed += 1;
+ },
+ })}
+ />,
+ );
+ await userEvent.click(screen.getByTestId("ticktick-refresh"));
+ expect(refreshed).toBe(1);
+ });
+
+ it("disables the refresh control while a refresh is in flight", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("ticktick-refresh")).toBeDisabled();
+ });
+
+ it("the error states offer a Retry that fires the refresh command", async () => {
+ let refreshed = 0;
+ render(
+ {
+ refreshed += 1;
+ },
+ })}
+ />,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(refreshed).toBe(1);
+ });
+
+ it("shows loading (never a false 'No tasks.') while the status read is pending", () => {
+ // The first status fetch is in flight: not resolved, not connected, no rows,
+ // no task-loading flag yet (the task read is still gated) — review M5.
+ render(
+ ,
+ );
+ expect(screen.getByText("Loading tasks…")).toBeInTheDocument();
+ expect(screen.queryByText("No tasks.")).toBeNull();
+ });
+
+ it("a STALE refetch failure keeps the last-good rows with a stale indicator", () => {
+ render(
+ ,
+ );
+ // The rows survive a failed background refetch (A2 failure semantics)…
+ expect(screen.getByTestId("ticktick-task")).toHaveTextContent("buy milk");
+ // …with a stale indicator, NOT the full error screen.
+ expect(screen.getByTestId("ticktick-stale-warning")).toBeInTheDocument();
+ expect(screen.queryByTestId("ticktick-error")).toBeNull();
+ });
+
+ it("renders the truncation warning ONLY when the source limit was reached", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.queryByTestId("ticktick-truncation-warning")).toBeNull();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByTestId("ticktick-truncation-warning")).toHaveTextContent(
+ "200-item limit",
+ );
+ });
+
+ it("renders one row per task, with an unmatched list shown as 'unnamed list'", () => {
+ render(
+ ,
+ );
+ const rows = screen.getAllByTestId("ticktick-task");
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toHaveTextContent("buy milk");
+ expect(rows[0]).toHaveTextContent("Inbox");
+ expect(rows[1]).toHaveTextContent("unnamed list");
+ });
+
+ it("filters LOCALLY by title over the one fetched result (A2 display-only filtering)", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ expect(screen.getAllByTestId("ticktick-task")).toHaveLength(2);
+ await user.type(
+ screen.getByRole("searchbox", { name: /filter tasks/i }),
+ "milk",
+ );
+ const rows = screen.getAllByTestId("ticktick-task");
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toHaveTextContent("buy milk");
+ });
+
+ it("renders an all-day due in its own timezone, not UTC (Asia/Shanghai regression)", () => {
+ // An all-day 2026-08-20 in Asia/Shanghai (+8) is stored as the UTC instant
+ // of local midnight: 2026-08-19T16:00:00Z. Formatting in UTC would show
+ // Aug 19 (the previous day); the local zone must show Aug 20.
+ const date = "2026-08-19T16:00:00.000+0000";
+ render(
+ ,
+ );
+ const rowText = screen.getByTestId("ticktick-task").textContent ?? "";
+ const shanghai = new Date(date).toLocaleDateString(undefined, {
+ timeZone: "Asia/Shanghai",
+ });
+ const utc = new Date(date).toLocaleDateString(undefined, {
+ timeZone: "UTC",
+ });
+ expect(shanghai).not.toBe(utc); // the two zones disagree on the calendar day
+ expect(rowText).toContain(shanghai);
+ expect(rowText).not.toContain(utc);
+ });
+
+ it("shows a checklist progress count", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("ticktick-task")).toHaveTextContent("1/2");
+ });
+});
diff --git a/apps/web/test/lib/hooks/useTickTick.test.tsx b/apps/web/test/lib/hooks/useTickTick.test.tsx
new file mode 100644
index 00000000..899db86e
--- /dev/null
+++ b/apps/web/test/lib/hooks/useTickTick.test.tsx
@@ -0,0 +1,341 @@
+import type {
+ TickTickStatusResult,
+ TickTickTasksListResult,
+} from "@inkstone/protocol";
+import type { ConnectionStatus } from "@inkstone/ui-sdk";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { makeCoreRuntime } from "@test/test-utils/renderWithCore";
+import { act, render, renderHook, waitFor } from "@testing-library/react";
+import { Effect, Queue, Stream } from "effect";
+import type { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import {
+ classifyTasksError,
+ TickTickReconnectSync,
+ useTickTick,
+} from "@/lib/hooks/useTickTick.js";
+import { RuntimeProvider } from "@/runtime";
+
+// The A2 reconnect protocol splits across two homes: `useTickTick` GATES the task
+// read on the connection id, and the app-lifetime `TickTickReconnectSync` PURGES
+// the cache on a real WS reconnect edge — NOT a mount's replayed `connected`
+// (review F2/F5). Both are pinned here, deterministically (no stream timing).
+
+function wrapper(opts: {
+ status?: TickTickStatusResult;
+ tasks?: TickTickTasksListResult;
+ statusRead?: () => Effect.Effect;
+ tasksRead?: () => Effect.Effect;
+ onTasksCall?: () => void;
+ connectionStatus?: Stream.Stream;
+ queryClient: QueryClient;
+}) {
+ const connectionStatus = opts.connectionStatus;
+ const runtime = makeCoreRuntime({
+ overrides: {
+ tickTickStatus:
+ opts.statusRead ??
+ (() => Effect.succeed(opts.status ?? { state: "not_connected" })),
+ tickTickTasksList: () =>
+ Effect.suspend(() => {
+ opts.onTasksCall?.();
+ return (
+ opts.tasksRead?.() ??
+ Effect.succeed(
+ opts.tasks ?? { tasks: [], source_limit_reached: false },
+ )
+ );
+ }),
+ ...(connectionStatus ? { connectionStatus: () => connectionStatus } : {}),
+ },
+ });
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+}
+
+const task = (id: string, title: string) => ({
+ id,
+ title,
+ kind: "TEXT" as const,
+ priority: 0,
+ tags: [],
+ checklist_items: [],
+});
+
+describe("useTickTick task-read gating", () => {
+ it("gates the task read on a known connection id (never fetches while not connected)", async () => {
+ let tasksCalls = 0;
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const { result } = renderHook(() => useTickTick(), {
+ wrapper: wrapper({
+ status: { state: "not_connected" },
+ onTasksCall: () => {
+ tasksCalls += 1;
+ },
+ queryClient,
+ }),
+ });
+
+ await waitFor(() => expect(result.current.statusResolved).toBe(true));
+ expect(result.current.connected).toBe(false);
+ // The task read is `enabled` only once an id is known — it never fired.
+ expect(tasksCalls).toBe(0);
+ expect(result.current.rows).toEqual([]);
+ });
+
+ it("a retry from disconnected awaits fresh status and then fetches the fresh key", async () => {
+ let statusCalls = 0;
+ let tasksCalls = 0;
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const { result } = renderHook(() => useTickTick(), {
+ wrapper: wrapper({
+ statusRead: () =>
+ Effect.sync(() => {
+ statusCalls += 1;
+ return statusCalls === 1
+ ? ({ state: "not_connected" } as const)
+ : ({ state: "connected", connection_id: "acct-B" } as const);
+ }),
+ tasksRead: () =>
+ Effect.sync(() => {
+ tasksCalls += 1;
+ return {
+ tasks: [task("b-1", "B task")],
+ source_limit_reached: false,
+ };
+ }),
+ queryClient,
+ }),
+ });
+
+ await waitFor(() => expect(result.current.statusResolved).toBe(true));
+ expect(result.current.connected).toBe(false);
+ expect(tasksCalls).toBe(0);
+
+ act(() => result.current.refresh());
+
+ await waitFor(() =>
+ expect(result.current.rows.map((row) => row.id)).toEqual(["b-1"]),
+ );
+ expect(statusCalls).toBe(2);
+ expect(tasksCalls).toBe(1);
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-B"]),
+ ).toMatchObject({ tasks: [{ id: "b-1" }] });
+ });
+
+ it("clears account A immediately and waits for delayed account B status before fetching", async () => {
+ let resolveFreshStatus!: (status: TickTickStatusResult) => void;
+ const freshStatus = new Promise((resolve) => {
+ resolveFreshStatus = resolve;
+ });
+ let statusCalls = 0;
+ let tasksCalls = 0;
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const { result } = renderHook(() => useTickTick(), {
+ wrapper: wrapper({
+ statusRead: () => {
+ statusCalls += 1;
+ return statusCalls === 1
+ ? Effect.succeed({
+ state: "connected",
+ connection_id: "acct-A",
+ } as const)
+ : Effect.promise(() => freshStatus);
+ },
+ tasksRead: () =>
+ Effect.sync(() => {
+ tasksCalls += 1;
+ return tasksCalls === 1
+ ? {
+ tasks: [task("a-1", "A task")],
+ source_limit_reached: false,
+ }
+ : {
+ tasks: [task("b-1", "B task")],
+ source_limit_reached: false,
+ };
+ }),
+ queryClient,
+ }),
+ });
+
+ await waitFor(() =>
+ expect(result.current.rows.map((row) => row.id)).toEqual(["a-1"]),
+ );
+ expect(tasksCalls).toBe(1);
+
+ act(() => result.current.refresh());
+
+ await waitFor(() => expect(result.current.rows).toEqual([]));
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-A"]),
+ ).toBeUndefined();
+ expect(tasksCalls).toBe(1);
+ expect(result.current.refreshing).toBe(true);
+
+ await act(async () => {
+ resolveFreshStatus({
+ state: "connected",
+ connection_id: "acct-B",
+ });
+ await freshStatus;
+ });
+
+ await waitFor(() =>
+ expect(result.current.rows.map((row) => row.id)).toEqual(["b-1"]),
+ );
+ expect(statusCalls).toBe(2);
+ expect(tasksCalls).toBe(2);
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-A"]),
+ ).toBeUndefined();
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-B"]),
+ ).toMatchObject({ tasks: [{ id: "b-1" }] });
+ });
+});
+
+describe("TickTickReconnectSync (app-lifetime cache purge)", () => {
+ it("drops task caches and resets status on a WS reconnect (disconnected→connected)", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ // Warm caches under the PRIOR account — the tab survived a Core restart.
+ queryClient.setQueryData(["ticktick", "tasks", "acct-A"], {
+ tasks: [
+ {
+ id: "old",
+ title: "A's task",
+ kind: "TEXT",
+ priority: 0,
+ tags: [],
+ checklist_items: [],
+ },
+ ],
+ source_limit_reached: false,
+ });
+ queryClient.setQueryData(["ticktick", "status"], {
+ state: "connected",
+ connection_id: "acct-A",
+ });
+
+ render(, {
+ wrapper: wrapper({
+ // A real reconnect EDGE: a drop, then back to connected.
+ connectionStatus: Stream.make(
+ "reconnecting" as ConnectionStatus,
+ "connected" as ConnectionStatus,
+ ),
+ queryClient,
+ }),
+ });
+
+ // The reconnect drops every task query AND resets status, so the next
+ // status read mints the fresh id — account A can never render under B.
+ await waitFor(() =>
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-A"]),
+ ).toBeUndefined(),
+ );
+ await waitFor(() =>
+ expect(queryClient.getQueryData(["ticktick", "status"])).toBeUndefined(),
+ );
+ });
+
+ it("leaves a warm cache intact on a plain mount replay (no prior drop)", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ queryClient.setQueryData(["ticktick", "tasks", "acct-A"], {
+ tasks: [
+ {
+ id: "warm",
+ title: "warm task",
+ kind: "TEXT",
+ priority: 0,
+ tags: [],
+ checklist_items: [],
+ },
+ ],
+ source_limit_reached: false,
+ });
+
+ // A test-driven stream (not a canned one): first the mount replay, then —
+ // only after asserting survival — a REAL reconnect edge. The eventual purge
+ // proves the SAME ordered fiber consumed the replay first, so the survival
+ // assertion is ordering-based, not a fixed sleep (CodeRabbit #336).
+ const queue = Effect.runSync(Queue.unbounded());
+ render(, {
+ wrapper: wrapper({
+ connectionStatus: Stream.fromQueue(queue),
+ queryClient,
+ }),
+ });
+
+ // Mount replay: a lone `connected` with NO prior non-connected state
+ // (ADR-0051 `.changes` replays current on subscribe).
+ await Effect.runPromise(
+ Queue.offer(queue, "connected" as ConnectionStatus),
+ );
+ // The replay is NOT a reconnect → the warm cache survives (review F2/F5).
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-A"]),
+ ).toBeDefined();
+
+ // Now a REAL edge through the same stream: the purge landing proves the
+ // observer processed the whole ordered sequence — replay included.
+ await Effect.runPromise(
+ Queue.offer(queue, "reconnecting" as ConnectionStatus),
+ );
+ await Effect.runPromise(
+ Queue.offer(queue, "connected" as ConnectionStatus),
+ );
+ await waitFor(() =>
+ expect(
+ queryClient.getQueryData(["ticktick", "tasks", "acct-A"]),
+ ).toBeUndefined(),
+ );
+ });
+});
+
+// Review #4: the initial-vs-stale split keys on `data === undefined`, not row
+// count. TanStack retains the last-good `data` through a failed refetch (a
+// successful fetch then a refetch failure lands `status: 'error'` with the prior
+// data still present) — so a valid EMPTY fetch (`{tasks: []}`) whose refetch
+// later fails is a STALE error over an empty cache, NEVER an initial one. The
+// old `rows.length === 0` could not tell that empty-but-valid cache from "no data
+// yet"; this classifier can.
+describe("classifyTasksError (initial vs stale)", () => {
+ it("classifies a failed refetch over a valid empty cache as stale, not initial", () => {
+ expect(
+ classifyTasksError(true, { tasks: [], source_limit_reached: false }),
+ ).toEqual({ tasksInitialError: false, tasksStaleError: true });
+ });
+
+ it("classifies a failure with no prior fetch as initial", () => {
+ expect(classifyTasksError(true, undefined)).toEqual({
+ tasksInitialError: true,
+ tasksStaleError: false,
+ });
+ });
+
+ it("reports neither flag when there is no error", () => {
+ expect(
+ classifyTasksError(false, { tasks: [], source_limit_reached: false }),
+ ).toEqual({ tasksInitialError: false, tasksStaleError: false });
+ expect(classifyTasksError(false, undefined)).toEqual({
+ tasksInitialError: false,
+ tasksStaleError: false,
+ });
+ });
+});
diff --git a/apps/web/test/store/bridge.test.tsx b/apps/web/test/store/bridge.test.tsx
index 9701a187..9ab76439 100644
--- a/apps/web/test/store/bridge.test.tsx
+++ b/apps/web/test/store/bridge.test.tsx
@@ -33,6 +33,7 @@ import {
} from "@/store/bridge.js";
import {
appendMessage,
+ applyEvent,
attachRun,
getChatState,
resetChatStore,
@@ -358,11 +359,18 @@ describe("decideProposal resume fiber tracking (M2)", () => {
});
describe("cancelRun (ADR-0014)", () => {
- /** A stub whose subscribeRun never terminates and whose cancelRun returns `outcome`. */
+ /** A stub whose subscribeRun never terminates and whose cancelRun returns
+ * `outcome` + `live_tail` (default false — no live stream event follows). */
function makeCancelRuntime(outcome: {
readonly outcome: "accepted" | "already_terminal" | "unknown_run";
+ readonly live_tail?: boolean;
}) {
- const cancelRun = vi.fn(() => Effect.succeed(outcome));
+ const cancelRun = vi.fn(() =>
+ Effect.succeed({
+ outcome: outcome.outcome,
+ live_tail: outcome.live_tail ?? false,
+ }),
+ );
const subscribeRun = vi.fn(
(_runId: RunId): Stream.Stream =>
Stream.fromQueue(Effect.runSync(Queue.unbounded())),
@@ -422,6 +430,95 @@ describe("cancelRun (ADR-0014)", () => {
await runtime.dispose();
});
+ it("on accepted for a RUNNING run: applies the real interrupted tool_call + cancelled tail (A4)", async () => {
+ // Core pins the wire order response → interrupted tool_call(s) →
+ // cancelled (external-task-views A4). The bridge must let the live stream
+ // APPLY them — a synthetic settle would drop the interrupted result and
+ // diverge live from reload.
+ const queue = Effect.runSync(Queue.unbounded());
+ const cancelRun = vi.fn(() =>
+ Effect.sync(() => {
+ // The event tail lands right behind the response, as Core does.
+ Queue.unsafeOffer(queue, {
+ kind: "tool_call",
+ tool_call_id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "error",
+ result: {
+ content: [{ type: "text", text: "interrupted" }],
+ is_error: true,
+ },
+ });
+ Queue.unsafeOffer(queue, { kind: "cancelled" });
+ // live_tail: true — the stream delivers the real interrupted +
+ // cancelled tail; the bridge must NOT synthesize its own.
+ return { outcome: "accepted" as const, live_tail: true };
+ }),
+ );
+ const subscribeRun = vi.fn(
+ (_runId: RunId): Stream.Stream =>
+ Stream.fromQueue(queue),
+ );
+ const runtime = makeCoreRuntime({ overrides: { subscribeRun, cancelRun } });
+ const runId = "run-cancel-external" as RunId;
+ seedActiveRun(runId, runtime);
+ // The external call is in flight (a running row from the started event).
+ applyEvent("t1", runId, {
+ kind: "tool_call",
+ tool_call_id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "started",
+ });
+
+ await cancelRunBridge(runtime, runId);
+ // The stream applies the tail and its takeUntil reaps the fiber.
+ await vi.waitFor(() => {
+ expect(hasRunFiber(runId)).toBe(false);
+ });
+
+ const thread = getChatState().threads.t1;
+ const message = thread?.messages.find((m) => m.id === "a1");
+ expect(message?.status).toBe("incomplete");
+ expect(message?.cancelled).toBe(true);
+ // The row settled from the REAL interrupted event: error + the
+ // Core-generated result — the same object reload will serve.
+ const row = message?.segments.find((s) => s.kind === "tool_call");
+ expect(row?.kind === "tool_call" && row.call).toEqual({
+ id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "error",
+ arg: undefined,
+ result: {
+ content: [{ type: "text", text: "interrupted" }],
+ is_error: true,
+ },
+ });
+
+ await runtime.dispose();
+ });
+
+ it("on accepted WITHOUT a live tail: settles immediately off the response (no timer)", async () => {
+ // live_tail: false is Core's explicit "no stream event will follow"
+ // (parked, or the running-without-hub resume window). The bridge settles
+ // synthetically at once — no 3-second grace timer, no wedged Stop.
+ const { runtime } = makeCancelRuntime({
+ outcome: "accepted",
+ live_tail: false,
+ });
+ const runId = "run-cancel-no-tail" as RunId;
+ seedActiveRun(runId, runtime);
+
+ await cancelRunBridge(runtime, runId);
+
+ const thread = getChatState().threads.t1;
+ expect(thread?.messages.find((m) => m.id === "a1")?.status).toBe(
+ "incomplete",
+ );
+ expect(hasRunFiber(runId)).toBe(false);
+
+ await runtime.dispose();
+ });
+
it("on already_terminal for a RUNNING run: leaves it untouched (the stream owns the final state)", async () => {
const { runtime, cancelRun } = makeCancelRuntime({
outcome: "already_terminal",
@@ -541,7 +638,8 @@ describe("cancelRun (ADR-0014)", () => {
const runtime = makeCoreRuntime({
overrides: {
subscribeRun,
- cancelRun: () => Effect.succeed({ outcome: "accepted" as const }),
+ cancelRun: () =>
+ Effect.succeed({ outcome: "accepted" as const, live_tail: false }),
retryRun: () => Effect.succeed({ outcome: "accepted" as const }),
proposalDecide: () =>
Effect.promise(() => decideGate).pipe(
diff --git a/apps/web/test/store/chat.test.tsx b/apps/web/test/store/chat.test.tsx
index f01638dd..063b6868 100644
--- a/apps/web/test/store/chat.test.tsx
+++ b/apps/web/test/store/chat.test.tsx
@@ -211,6 +211,51 @@ describe("chat store + stream bridge", () => {
await runtime.dispose();
});
+ it("merges a terminal event's result into the existing external call (A4)", async () => {
+ const queue = Effect.runSync(Queue.unbounded());
+ const runtime = makeStubRuntime(queue, "run-external");
+
+ await send(runtime, "threadA", "how many tasks tomorrow?");
+
+ Queue.unsafeOffer(queue, {
+ kind: "tool_call",
+ tool_call_id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "started",
+ });
+ Queue.unsafeOffer(queue, {
+ kind: "tool_call",
+ tool_call_id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "completed",
+ result: {
+ content: [{ type: "text", text: "1 task found: S1 timed" }],
+ is_error: false,
+ },
+ });
+ Queue.unsafeOffer(queue, { kind: "done" });
+ await awaitRun(runtime, "run-external");
+
+ const assistant = getChatState().threads.threadA?.messages[1];
+ expect(
+ (assistant?.segments ?? [])
+ .filter((s) => s.kind === "tool_call")
+ .map((s) => s.call),
+ ).toEqual([
+ {
+ id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "completed",
+ result: {
+ content: [{ type: "text", text: "1 task found: S1 timed" }],
+ is_error: false,
+ },
+ },
+ ]);
+
+ await runtime.dispose();
+ });
+
it("maps a tool_call error status onto the matching row", async () => {
const queue = Effect.runSync(Queue.unbounded());
const runtime = makeStubRuntime(queue, "run-tool-err");
@@ -550,6 +595,40 @@ describe("segment timeline (ADR-0045)", () => {
expect(concatText(segs)).toBe("First. Second. Done.");
});
+ it("a snapshot ending with an OPEN reasoning block seals it on the next boundary (F4)", () => {
+ seedRun("tF4", "run-f4");
+
+ // A reconnect `snapshot` whose LAST segment is an OPEN reasoning block (no
+ // `duration_ms`). The wire snapshot carries no open-time, so applyEvent must
+ // RE-ANCHOR `reasoningOpenedAt` to `now`; without it a later boundary can
+ // never seal the block and it stays "Thinking…" forever (review F4).
+ applyEvent(
+ "tF4",
+ "run-f4",
+ {
+ kind: "snapshot",
+ segments: [
+ { kind: "text", text: "hello" },
+ { kind: "reasoning", text: "thinking" },
+ ],
+ },
+ 1_000,
+ );
+ // Right after the snapshot the block is open (unsealed).
+ expect(segmentsOf("tF4", "run-f4")).toEqual([
+ { kind: "text", text: "hello" },
+ { kind: "reasoning", text: "thinking" },
+ ]);
+
+ // A later boundary seals it with a duration web-clocked from the snapshot instant.
+ applyEvent("tF4", "run-f4", { kind: "text_delta", delta: "world" }, 3_000);
+ expect(segmentsOf("tF4", "run-f4")).toEqual([
+ { kind: "text", text: "hello" },
+ { kind: "reasoning", text: "thinking", durationMs: 2_000 },
+ { kind: "text", text: "world" },
+ ]);
+ });
+
it("appends a proposal segment exactly once per run (skip-if-present)", () => {
seedRun("tDup", "run-dup");
const proposal = {
diff --git a/apps/web/test/store/hydrate.test.tsx b/apps/web/test/store/hydrate.test.tsx
index 45262db0..1ad6eafd 100644
--- a/apps/web/test/store/hydrate.test.tsx
+++ b/apps/web/test/store/hydrate.test.tsx
@@ -99,16 +99,31 @@ describe("refresh-durable hydration", () => {
segments: [
{
kind: "tool_call",
+ tool_call_id: "tc_1",
name: "search_entities",
status: "completed",
arg: "Lev",
},
{
kind: "tool_call",
+ tool_call_id: "tc_2",
name: "search_entities",
status: "error",
arg: "Acme",
},
+ // An external call reloads WITH its model-received result
+ // (external-task-views A4), so the row expands identically
+ // to the live one.
+ {
+ kind: "tool_call",
+ tool_call_id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "completed",
+ result: {
+ content: [{ type: "text", text: "1 task found" }],
+ is_error: false,
+ },
+ },
{ kind: "text", text: "done" },
],
},
@@ -127,23 +142,41 @@ describe("refresh-durable hydration", () => {
await hydrateThread(runtime, "tTools");
const assistant = getChatState().threads.tTools?.messages[1];
+ // The rows key on the DURABLE tool_call_id (external-task-views A4) — the
+ // same id the live `tool_call` Run Event carried, so the reload row keys
+ // identically to the live one.
expect(assistant?.segments.filter((s) => s.kind === "tool_call")).toEqual([
{
kind: "tool_call",
call: {
- id: "m2:seg:0",
+ id: "tc_1",
name: "search_entities",
status: "completed",
arg: "Lev",
+ result: undefined,
},
},
{
kind: "tool_call",
call: {
- id: "m2:seg:1",
+ id: "tc_2",
name: "search_entities",
status: "error",
arg: "Acme",
+ result: undefined,
+ },
+ },
+ {
+ kind: "tool_call",
+ call: {
+ id: "tc_ext",
+ name: "ticktick_filter_tasks",
+ status: "completed",
+ arg: undefined,
+ result: {
+ content: [{ type: "text", text: "1 task found" }],
+ is_error: false,
+ },
},
},
]);
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 1d74a7c6..03c91df7 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -2,11 +2,15 @@
name = "core"
version = "0.1.0"
edition = "2024"
+# let-chains (used across the crate) stabilized in 1.88; CI runs unpinned stable.
+rust-version = "1.88"
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["ws"] }
base64 = "0.22"
+libc = "0.2"
+reqwest = { version = "0.12", features = ["json"] }
rust-embed = { version = "8", features = ["mime-guess"] }
schemars = "0.8"
serde = { version = "1", features = ["derive"] }
@@ -21,6 +25,7 @@ tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
+url = "2"
uuid = { version = "1", features = ["v7", "serde"] }
[dev-dependencies]
diff --git a/crates/core/src/cancel.rs b/crates/core/src/cancel.rs
index dd145181..a8be8c62 100644
--- a/crates/core/src/cancel.rs
+++ b/crates/core/src/cancel.rs
@@ -1,121 +1,95 @@
//! Run cancellation as one deep, directly-testable verb (ADR-0029, extending the
//! `proposal/decide` → [`crate::decide`] precedent to `run/cancel`).
//!
-//! [`cancel`] owns the whole decision: read the Run status, pick the parked vs
-//! running guarded transition (ADR-0028), and on a won running-cancel perform the
-//! Worker signal. It returns a typed [`Outcome`] — `Accepted` / `AlreadyTerminal`
-//! / `UnknownRun` — the three [ADR-0014](../docs/adr/0014-client-core-wire-protocol.md)
-//! result values, NOT error codes. The only failure channel is a DB fault, which
-//! rides `anyhow::Error` (the handler maps it to `-32603`); the negative-but-
-//! expected domain outcomes stay in the `Ok` payload (ADR-0029 "protocol error vs
-//! result value").
+//! [`cancel`] owns the whole decision AND its side effects: read the Run status,
+//! pick the parked vs running guarded transition (ADR-0028), and — for a won
+//! running-cancel with a live hub — signal the Worker, frame the Response (via
+//! the injected `respond`), settle + publish the interrupted external calls, and
+//! publish the terminal `Cancelled`, ALL under ONE lifecycle-slot + hub-gate
+//! acquisition. Holding both across the settle tx AND the raw event sends makes
+//! generation changes and `run/subscribe` classification atomic with the whole
+//! sequence.
//!
-//! The hub interaction is INJECTED as a closure (`get_hub`) so the decision + the
-//! Worker signal are assertable against a `:memory:` pool without the live `Hubs`
-//! registry — mirroring how [`crate::decide::apply`] injects `worker::resume`
-//! (ADR-0026: the verb takes no new subsystem dependency). On a won running-cancel
-//! the verb signals the live Worker and returns the won [`RunHub`] inside
-//! [`Outcome::Accepted`]; the terminal `Cancelled` publish + `hub::remove` are
-//! performed by [`publish_cancelled`], which the thin handler calls AFTER framing
-//! its Response — preserving the deterministic `response → cancelled` wire order.
+//! Response framing is INJECTED as `respond` so the verb frames it in the right
+//! place (inside the gate, BEFORE the events — pinning the wire order
+//! response → interrupted → cancelled) while staying assertable against a
+//! `:memory:` pool. The only failure channel is a DB fault on `anyhow::Error`
+//! (the handler maps it to `-32603`); expected domain outcomes ride the `respond`
+//! string (`accepted` / `already_terminal` / `unknown_run`), ADR-0029.
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::db::{self, RunStatus};
-use crate::hub::{self, Hubs, RunHub};
+use crate::hub::{self, Hubs};
use crate::protocol::RunEvent;
-/// The result of a cancel request (ADR-0014 result values, not error codes). The
-/// handler maps each to its wire `outcome` string.
-pub enum Outcome {
- /// The Run was live (running) or parked and is now cancelling. For a won
- /// running-cancel this carries the live [`RunHub`] the verb signalled, so the
- /// handler can publish the terminal `Cancelled` AFTER framing its Response; a
- /// parked cancel carries `None` (no live Worker to signal or publish for).
- Accepted { hub: Option },
- /// The Run had already finished (terminal) or a concurrent winner committed the
- /// terminal transition first — nothing to cancel.
- AlreadyTerminal,
- /// No Run with this id.
- UnknownRun,
-}
-
-/// Cancel a Run (ADR-0014, ADR-0028). Reads the status, picks the guarded
-/// transition, and on a won running-cancel signals the live Worker via the
-/// injected `get_hub`. Returns the typed [`Outcome`]; a DB fault is the only
-/// `Err`.
-///
-/// `get_hub` resolves the live [`RunHub`] for a run id (production: `|id|
-/// hub::get(hubs, id)`); injected so the decision + Worker signal are testable
-/// against `:memory:` without the live registry.
-pub async fn cancel(pool: &SqlitePool, run_id: Uuid, get_hub: F) -> anyhow::Result
-where
- F: FnOnce(Uuid) -> Option,
-{
+/// Cancel a Run (ADR-0014/0028/0029). The transient lifecycle slot makes
+/// the durable status, active hub generation, and any terminal drain one
+/// linearized decision. `respond(outcome, live_tail)` is framed before live
+/// terminal events so the initiating connection observes response first.
+pub async fn cancel(
+ pool: &SqlitePool,
+ hubs: &Hubs,
+ run_id: Uuid,
+ respond: impl FnOnce(&str, bool),
+) -> anyhow::Result<()> {
+ let lifecycle = hub::lifecycle(hubs, run_id).await;
match db::run_status(pool, run_id).await? {
- // Unknown run id — an ADR-0014 result value, not an error code.
- None => Ok(Outcome::UnknownRun),
+ None => respond("unknown_run", false),
Some(RunStatus::Parked) => {
- // Parked Run has no live Worker: a pure tier-2 flip of the Run + its
- // pending Proposal. A rollback (no pending Proposal, or a concurrent
- // decide/cancel already won) maps to AlreadyTerminal.
- if db::cancel_parked_run(pool, run_id, db::now_ms()).await? {
- Ok(Outcome::Accepted { hub: None })
- } else {
- Ok(Outcome::AlreadyTerminal)
- }
+ let accepted = db::cancel_parked_run(pool, run_id, db::now_ms()).await?;
+ respond(
+ if accepted {
+ "accepted"
+ } else {
+ "already_terminal"
+ },
+ false,
+ );
}
- Some(RunStatus::Running) => {
- // Win the guarded running -> cancelled transition first; the DB
- // transition is the user-visible outcome. On a win, signal the live
- // Worker (cleanup) and hand the hub back so the handler publishes the
- // terminal Cancelled AFTER framing its Response.
- if db::cancel_running_run(pool, run_id, db::now_ms()).await?.won() {
- let hub = get_hub(run_id);
- if let Some(run_hub) = &hub {
- run_hub.cancel();
+ Some(RunStatus::Running) => match hub::get(hubs, run_id) {
+ Some(run_hub) => {
+ let gate = run_hub.gate().await;
+ match db::cancel_running_run(pool, run_id, db::now_ms()).await? {
+ db::Terminal::Won { interrupted } => {
+ run_hub.cancel();
+ respond("accepted", true);
+ crate::worker::publish_interrupted(&run_hub, interrupted);
+ run_hub.send(RunEvent::Cancelled);
+ hub::remove_own(hubs, run_id, &run_hub, &lifecycle);
+ }
+ db::Terminal::Lost => respond("already_terminal", false),
}
- Ok(Outcome::Accepted { hub })
- } else {
- // The Worker committed a terminal transition first.
- Ok(Outcome::AlreadyTerminal)
+ drop(gate);
}
- }
- // Completed, errored, or cancelled — the Run already ended. The terminal set
- // is classified once by `is_terminal` (ADR-0028), not re-spelled here.
- Some(status) if status.is_terminal() => Ok(Outcome::AlreadyTerminal),
- // Unreachable: the two live states are matched above and the rest are
- // terminal. A guarded arm does not count toward match exhaustiveness, so
- // this explicit arm is required.
- Some(_) => Ok(Outcome::AlreadyTerminal),
+ None => {
+ let terminal = db::cancel_running_run(pool, run_id, db::now_ms()).await?;
+ respond(
+ if terminal.won() {
+ "accepted"
+ } else {
+ "already_terminal"
+ },
+ false,
+ );
+ }
+ },
+ Some(_) => respond("already_terminal", false),
}
-}
-
-/// Publish the terminal `Cancelled` Run Event and remove the hub, after a won
-/// running-cancel. Called by the handler AFTER it frames the cancel Response, so
-/// the client always sees `response → cancelled` (not a racing broadcast). The
-/// gated publish (`lock → send → unlock`, ADR-0022) is
-/// [`RunHub::publish_gated`]; then `hub::remove`.
-pub async fn publish_cancelled(hubs: &Hubs, run_id: Uuid, hub: Option) {
- let Some(run_hub) = hub else {
- return;
- };
-
- run_hub.publish_gated(RunEvent::Cancelled).await;
-
- hub::remove(hubs, run_id);
+ Ok(())
}
#[cfg(test)]
mod tests {
- use crate::db::test_support::memory_pool;
- use super::{cancel, publish_cancelled, Outcome};
+ use super::cancel;
use crate::db;
+ use crate::db::test_support::memory_pool;
use crate::hub;
- use crate::protocol::RunEvent;
+ use crate::protocol::{RunEvent, ToolCallStatus, TranscriptToolResult};
use crate::workflow::Workflow;
use sqlx::SqlitePool;
+ use std::cell::Cell;
use uuid::Uuid;
fn test_workflow() -> Workflow {
@@ -127,6 +101,7 @@ mod tests {
system_prompt: "sp".to_string(),
thinking_level: Some("off".to_string()),
tools: vec!["propose_workspace_mutation".to_string()],
+ external_tools: false,
}
}
@@ -191,25 +166,41 @@ mod tests {
.expect("count pending proposals")
}
- // 1. Parked Run → Accepted (no hub); the Run AND its pending Proposal flip to
- // cancelled. The verb takes the pure tier-2 parked path; no Worker to signal.
+ /// Run `cancel`, recording the single `respond(outcome, live_tail)` call.
+ async fn cancel_recording(pool: &SqlitePool, hubs: &hub::Hubs, run_id: Uuid) -> (String, bool) {
+ let recorded: Cell