mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
fix(desktop): a historical-source tab states why it cannot send
A saved tab kept in recovery with reason historical_source_pending is restored onto a native legacy runtime. That runtime continues the transcript in place but has no durable session store, and every desktop send carries a submission ID, so the controller refused each one with "durable submission identity unavailable". The tab offered a composer that could only fail, and the banner's import action reopened the same source in place instead of preparing it (#10759, #10760). - Turn admission refuses an identified submission to a native legacy runtime before it reaches the controller, with the typed session code historical_source_pending carried in the not-accepted RPC error. - Tab and runtime meta report the historical source for such a runtime, so the frontend projects the tab as pending: the composer is disabled and says to import before sending. - The banner action prepares the source (PrepareSession) and opens the resulting canonical session, reusing the source-update polling path. - The controller's refusal is an exported sentinel, ErrSubmissionIdentityUnavailable, instead of an anonymous error.
This commit is contained in:
1 parent
7493b2814c
commit
ffdd87ef8b
17 files changed
+229
-26
No files matched your search
@@ -18,6 +18,7 @@ let polledPreparation: SessionPreparationView | undefined;
|
||||
let navigationEpoch = 0;
|
||||
let cancelled = 0;
|
||||
const opened: string[] = [];
|
||||
const preparedSelectors: unknown[] = [];
|
||||
const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
|
||||
const host = installDesktopHostStub({
|
||||
CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
|
||||
@@ -30,6 +31,11 @@ const host = installDesktopHostStub({
|
||||
if (polledPreparation) return polledPreparation;
|
||||
throw new Error("terminal preparation must not poll");
|
||||
},
|
||||
PrepareSession: async (selector: unknown) => {
|
||||
preparedSelectors.push(selector);
|
||||
return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
},
|
||||
CancelSessionPreparation: async (operationId: string) => { cancelled++; return cancellation ? cancellation.promise : { operationId, sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }; },
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
@@ -117,10 +123,12 @@ await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners
|
||||
tab={{ ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }}
|
||||
navigate={async intent => { pendingIntents.push(intent); }}
|
||||
/></LocaleProvider>));
|
||||
assert.deepEqual(pendingIntents, [], "restoring a legacy tab never starts preparation automatically");
|
||||
assert.deepEqual(preparedSelectors, [], "restoring a legacy tab never starts preparation automatically");
|
||||
assert.ok(document.body.textContent?.includes("Import this conversation to keep sending messages"), "the banner states why sending waits");
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
assert.equal(pendingIntents.length, 1);
|
||||
assert.equal((pendingIntents[0] as { kind: string }).kind, "resume-session", "explicit preparation uses the shared navigation owner");
|
||||
assert.deepEqual(preparedSelectors, [{ source }], "the action prepares the source instead of reopening it in place");
|
||||
assert.deepEqual(pendingIntents, [{ kind: "canonical-session", ref: { hostId: "local", sessionId: "imported-source" } }],
|
||||
"a prepared source opens as its canonical session");
|
||||
const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSource: source }], baseProps.tab);
|
||||
assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { initialState } from "../lib/useController";
|
||||
import { applyHydrateErrorState } from "../lib/hydrateErrorState";
|
||||
import { projectSessionAvailability, type SessionAvailability } from "../lib/sessionAvailability";
|
||||
import { projectSessionAvailability, submitBlockReason, type SessionAvailability } from "../lib/sessionAvailability";
|
||||
|
||||
assert.deepEqual(projectSessionAvailability({empty:true}),{kind:"ready",source:"history"},"an empty welcome has no runtime to wait for");
|
||||
assert.equal(projectSessionAvailability({}).kind,"loading","an unresolved selected source still reports loading");
|
||||
@@ -26,6 +26,13 @@ const failedLocal = applyHydrateErrorState(readyLocal, "startup", "history read
|
||||
assert.deepEqual(projectSessionAvailability({ local: { ...failedLocal, meta: {
|
||||
...readyLocal.meta, ready: false, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" },
|
||||
} } }), { kind: "pending", source: "runtime" }, "unprepared history is a user choice, not a failed recovery");
|
||||
const nativeHistory = projectSessionAvailability({ local: { ...readyLocal, meta: {
|
||||
...readyLocal.meta, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" },
|
||||
} } });
|
||||
assert.deepEqual(nativeHistory, { kind: "pending", source: "runtime" }, "a readable native history runtime still cannot accept sends");
|
||||
const recordKey = ((key: string) => key) as Translator;
|
||||
assert.equal(submitBlockReason(nativeHistory, recordKey), "sessionRecovery.importBeforeSend", "the disabled composer says why and what to do");
|
||||
assert.equal(submitBlockReason(projectSessionAvailability({ local: readyLocal }), recordKey), undefined);
|
||||
const noop = () => {};
|
||||
const common: TranscriptSurfaceProjectionInput = {
|
||||
hydrating: false, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined, hydratePlaceholderActive: false,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { buildAppShellClassNames, buildSessionStatusBannerProps, buildSidebarReg
|
||||
import { buildBottomRegionsProps, buildWorkspaceDockProps } from "./dockRegionBuilders";
|
||||
import { buildOverlayHostProps } from "./overlayBuilders";
|
||||
import { buildComposerSurface, buildDecisionFooterSurface, buildFooterTodo, buildFooterUndo } from "./decisionFooterBuilders";
|
||||
import { submitBlockReason } from "../lib/sessionAvailability";
|
||||
|
||||
const WindowsWindowControls = lazy(() => import("./WindowsWindowControls").then((module) => ({ default: module.WindowsWindowControls })));
|
||||
const DockLauncher = lazy(() => import("../components/DockLauncher").then((module) => ({ default: module.DockLauncher })));
|
||||
@@ -405,8 +406,7 @@ export function AppRuntimeView(props: AppRuntimeViewProps) {
|
||||
decisionActive: Boolean(decisionSurface),
|
||||
runtimeTransitioning: presentationTransitioning,
|
||||
controllerReady: controllerReady && session.transcript.availability.kind === "ready",
|
||||
submitDisabledReason: session.transcript.availability.kind !== "ready" && session.transcript.availability.source !== "runtime"
|
||||
? t("sessionRecovery.sendAfterRecovery") : undefined,
|
||||
submitDisabledReason: submitBlockReason(session.transcript.availability, t),
|
||||
showContextWindowRing: false,
|
||||
},
|
||||
base: conversationView.composer,
|
||||
|
||||
@@ -146,7 +146,9 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
const cancellingOperationRef = useRef("");
|
||||
const activeHostId = activeRef?.hostId ?? "";
|
||||
const activeSessionId = activeRef?.sessionId ?? "";
|
||||
const activeKey = activeSessionId ? `${activeHostId}:${activeSessionId}` : "";
|
||||
const historicalSource = tab?.historicalSource;
|
||||
const activeKey = activeSessionId ? `${activeHostId}:${activeSessionId}`
|
||||
: historicalSource ? `source:${tab?.id}:${historicalSource.sourceKey || historicalSource.path}` : "";
|
||||
const activeKeyRef = useRef(activeKey);
|
||||
activeKeyRef.current = activeKey;
|
||||
const mounted = useRef(true);
|
||||
@@ -174,7 +176,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
};
|
||||
void run().catch(() => {});
|
||||
return () => { current = false; };
|
||||
}, [activeHostId, activeSessionId]);
|
||||
}, [activeHostId, activeSessionId, activeKey]);
|
||||
|
||||
const dismissUpdate = () => {
|
||||
if (update?.version) {
|
||||
@@ -182,8 +184,17 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
}
|
||||
setUpdate(null);
|
||||
};
|
||||
const importUpdate = async () => {
|
||||
if (busy || !update?.source || !update.version || !app.PrepareHistoricalSourceVersion || !app.GetSessionPreparation) return;
|
||||
const importUpdate = () => {
|
||||
if (!update?.source || !update.version || !app.PrepareHistoricalSourceVersion) return;
|
||||
const { source, version } = update;
|
||||
return runPreparation(() => app.PrepareHistoricalSourceVersion!(source, version));
|
||||
};
|
||||
const importSource = () => {
|
||||
if (!historicalSource || !app.PrepareSession) return;
|
||||
return runPreparation(() => app.PrepareSession!({ source: historicalSource }));
|
||||
};
|
||||
const runPreparation = async (start: () => Promise<SessionPreparationView>) => {
|
||||
if (busy || !app.GetSessionPreparation) return;
|
||||
const expectedActive = activeKey;
|
||||
const operation = ++updateOperation.current;
|
||||
const navigationCurrent = captureNavigation?.() ?? (() => activeKeyRef.current === expectedActive);
|
||||
@@ -191,7 +202,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
setBusy(true);
|
||||
setUpdateError("");
|
||||
try {
|
||||
let view: SessionPreparationView = await app.PrepareHistoricalSourceVersion(update.source, update.version);
|
||||
let view = await start();
|
||||
while (current() && !terminalPreparation.has(view.status)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
if (!current()) return;
|
||||
@@ -233,14 +244,11 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
{!waiting && preparation.retryable && <button type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: preparation.session })}>{t("common.retry")}</button>}
|
||||
</div>;
|
||||
}
|
||||
if (tab?.historicalSource) return <div className="banner banner--warning banner--actionable" role="status">
|
||||
<span className="banner__msg">{tab.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
|
||||
<span className="banner__hint">{m("historicalImportDescription")}</span>
|
||||
if (historicalSource) return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
|
||||
<span className="banner__msg">{tab?.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
|
||||
<span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
|
||||
<span className="banner__spacer" />
|
||||
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: {
|
||||
source: tab.historicalSource, path: tab.historicalSource!.path, scope: tab.scope, workspaceRoot: tab.workspaceRoot,
|
||||
topicId: tab.topicId, title: tab.topicTitle, preview: "", turns: 0, turnsState: "unknown", createdAt: 0, lastActivityAt: 0, modTime: 0, current: true, open: true,
|
||||
} })}>{m("historicalImportOpen")}</button>
|
||||
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" disabled={busy} onClick={() => void importSource()}>{m("historicalImportOpen")}</button>
|
||||
</div>;
|
||||
if (!update) return null;
|
||||
return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
|
||||
|
||||
@@ -49,6 +49,7 @@ const messages = {
|
||||
historicalSourceBusy: ["其他实例正在使用此会话,关闭后可重试。", "其他實例正在使用此對話,關閉後可重試。", "In use by another instance. Close it and retry."],
|
||||
historicalImportFailed: ["导入失败,原件已保留。可重试或查看恢复详情。", "匯入失敗,原件已保留。可重試或檢視恢復詳情。", "Import failed. Original files are preserved; retry or review recovery details."],
|
||||
historicalAvailable: ["尚未导入", "尚未匯入", "Not imported"],
|
||||
historicalImportToSend: ["导入后才能继续发送消息,原始文件会保留。", "匯入後才能繼續傳送訊息,原始檔案會保留。", "Import this conversation to keep sending messages. The original file is kept."],
|
||||
historicalSourceUpdated: ["旧来源有更新,可导入为独立分支", "舊來源有更新,可匯入為獨立分支", "Historical source changed. Import as a separate branch."],
|
||||
historicalQueued: ["排队中", "排隊中", "Queued"],
|
||||
historicalImporting: ["正在导入", "正在匯入", "Importing"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Translator } from "./i18n";
|
||||
import type { State } from "./useController";
|
||||
import type { RemoteSessionApi } from "./useRemoteSession";
|
||||
|
||||
@@ -30,3 +31,10 @@ export function projectSessionAvailability(input: { local?: LocalSession; remote
|
||||
&& (!local.meta.runtime || local.meta.runtime.phase === "ready");
|
||||
return { kind: ready ? "ready" : "loading", source: "runtime" };
|
||||
}
|
||||
|
||||
/** The reason a disabled composer states, or none when readiness alone explains it. */
|
||||
export function submitBlockReason(availability: SessionAvailability, t: Translator): string | undefined {
|
||||
if (availability.kind === "pending") return t("sessionRecovery.importBeforeSend");
|
||||
if (availability.kind !== "ready" && availability.source !== "runtime") return t("sessionRecovery.sendAfterRecovery");
|
||||
return undefined;
|
||||
}
|
||||
@@ -295,6 +295,7 @@ export const en = {
|
||||
"sessionRecovery.retryFailed": "Recovery failed. Check the details and try again.",
|
||||
"sessionRecovery.details": "Details",
|
||||
"sessionRecovery.sendAfterRecovery": "Send after recovery",
|
||||
"sessionRecovery.importBeforeSend": "Import to send",
|
||||
"sessionRecovery.contentAfterRecovery": "Session content will appear after recovery",
|
||||
"takeover.bannerButton": "Take over session",
|
||||
"takeover.title": "Take over this session",
|
||||
|
||||
@@ -286,6 +286,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"sessionRecovery.retryFailed": "恢復失敗,請查看詳情後重試。",
|
||||
"sessionRecovery.details": "詳情",
|
||||
"sessionRecovery.sendAfterRecovery": "恢復後可傳送",
|
||||
"sessionRecovery.importBeforeSend": "匯入後可傳送",
|
||||
"sessionRecovery.contentAfterRecovery": "恢復後顯示會話內容",
|
||||
"takeover.bannerButton": "接管會話",
|
||||
"takeover.title": "接管該會話",
|
||||
|
||||
@@ -296,6 +296,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"sessionRecovery.retryFailed": "恢复失败,请查看详情后重试。",
|
||||
"sessionRecovery.details": "详情",
|
||||
"sessionRecovery.sendAfterRecovery": "恢复后可发送",
|
||||
"sessionRecovery.importBeforeSend": "导入后可发送",
|
||||
"sessionRecovery.contentAfterRecovery": "恢复后显示会话内容",
|
||||
"takeover.bannerButton": "接管会话",
|
||||
"takeover.title": "接管该会话",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
const sessionOperationHistoricalSourcePending = "historical_source_pending"
|
||||
|
||||
// nativeHistoricalSource names the transcript a native legacy runtime continues
|
||||
// in place. That runtime has no durable submission store, so the source must
|
||||
// be prepared into a canonical session before it can accept an identified send.
|
||||
func nativeHistoricalSource(ctrl control.SessionAPI, path, headID string) *SessionSourceRef {
|
||||
native, ok := ctrl.(*control.Controller)
|
||||
path = strings.TrimSpace(path)
|
||||
if !ok || path == "" || !native.NativeLegacySession() {
|
||||
return nil
|
||||
}
|
||||
path = agent.CanonicalSessionPath(path)
|
||||
return &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: headID, SourceKey: desktopSourceKey(path, headID)}
|
||||
}
|
||||
|
||||
// tabHistoricalSourceLocked requires a.mu; it reports a source whether the tab
|
||||
// was restored as a preparation shell or runs the source natively.
|
||||
func tabHistoricalSourceLocked(tab *WorkspaceTab) *SessionSourceRef {
|
||||
if tab == nil {
|
||||
return nil
|
||||
}
|
||||
if tab.HistoricalSource != nil {
|
||||
return tab.HistoricalSource
|
||||
}
|
||||
return nativeHistoricalSource(tab.Ctrl, tab.currentSessionPath(), tab.SessionHeadID)
|
||||
}
|
||||
|
||||
// identifiedSubmissionCheck runs under the turn admission locks, so the
|
||||
// refusal names the same runtime the submission would have reached.
|
||||
func identifiedSubmissionCheck(submissionID []string) func(control.SessionAPI) error {
|
||||
if firstSubmissionID(submissionID) == "" {
|
||||
return nil
|
||||
}
|
||||
return func(ctrl control.SessionAPI) error {
|
||||
if nativeHistoricalSource(ctrl, ctrl.SessionPath(), "") == nil {
|
||||
return nil
|
||||
}
|
||||
return newSessionOperationError(sessionOperationHistoricalSourcePending,
|
||||
"This conversation is still a historical source. Prepare it as a session before sending new messages.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func restoreHistoricalSourcePendingTab(t *testing.T) (*App, *WorkspaceTab, string) {
|
||||
t.Helper()
|
||||
app := newSavedTabReconcileTestApp(t)
|
||||
t.Cleanup(app.closeSessionServices)
|
||||
path := filepath.Join(config.SessionDir(), "pending.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := agent.NewSession("system")
|
||||
legacy.Add(provider.Message{ID: "old", Role: provider.RoleUser, Content: "original work"})
|
||||
if err := legacy.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := desktopTabEntry{ID: "pending", Scope: "global", TopicID: "topic", SessionPath: path}
|
||||
file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}}
|
||||
if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), mustMarshalJSON(t, file), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finishSavedTabMigration(app)
|
||||
app.tabsRestored = make(chan struct{})
|
||||
app.restoreOrBuildTabs()
|
||||
tab := waitForTabReady(t, app, entry.ID)
|
||||
native, ok := tab.Ctrl.(*control.Controller)
|
||||
if !ok || !native.NativeLegacySession() || agent.CanonicalSessionPath(tab.SessionPath) != agent.CanonicalSessionPath(path) {
|
||||
t.Fatalf("pending source did not restore as its native runtime: path=%q ctrl=%T", tab.SessionPath, tab.Ctrl)
|
||||
}
|
||||
return app, tab, path
|
||||
}
|
||||
|
||||
func TestHistoricalSourcePendingTabRefusesSendWithTypedReason(t *testing.T) {
|
||||
app, tab, path := restoreHistoricalSourcePendingTab(t)
|
||||
|
||||
err := app.SubmitToTabWithID(tab.ID, "continue", "submission-1")
|
||||
var refusal *SessionOperationError
|
||||
if !errors.As(err, &refusal) || refusal.Code != sessionOperationHistoricalSourcePending {
|
||||
t.Fatalf("send was not refused with the historical-source reason: %v", err)
|
||||
}
|
||||
if !errors.Is(err, control.ErrSubmissionNotAccepted) {
|
||||
t.Fatalf("refusal does not say the submission was not accepted: %v", err)
|
||||
}
|
||||
if errors.Is(err, control.ErrSubmissionIdentityUnavailable) {
|
||||
t.Fatalf("send reached the controller before being refused: %v", err)
|
||||
}
|
||||
if tab.Ctrl.RuntimeStatus().Running {
|
||||
t.Fatal("refused send started a turn")
|
||||
}
|
||||
|
||||
meta := app.metaForTab(tab.ID)
|
||||
if meta.HistoricalSource == nil || agent.CanonicalSessionPath(meta.HistoricalSource.Path) != agent.CanonicalSessionPath(path) {
|
||||
t.Fatalf("meta does not report the pending source, so the composer stays enabled: %+v", meta.HistoricalSource)
|
||||
}
|
||||
for _, listed := range app.ListTabs() {
|
||||
if listed.ID == tab.ID && listed.HistoricalSource == nil {
|
||||
t.Fatal("tab list does not report the pending source")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalSourcePendingTabPreparesIntoCanonicalSession(t *testing.T) {
|
||||
app, tab, _ := restoreHistoricalSourcePendingTab(t)
|
||||
source := app.metaForTab(tab.ID).HistoricalSource
|
||||
if source == nil {
|
||||
t.Fatal("meta does not report the pending source")
|
||||
}
|
||||
view, err := app.PrepareSession(SessionSelector{Source: source})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for view.Status != "ready" && view.Status != "failed" && view.Status != "blocked" && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if view, err = app.GetSessionPreparation(view.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if view.Status != "ready" || view.Target == nil {
|
||||
t.Fatalf("pending source could not be prepared while its tab is open: %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeLegacyRuntimeRefusesIdentifiedSendBeforeController(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "native.jsonl")
|
||||
ctrl := control.New(control.Options{Sink: &tabEventSink{tabID: "tab"}, SessionDir: dir, SessionPath: path, NativeLegacySession: true})
|
||||
defer ctrl.Close()
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: &tabEventSink{tabID: "tab"}, SessionPath: path}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
|
||||
|
||||
err := app.SubmitToTabWithID(tab.ID, "continue", "submission-1")
|
||||
var refusal *SessionOperationError
|
||||
if !errors.As(err, &refusal) || refusal.Code != sessionOperationHistoricalSourcePending {
|
||||
t.Fatalf("send was not refused with the historical-source reason: %v", err)
|
||||
}
|
||||
if errors.Is(err, control.ErrSubmissionIdentityUnavailable) {
|
||||
t.Fatalf("send reached the controller before being refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ func (a *App) metaForTab(tabID string) Meta {
|
||||
tab := a.tabByIDLocked(tabID)
|
||||
snap := snapshotTabRuntimeLocked(tab)
|
||||
runtimeView := a.sessionRuntimeViewLocked(tab)
|
||||
historical := tabHistoricalSourceLocked(tab)
|
||||
a.mu.RUnlock()
|
||||
if tab == nil {
|
||||
meta := Meta{EventChannel: eventChannel}
|
||||
@@ -71,7 +72,7 @@ func (a *App) metaForTab(tabID string) Meta {
|
||||
meta := Meta{
|
||||
Label: snap.label, Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
|
||||
Runtime: runtimeView, StartupErr: snap.startupErr, EventChannel: eventChannel,
|
||||
HistoricalSource: snap.historicalSource,
|
||||
HistoricalSource: historical,
|
||||
SessionPath: sessionPath, SessionID: sessionID, Session: sessionRef,
|
||||
SessionRevision: sessionRevision, SessionDigest: sessionDigest,
|
||||
SessionGeneration: snap.sessionGeneration, RuntimeStateSnapshot: runtimeStateSnapshot,
|
||||
|
||||
@@ -21,7 +21,7 @@ func (a *App) startModelApplicationTurn(tabID, submissionID string, req control.
|
||||
if receipt, found, err := a.knownSubmissionReceipt(tabID, req); found || err != nil {
|
||||
return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, err
|
||||
}
|
||||
admission, ctrl, err := a.beginRuntimeTurnWithModelChoice(tabID, true, false, nil, choice, submissionID)
|
||||
admission, ctrl, err := a.beginRuntimeTurnWithModelChoice(tabID, true, false, identifiedSubmissionCheck([]string{submissionID}), choice, submissionID)
|
||||
if err != nil {
|
||||
return TurnStartView{}, a.submissionAdmissionError(tabID, req, err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ type tabRuntimeSnapshot struct {
|
||||
ready bool
|
||||
readOnly bool
|
||||
startupErr string
|
||||
historicalSource *SessionSourceRef
|
||||
scope string
|
||||
workspaceRoot string
|
||||
sessionPath string
|
||||
@@ -43,8 +42,7 @@ func snapshotTabRuntimeLocked(tab *WorkspaceTab) tabRuntimeSnapshot {
|
||||
return tabRuntimeSnapshot{
|
||||
ctrl: tab.Ctrl, sink: tab.sink, label: tab.Label, ready: tab.Ready,
|
||||
readOnly: tab.ReadOnly, startupErr: tab.StartupErr, scope: tab.Scope,
|
||||
historicalSource: tab.HistoricalSource,
|
||||
workspaceRoot: tab.WorkspaceRoot, sessionPath: tab.SessionPath, sessionID: tab.SessionID,
|
||||
workspaceRoot: tab.WorkspaceRoot, sessionPath: tab.SessionPath, sessionID: tab.SessionID,
|
||||
sessionGeneration: tab.SessionGeneration, topicID: tab.TopicID, topicTitle: tab.TopicTitle,
|
||||
sharedHostKey: tab.SharedHostKey, model: tab.model, effort: cloneStringPtr(tab.effort),
|
||||
tokenMode: currentTabTokenMode(tab), qualityFloor: tab.qualityFloor, mode: tab.mode,
|
||||
|
||||
+1
-1
@@ -2187,7 +2187,7 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
Goal: currentTabGoal(tab),
|
||||
GoalStatus: currentTabGoalStatus(tab),
|
||||
StartupErr: tab.StartupErr,
|
||||
HistoricalSource: tab.HistoricalSource,
|
||||
HistoricalSource: tabHistoricalSourceLocked(tab),
|
||||
Active: active,
|
||||
Cwd: tab.WorkspaceRoot,
|
||||
IsolatedWorktree: floor.isolated,
|
||||
|
||||
@@ -60,7 +60,7 @@ func (admission *tabTurnAdmission) abort() {
|
||||
|
||||
// beginTabTurn reserves one tab until its TurnDone fan-out completes.
|
||||
func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) (*tabTurnAdmission, control.SessionAPI, error) {
|
||||
return a.beginRuntimeTurn(tabID, reclaim, false, submissionID...)
|
||||
return a.beginRuntimeTurnChecked(tabID, reclaim, false, identifiedSubmissionCheck(submissionID), submissionID...)
|
||||
}
|
||||
|
||||
func (a *App) beginRuntimeTurn(tabID string, reclaim, detached bool, submissionID ...string) (*tabTurnAdmission, control.SessionAPI, error) {
|
||||
|
||||
@@ -52,6 +52,10 @@ type SubmissionAttachment struct {
|
||||
// the caller must query the receipt before deciding whether a retry may run.
|
||||
var ErrSubmissionNotAccepted = errors.New("submission not accepted")
|
||||
|
||||
// ErrSubmissionIdentityUnavailable marks a runtime with no durable store to
|
||||
// record an identified submission in, such as a native legacy transcript.
|
||||
var ErrSubmissionIdentityUnavailable = errors.New("durable submission identity unavailable")
|
||||
|
||||
type submissionIdentityState struct {
|
||||
mu sync.Mutex
|
||||
pending atomic.Pointer[pendingSubmissionAdmission]
|
||||
@@ -279,7 +283,7 @@ func (c *Controller) acceptPreparedSubmission(ctx context.Context, candidate *Pr
|
||||
store := c.sessionEventStore()
|
||||
if req.ID != "" {
|
||||
if store == nil {
|
||||
return session.SubmissionReceipt{}, errors.Join(ErrSubmissionNotAccepted, errors.New("durable submission identity unavailable"))
|
||||
return session.SubmissionReceipt{}, errors.Join(ErrSubmissionNotAccepted, ErrSubmissionIdentityUnavailable)
|
||||
}
|
||||
if c.Running() {
|
||||
return session.SubmissionReceipt{}, errors.Join(ErrSubmissionNotAccepted, ErrTurnRunning)
|
||||
|
||||
Reference in new issue
Block a user