Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e3857f6
refactor(turn): consume one complete source through execution and rec…
loopx-agent Nov 6, 2026
5b4f6bb
test(turn): qualify complete source and versioned recovery boundaries
loopx-agent Nov 6, 2026
bff3b29
docs(turn): define the staged lifecycle extraction boundary
loopx-agent Nov 6, 2026
71bce23
Merge current main into the complete Turn lifecycle slice
loopx-agent Nov 7, 2026
e489b6b
fix(turn): qualify native execution and preserve Host obligations
loopx-agent Nov 7, 2026
f653078
test(turn): preserve quota and complete-source consumer boundaries
loopx-agent Nov 7, 2026
f016cb1
docs(turn): preserve consumer and packaged qualification boundaries
loopx-agent Nov 7, 2026
cc054bb
Merge current main into the complete Turn lifecycle proposal
loopx-agent Nov 7, 2026
d9cedec
Qualify generic CLI adapters for the complete Turn request
loopx-agent Nov 7, 2026
ea34e58
Reuse observed Goal narrative in progressive first reads
loopx-agent Nov 7, 2026
78153f9
Merge remote-tracking branch 'origin/main' into codex/turn-8211-repai…
loopx-agent Nov 8, 2026
b0e7bfb
fix(qualification): align model recovery and result instructions
loopx-agent Nov 8, 2026
7c18d78
Preserve bounded recovery after isolated draft shell errors
loopx-agent Nov 8, 2026
8a9bbb7
Merge checkpoint fixture repair into the 2.3.0 qualification candidate
loopx-agent Nov 8, 2026
5238c9c
fix(turn): qualify bounded inspection and original-session recovery
loopx-agent Nov 8, 2026
e3797e2
docs(turn): record inspection costs and recovery migration boundary
loopx-agent Nov 8, 2026
fbf7d5a
Merge commit 'a02be12695' into codex/turn-8211-final-main-20261108
loopx-agent Nov 8, 2026
e9856eb
chore(quality): bound reviewed Lark reply context growth
loopx-agent Nov 8, 2026
e0c27ce
test(turn): scope frozen comparisons and recover inspection detail
loopx-agent Nov 8, 2026
695cc49
Integrate reviewed release prerequisites from main
loopx-agent Nov 8, 2026
553733d
Integrate the measured Lark module ceiling for release qualification
loopx-agent Nov 8, 2026
245cb59
test(quality): retain terminal correction room with complete context …
loopx-agent Nov 8, 2026
d53b402
fix(turn): keep read-only source inspection portable
loopx-agent Nov 8, 2026
9b1e524
Resume verified conversation history after transport interruptions
loopx-agent Nov 8, 2026
078ae6f
fix(turn): require the Core release exposing complete-source adapters
loopx-agent Nov 8, 2026
5bd42ed
Merge latest main into the complete-source Turn repair
loopx-agent Nov 8, 2026
e441ae1
Merge pull request #8355 from loopx-project/codex/steward-history-rea…
loopx-agent Nov 8, 2026
c263f7c
Merge pull request #8211 from loopx-project/codex/turn-complete-sourc…
loopx-agent Nov 8, 2026
e4b2fff
Integrate bounded 2.3.0 qualification recovery on current main
loopx-agent Nov 8, 2026
75d012e
perf(chat): read delivery state only for visible history results
loopx-agent Nov 8, 2026
4e949f4
Merge pull request #8358 from loopx-project/codex/chat-history-cost-2…
loopx-agent Nov 8, 2026
8106fc7
Preserve correctable missing-draft feedback within the terminal tool …
loopx-agent Nov 8, 2026
c207ba3
Keep selected context values exact without inflating short scalar arrays
loopx-agent Nov 8, 2026
d71cb1c
Keep bounded reader and refresh settlement adapters with their existi…
loopx-agent Nov 8, 2026
244a269
Validate native fixture recovery and retain complete monitor closeout…
loopx-agent Nov 8, 2026
3f1cb51
Complete bilingual 2.3.0 release decisions, optional usage and commun…
loopx-agent Nov 8, 2026
92a5f22
fix(workspace): read complete evidence from original Task sources
loopx-agent Nov 8, 2026
0cbc922
Merge main and retain Goal reuse in the shared context reader
loopx-agent Nov 8, 2026
c63a8f9
Merge pull request #8361 from loopx-project/codex/product-engineer-20…
loopx-agent Nov 8, 2026
a2341e4
Merge main after original Task evidence readback update
loopx-agent Nov 8, 2026
fe36f31
Merge pull request #8323 from loopx-project/codex/progressive-goal-na…
loopx-agent Nov 8, 2026
0bab447
Integrate qualified release recovery with current complete-source Tur…
loopx-agent Nov 8, 2026
27ca9ba
Preserve valid root object projections in captured replan readback
loopx-agent Nov 8, 2026
34af6a5
Resolve shared-topic ambiguity and return available accepted report c…
loopx-agent Nov 8, 2026
f79687f
fix(chat): keep live work through transient progress storage pressure
loopx-agent Nov 8, 2026
bda8d23
chore(canary): record inherited private-conversation module size
loopx-agent Nov 8, 2026
e71bf9c
Keep capture selection navigation in its bounded reader owner
loopx-agent Nov 8, 2026
4055e74
Complete bilingual release recovery notes and historical attribution
loopx-agent Nov 8, 2026
561f781
fix(chat): require durable events before deferring progress metadata
loopx-agent Nov 8, 2026
3628930
Merge pull request #8366 from loopx-project/codex/chat-persistence-pr…
loopx-agent Nov 8, 2026
9093899
Merge commit '4055e743c9d335f5ad19a1b358a90532938f22dc' into codex/re…
loopx-agent Nov 8, 2026
2941018
fix(release): deliver current task operations in native first reads
loopx-agent Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ def unavailable(handler):
"Verified results retain their source. Unconfirmed returns remain open for recovery."
for part in range(6)))
result = {"populated": True, "messages": len(server.chat_store.messages(sid))}
elif command == "history":
for index in range(450):
server.chat_store.append_message(sid, role="agent",
text="Original full body. " * 1000 if index == 201 else f"Paged bound row {index}")
result = {"history_rows": len(server.chat_store.messages(sid))}
elif command == "inspect":
saved = server.chat_store.load_session(sid)
result = {"sessions": len(server.chat_store.list_sessions()), "original_thread": saved["upstream_thread_id"],
Expand Down
65 changes: 63 additions & 2 deletions apps/presentation/dashboard/smoke/bound-conversation-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,73 @@ try {
assert.equal(calls[0].path, `/api/chat/sessions?conversation_binding_id=${bindingId}`);
const routedRead = globalThis.fetch;
if (!fallback) {
// Repeated transport loss at later pages must advance one original prefix
// through the existing retry caller, without ever publishing partial text.
child.stdin.write("history\n");
const expectedRows = (await next()).history_rows;
const complete = await fetchChatSessionHistory(original.session);
assert.equal(complete.messages.length, expectedRows);
const recoveredOffsets: number[] = [];
const failedOffsets = new Set<number>();
globalThis.fetch = async (input, init) => {
const response = await routedRead(input, init);
if (new URL(String(input), origin).pathname !== `/api/chat/sessions/${sessionId}`) return response;
const value = await response.clone().json();
const offset = value.message_page.offset;
recoveredOffsets.push(offset);
if (offset > 0 && !failedOffsets.has(offset)) {
failedOffsets.add(offset);
throw new Error("Synthetic interrupted transport after a verified prefix");
}
return response;
};
const interrupted = await fetchChatHistory(options);
assert.deepEqual(interrupted.unavailableSessionIds, [sessionId]);
assert.deepEqual(interrupted.messages, []);
assert.deepEqual(interrupted.snapshots, []);
const interruptedAgain = await fetchChatHistory(options, interrupted);
assert.deepEqual(interruptedAgain.messages, []);
const recovered = await fetchChatHistory(options, interruptedAgain);
assert.deepEqual(recovered.unavailableSessionIds, []);
assert.deepEqual(recovered.snapshots[0].messages, complete.messages);
assert.deepEqual(recoveredOffsets, [0, 200, 200, 400, 400], "Only failed pages are read again");
assert.equal(interrupted.pendingReads?.get(sessionId)?.messages.length, 200,
"Later retries cannot mutate the previously published read state");
assert.equal(recovered.pendingReads?.size, 0);
assert.ok(calls.every(row => row.method === "GET"), "Recovery never starts work");
globalThis.fetch = routedRead;
// Current source revocation blocks recovery before another history page.
child.stdin.write("deny\n"); assert.equal((await next()).source_verified, false);
calls.length = 0;
await assert.rejects(() => fetchChatHistory(options, interrupted));
assert.ok(calls.every(row => new URL(row.path, origin).pathname === "/api/chat/sessions"));
child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true);
// Permanent denial, a changed source or malformed prefix cannot be retained
// as transport progress. The following read must validate page zero again.
for (const fault of ["denied", "source", "digest"]) {
globalThis.fetch = async (input, init) => {
const response = await routedRead(input, init);
if (!new URL(String(input), origin).searchParams.has("message_cursor")) return response;
if (fault === "denied") return new Response("{}", {status: 403});
const value = await response.json();
if (fault === "source") value.session.goal_id = "unrelated-goal";
else value.message_page.snapshot_sha256 = "0".repeat(64);
return new Response(JSON.stringify(value));
};
const rejected = await fetchChatHistory(options, interrupted);
assert.deepEqual(rejected.messages, []);
assert.equal(rejected.pendingReads?.size, 0);
globalThis.fetch = routedRead;
calls.length = 0;
assert.deepEqual((await fetchChatHistory(options, rejected)).snapshots[0].messages, complete.messages);
assert.equal(new URL(calls.find(row => row.path.includes(`${sessionId}?`))!.path, origin).searchParams.has("message_cursor"), false);
}
// Real HTTP responses beyond the old interactive deadline must still hydrate
// the complete original history. Source verification keeps its own deadline.
child.stdin.write("slow-history\n");
assert.equal((await next()).slow_history, true);
const slow = await fetchChatHistory(options);
assert.deepEqual(slow.messages, history.messages);
assert.deepEqual(slow.messages, recovered.messages);
assert.deepEqual(slow.unavailableSessionIds, []);
// Delay only the post-history fresh source check, not initial discovery.
const beforeSlowSource = calls.length;
Expand All @@ -98,7 +159,7 @@ try {
return response;
};
const sourceSlow = await fetchChatHistory(options);
assert.deepEqual(sourceSlow.messages, history.messages);
assert.deepEqual(sourceSlow.messages, recovered.messages);
assert.deepEqual(sourceSlow.unavailableSessionIds, []);
assert.ok(calls.slice(beforeSlowSource).every(row => row.method === "GET"));
globalThis.fetch = routedRead;
Expand Down
61 changes: 52 additions & 9 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ export async function readTodoRequest(goalId: string, todoId: string, signal: Ab
const result = z.object({
ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(),
status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(),
evidence: z.string().nullable().optional(), note: z.string().nullable().optional(),
review_basis: todoUpdateSourceBasisSchema.optional(),
source_access: z.object({namespace: z.string()}).optional(),
}).parse(await requestJson<unknown>(url, {signal}));
Expand Down Expand Up @@ -1055,15 +1056,40 @@ const chatMessagePageSchema = z.object({
// its pages and fresh source checks; caller cancellation still takes priority.
const CHAT_HISTORY_READ_TIMEOUT_MS = 30_000;

type ChatHistoryReadProgress = {
first: ChatSessionSnapshot;
messages: ChatVisibleMessage[];
cursors: Set<string>;
cursor: string;
total: number;
digest: string;
};

function sameHistorySource(actual: ChatSessionSummary, expected: ChatSessionSummary) {
return actual.session_id === expected.session_id &&
(["agent_id", "goal_id", "channel_id", "managed_agent_id", "goal_instance_id", "project_ref"] as const)
.every(key => (actual[key] ?? null) === (expected[key] ?? null)) &&
(actual.session_mode ?? "managed_runtime") === (expected.session_mode ?? "managed_runtime");
}

/** Hydrate one complete original prefix; partial pages never become a snapshot. */
export async function fetchChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string): Promise<ChatSessionSnapshot> {
return readChatSessionHistory(session, signal, turnId);
}

async function readChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string,
progress?: ChatHistoryReadProgress, checkpoint?: (progress: ChatHistoryReadProgress) => void): Promise<ChatSessionSnapshot> {
const sessionId = typeof session === "string" ? session : session.session_id;
// A delegation has the original Session ID; pin its first verified page's
// scope. History discovery callers keep validating against their own source.
let expectedSession = typeof session === "string" ? undefined : session;
let first: ChatSessionSnapshot | undefined, cursor: string | null = null;
let total: number | undefined, digest: string | undefined;
const messages: ChatVisibleMessage[] = [], cursors = new Set<string>(), ids = new Set<string>();
// Recovery is derived browser memory, scoped again by current discovery. It
// grants no source authority and never exposes an incomplete transcript.
if (progress && expectedSession && !sameHistorySource(progress.first.session, expectedSession)) progress = undefined;
let first = progress?.first, cursor: string | null = progress?.cursor ?? null;
let total = progress?.total, digest = progress?.digest;
const messages = [...(progress?.messages ?? [])], cursors = new Set(progress?.cursors),
ids = new Set(messages.map(message => message.message_id));
do {
signal?.throwIfAborted();
const query = new URLSearchParams({message_limit: "200"});
Expand All @@ -1074,10 +1100,7 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri
{signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)]) : AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)});
signal?.throwIfAborted();
const expected = expectedSession;
const sameSource = snapshot.session?.session_id === sessionId && (!expected || (
(["agent_id", "goal_id", "channel_id", "managed_agent_id", "goal_instance_id", "project_ref"] as const)
.every(key => (snapshot.session[key] ?? null) === (expected[key] ?? null)) &&
(snapshot.session.session_mode ?? "managed_runtime") === (expected.session_mode ?? "managed_runtime")));
const sameSource = snapshot.session?.session_id === sessionId && (!expected || sameHistorySource(snapshot.session, expected));
if (snapshot.ok !== true || snapshot.schema_version !== "loopx_chat_store_v1" || !sameSource || !Array.isArray(snapshot.messages)) {
throw new Error("The original Session scope changed during history read.");
}
Expand Down Expand Up @@ -1106,7 +1129,10 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri
}
first ??= snapshot;
total = page.total; digest = page.snapshot_sha256; cursor = page.next_cursor;
if (cursor !== null) cursors.add(cursor);
if (cursor !== null) {
cursors.add(cursor);
checkpoint?.({first, messages: [...messages], cursors: new Set(cursors), cursor, total, digest});
}
} while (cursor !== null);
// Keep the first read's execution/revision hint. An append between pages is
// intentionally outside this prefix and will trigger the normal next refresh.
Expand Down Expand Up @@ -1206,6 +1232,7 @@ export type ChatHistory = {
sessions: ChatSessionSummary[];
snapshots: ChatSessionSnapshot[];
unavailableSessionIds: string[];
pendingReads?: Map<string, ChatHistoryReadProgress>;
};

export async function fetchChatHistory(options: {
Expand All @@ -1223,6 +1250,8 @@ export async function fetchChatHistory(options: {
const listed = options.managed ? {sessions: [await resolveManagedChatSession(options.managed, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS))]}
: (!options.bound ? previous : undefined) ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS) });
const known = new Map(previous?.snapshots.map((snapshot) => [snapshot.session.session_id, snapshot]));
const pendingReads = new Map(previous?.pendingReads);
for (const id of pendingReads.keys()) if (!listed.sessions.some(session => session.session_id === id)) pendingReads.delete(id);
// A failed historical read is not an empty transcript. Retrying only the
// missing snapshots keeps this recovery read-only and bounds repeated work.
const missing = listed.sessions.filter((session) => {
Expand All @@ -1234,7 +1263,20 @@ export async function fetchChatHistory(options: {
});
if (exact) for (const session of missing) known.delete(session.session_id);
const results = await Promise.allSettled(missing.map(async (session) => {
const snapshot = await fetchChatSessionHistory(session);
const savedProgress = pendingReads.get(session.session_id);
if (savedProgress && !sameHistorySource(savedProgress.first.session, session)) pendingReads.delete(session.session_id);
let snapshot: ChatSessionSnapshot;
try {
snapshot = await readChatSessionHistory(session, undefined, undefined, pendingReads.get(session.session_id),
progress => pendingReads.set(session.session_id, progress));
} catch (error) {
// Reuse the hook's existing retry schedule only for a lost transport.
// Revoked sources, invalid pages and stale cursors must start a fresh read.
if (!(error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable"
&& (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500))) pendingReads.delete(session.session_id);
throw error;
}
pendingReads.delete(session.session_id);
if (snapshot.session.session_id !== session.session_id || snapshot.session.agent_id !== session.agent_id) {
throw new Error("The original Session identity changed during history read.");
}
Expand All @@ -1258,6 +1300,7 @@ export async function fetchChatHistory(options: {
sessions: listed.sessions,
snapshots,
unavailableSessionIds: listed.sessions.filter((session) => !known.has(session.session_id)).map((session) => session.session_id),
pendingReads,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import type { LarkGoalConnection } from "../../data/chat";
import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n";
import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue, hasGoalUsage } from "./personal-workspace-model";
import { TeamPlanResult } from "./team-plan-result";
import { TaskRequest } from "./task-request";
import { TaskRequest, taskRequestIdentity, type TaskRequestRead } from "./task-request";
import { parseTodoResumeCondition } from "./todo-resume-condition";
import { MarkdownText } from "./markdown";
import { formatMonitorDate } from "./monitor-readback";
Expand Down Expand Up @@ -145,6 +145,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
const { locale, t } = useWorkspaceI18n();
const [correction, setCorrection] = useState("");
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [taskRequestRead, setTaskRequestRead] = useState<TaskRequestRead | null>(null);
const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle");
const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record");
const [runActions, setRunActions] = useState<Record<string, RunActionStates>>({});
Expand Down Expand Up @@ -355,6 +356,15 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
: selection.item.goalId;
const taskNamespace = goals.find(goal => goal.goalId === goalId)?.sourceAccess?.namespace;
const taskReadable = !readOnly || Boolean(taskNamespace);
const taskDetail = selection.kind === "todo" && taskRequestRead?.phase === "ready"
&& taskRequestRead.identity === taskRequestIdentity(selection.item, taskReadable, taskNamespace)
? taskRequestRead.detail : null;
// Missing fields retain compatibility with older/local request-only readers;
// an explicit null from the current source clears an outdated display hint.
const taskEvidence = taskDetail && "evidence" in taskDetail ? taskDetail.evidence
: selection.kind === "todo" ? selection.item.evidence : undefined;
const taskNote = taskDetail && "note" in taskDetail ? taskDetail.note
: selection.kind === "todo" ? selection.item.note : undefined;
const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? t("drawer.currentGoal")
: selection.kind === "todo" ? selection.item.goalTitle
: selection.kind === "run" ? selection.item.goalTitle
Expand Down Expand Up @@ -684,7 +694,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
) : selection.item.detailMode ? (
<section className="personal-task-inspector-summary">
<p className="personal-proposal-explainer">{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}</p>
<TaskRequest todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace} />
<TaskRequest todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace} onRead={setTaskRequestRead} />
</section>
) : (
<>
Expand All @@ -697,6 +707,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<span>{selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")}</span>
</div>
<TaskRequest key={`${taskNamespace}:${selection.item.goalId}:${selection.item.todoId}`} todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace}
onRead={setTaskRequestRead}
onSave={!readOnly && !todoCompleted ? callbacks.onSaveTodoRequirements : undefined} />
{selection.item.status === "blocked" ? <section aria-label={t("drawer.blockedReason")} className="personal-task-blocked-reason" data-recorded={selection.item.blockedReason ? "true" : "false"}>
<h4><AlertCircle size={14} />{t("drawer.blockedReason")}</h4>
Expand Down Expand Up @@ -788,13 +799,13 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<small>{t("drawer.taskCompletionProofHint")}</small></label>
</details>}
</div> : null}
{selection.item.evidence ? <section className="personal-detail-card" aria-label={t("drawer.evidence")}>
{taskEvidence ? <section className="personal-detail-card" aria-label={t("drawer.evidence")}>
<h4>{t("drawer.evidence")}</h4>
<MarkdownText text={selection.item.evidence} />
<MarkdownText text={taskEvidence} />
</section> : null}
{selection.item.note ? <details className="personal-detail-card" aria-label={t("drawer.notes")}>
{taskNote ? <details className="personal-detail-card" aria-label={t("drawer.notes")}>
<summary>{t("drawer.notes")}</summary>
<MarkdownText text={selection.item.note} />
<MarkdownText text={taskNote} />
</details> : null}
{todoCompleted ? <div className="personal-task-completed-note"><Check size={16} /><span><strong>{t("drawer.taskCompletedTitle")}</strong><small>{t("drawer.taskCompletedNote")}</small></span></div> : null}
</>
Expand Down
Loading