Skip to content

Commit 1ae2496

Browse files
Merge pull request #1162 from corbitsdev/cl-8877-continue-a-headless-run-on-an-existing-session-and-accept-p
Continue a headless run on an existing session
2 parents 8aa2b03 + 3ddedbc commit 1ae2496

3 files changed

Lines changed: 282 additions & 20 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,10 +392,12 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
392392
| Verb / Flag | Default | Description |
393393
| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
394394
| _(no verb)_ | — | Interactive session; optional trailing task text |
395-
| `exec` / `run` | — | Run a prompt (non-interactive / one-shot) |
395+
| `exec` / `run` / `-p` | — | Run a prompt (non-interactive / one-shot). `-p` is the same path as `exec` and may appear in any flag position. |
396396
| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. |
397-
| `--resume [<session-id>]` | — | Open the interactive session picker, or reopen a specific session when an id is given |
398-
| `resume <session-id>` | — | Reopen a specific session |
397+
| `--resume [<session-id>]` | — | Interactive: open the session picker, or reopen a specific session when an id is given. With `exec` / `-p` the id is required, that session is continued, and the new prompt is sent (no picker). |
398+
| `resume <session-id>` | — | Reopen a specific session in the TUI (does not auto-send a prompt) |
399+
| `exec --resume <session-id>` | — | Headless: load that session and send `<prompt>`, then exit. Missing or unreadable ids error and do not create a session. `--resume` without an id errors. |
400+
| `-p --resume <session-id>` | — | Same headless continue path as `exec --resume` |
399401
| `resume --pick` / `--list` | — | Interactive session picker |
400402
| `--cwd <dir>` | `process.cwd()` | Working directory |
401403
| `--config <path>` | `~/.corbits/settings.json` | Settings file to use for provider definitions; composes with (does not exclude) home-level codex/xai OAuth credentials |

‎src/config.test.ts‎

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { defined } from "../tests/helpers/defined.js";
22
import { afterEach, beforeEach, describe, test, expect } from "bun:test";
3-
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
3+
import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join, resolve } from "node:path";
66

@@ -50,9 +50,12 @@ import {
5050
import {
5151
generateSessionId,
5252
initSessionDir,
53+
sessionContextDir,
5354
sessionDir,
5455
} from "./session/index.js";
5556
import { saveState } from "./session/state.js";
57+
import { createOptimizedContextStore } from "./session/optimized-context-store.js";
58+
import { projectSessionsRoot } from "./session/project-key.js";
5659
import { filterMcpServersForConnect } from "./trust/project-trust.js";
5760
import { createExaMCPServerConfig } from "./mcp/exa.js";
5861
import { withFileLogSink } from "../tests/helpers/file-log-sink.js";
@@ -122,6 +125,15 @@ async function emptyCwd(): Promise<string> {
122125
return mkdtemp(join(tmpdir(), "ic-config-"));
123126
}
124127

128+
async function sessionIdsOnDisk(cwd: string, home: string): Promise<string[]> {
129+
try {
130+
const names = await readdir(projectSessionsRoot(cwd, home));
131+
return names.filter((name) => name !== "latest").sort();
132+
} catch {
133+
return [];
134+
}
135+
}
136+
125137
async function expectCliHelp(argv: readonly string[]): Promise<void> {
126138
try {
127139
await loadConfig([...argv], { globalSettingsPath: NO_SETTINGS });
@@ -815,6 +827,226 @@ describe("loadConfig", () => {
815827
}
816828
});
817829

830+
test("-p is the exec one-shot path in either flag order", async () => {
831+
const cwd = await emptyCwd();
832+
try {
833+
const globalPath = await writeGlobalSettings(cwd);
834+
const model = "accounts/fireworks/routers/kimi-k2p6-turbo";
835+
const viaExec = await loadConfig(["exec", "--cwd", cwd, "do the thing"], {
836+
globalSettingsPath: globalPath,
837+
});
838+
const viaP = await loadConfig(["-p", "--cwd", cwd, "do the thing"], {
839+
globalSettingsPath: globalPath,
840+
});
841+
const providerFirst = await loadConfig(
842+
["-p", "--provider", "fireworks", "--cwd", cwd, "hello"],
843+
{ globalSettingsPath: globalPath },
844+
);
845+
const modelFirst = await loadConfig(
846+
["--model", model, "-p", "--cwd", cwd, "hello"],
847+
{ globalSettingsPath: globalPath },
848+
);
849+
const directorFirst = await loadConfig(
850+
["--director", "skywalker", "-p", "--cwd", cwd, "ship it"],
851+
{ globalSettingsPath: globalPath },
852+
);
853+
assertConfigured(viaExec);
854+
assertConfigured(viaP);
855+
assertConfigured(providerFirst);
856+
assertConfigured(modelFirst);
857+
assertConfigured(directorFirst);
858+
expect(viaP.command).toBe("exec");
859+
expect(viaP.task).toBe(viaExec.task);
860+
expect(viaP.providerName).toBe(viaExec.providerName);
861+
expect(viaP.model).toBe(viaExec.model);
862+
expect(providerFirst.command).toBe("exec");
863+
expect(providerFirst.providerName).toBe("fireworks");
864+
expect(providerFirst.task).toBe("hello");
865+
expect(modelFirst.command).toBe("exec");
866+
expect(modelFirst.model).toBe(model);
867+
expect(modelFirst.task).toBe("hello");
868+
expect(directorFirst.command).toBe("exec");
869+
expect(directorFirst.director).toBe("skywalker");
870+
expect(directorFirst.task).toBe("ship it");
871+
} finally {
872+
await rm(cwd, { recursive: true, force: true });
873+
}
874+
});
875+
876+
test("exec --resume and -p --resume send the new prompt on that session", async () => {
877+
const cwd = await emptyCwd();
878+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
879+
try {
880+
const globalPath = await writeGlobalSettings(cwd);
881+
const sessionId = generateSessionId();
882+
await initSessionDir(cwd, sessionId, home);
883+
await saveState(
884+
cwd,
885+
sessionId,
886+
{
887+
status: "done",
888+
turnsUsed: 2,
889+
task: "original task",
890+
startedAt: Date.now() - 1_000,
891+
finishedAt: Date.now(),
892+
},
893+
home,
894+
);
895+
const viaExec = await loadConfig(
896+
["exec", "--resume", sessionId, "--cwd", cwd, "follow up"],
897+
{ globalSettingsPath: globalPath, home },
898+
);
899+
const viaP = await loadConfig(
900+
["-p", "--resume", sessionId, "--cwd", cwd, "follow up from p"],
901+
{ globalSettingsPath: globalPath, home },
902+
);
903+
const flagOrder = await loadConfig(
904+
["--resume", sessionId, "-p", "--cwd", cwd, "flag order"],
905+
{ globalSettingsPath: globalPath, home },
906+
);
907+
assertConfigured(viaExec);
908+
assertConfigured(viaP);
909+
assertConfigured(flagOrder);
910+
expect(viaExec.command).toBe("exec");
911+
expect(viaExec.resumeMode).toBe("id");
912+
expect(viaExec.sessionId).toBe(sessionId);
913+
expect(viaExec.skipInitialTask).toBeUndefined();
914+
expect(viaExec.resumePicker).toBeUndefined();
915+
expect(viaExec.task).toBe("follow up");
916+
expect(viaP.command).toBe("exec");
917+
expect(viaP.sessionId).toBe(sessionId);
918+
expect(viaP.skipInitialTask).toBeUndefined();
919+
expect(viaP.task).toBe("follow up from p");
920+
expect(flagOrder.command).toBe("exec");
921+
expect(flagOrder.sessionId).toBe(sessionId);
922+
expect(flagOrder.task).toBe("flag order");
923+
} finally {
924+
await rm(cwd, { recursive: true, force: true });
925+
await rm(home, { recursive: true, force: true });
926+
}
927+
});
928+
929+
test("exec --resume without an id errors and does not open a picker", async () => {
930+
const cwd = await emptyCwd();
931+
try {
932+
const globalPath = await writeGlobalSettings(cwd);
933+
await expect(
934+
loadConfig(["exec", "--resume", "--cwd", cwd], {
935+
globalSettingsPath: globalPath,
936+
}),
937+
).rejects.toThrow("--resume requires a session id in exec mode");
938+
await expect(
939+
loadConfig(["-p", "--resume", "--cwd", cwd, "orphan prompt"], {
940+
globalSettingsPath: globalPath,
941+
}),
942+
).rejects.toThrow("--resume requires a session id in exec mode");
943+
} finally {
944+
await rm(cwd, { recursive: true, force: true });
945+
}
946+
});
947+
948+
test("exec --resume with a missing or unreadable id does not create a session", async () => {
949+
const cwd = await emptyCwd();
950+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
951+
try {
952+
const globalPath = await writeGlobalSettings(cwd);
953+
const missing = generateSessionId();
954+
await expect(
955+
loadConfig(["exec", "--resume", missing, "--cwd", cwd, "follow up"], {
956+
globalSettingsPath: globalPath,
957+
home,
958+
}),
959+
).rejects.toThrow(new RegExp(`No session ${missing}`));
960+
expect(await sessionIdsOnDisk(cwd, home)).toEqual([]);
961+
962+
const unreadable = generateSessionId();
963+
await initSessionDir(cwd, unreadable, home);
964+
await writeFile(join(sessionDir(cwd, unreadable, home), "run.json"), "{");
965+
await expect(
966+
loadConfig(["-p", "--resume", unreadable, "--cwd", cwd, "follow up"], {
967+
globalSettingsPath: globalPath,
968+
home,
969+
}),
970+
).rejects.toBeInstanceOf(CliUserError);
971+
expect(await sessionIdsOnDisk(cwd, home)).toEqual([unreadable]);
972+
} finally {
973+
await rm(cwd, { recursive: true, force: true });
974+
await rm(home, { recursive: true, force: true });
975+
}
976+
});
977+
978+
test("a headless follow-up reopens the same context store and keeps prior turns", async () => {
979+
const cwd = await emptyCwd();
980+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
981+
try {
982+
const globalPath = await writeGlobalSettings(cwd);
983+
const sessionId = generateSessionId();
984+
await initSessionDir(cwd, sessionId, home);
985+
await saveState(
986+
cwd,
987+
sessionId,
988+
{
989+
status: "done",
990+
turnsUsed: 1,
991+
task: "first task",
992+
startedAt: Date.now() - 1_000,
993+
finishedAt: Date.now(),
994+
},
995+
home,
996+
);
997+
const contextDir = sessionContextDir(cwd, sessionId, home);
998+
const first = await createOptimizedContextStore(contextDir);
999+
await first.writeTurns([
1000+
{
1001+
role: "user",
1002+
content: [{ type: "text", text: "first task" }],
1003+
timestamp: 1,
1004+
},
1005+
{
1006+
role: "assistant",
1007+
content: [{ type: "text", text: "first answer" }],
1008+
model: "test",
1009+
timestamp: 2,
1010+
},
1011+
]);
1012+
await first.writeMetadata({
1013+
pendingOperations: [],
1014+
tokenUsage: {
1015+
input: 1,
1016+
output: 1,
1017+
cacheRead: 0,
1018+
cacheWrite: 0,
1019+
thinking: 0,
1020+
},
1021+
});
1022+
await first.commit({ message: "cycle" });
1023+
1024+
const config = await loadConfig(
1025+
["exec", "--resume", sessionId, "--cwd", cwd, "second prompt"],
1026+
{ globalSettingsPath: globalPath, home },
1027+
);
1028+
assertConfigured(config);
1029+
expect(config.command).toBe("exec");
1030+
expect(config.sessionId).toBe(sessionId);
1031+
expect(config.task).toBe("second prompt");
1032+
expect(config.skipInitialTask).toBeUndefined();
1033+
1034+
const reopened = await createOptimizedContextStore(
1035+
sessionContextDir(cwd, config.sessionId, home),
1036+
);
1037+
const loaded = await reopened.load();
1038+
expect(
1039+
loaded.turns.map((turn) => {
1040+
const block = turn.content[0];
1041+
return block?.type === "text" ? block.text : "";
1042+
}),
1043+
).toEqual(["first task", "first answer"]);
1044+
} finally {
1045+
await rm(cwd, { recursive: true, force: true });
1046+
await rm(home, { recursive: true, force: true });
1047+
}
1048+
});
1049+
8181050
test("--resume with a non-session-id token errors instead of leaking into task text", async () => {
8191051
const cwd = await emptyCwd();
8201052
try {

0 commit comments

Comments
 (0)