Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/V2-SESSION-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
# gajae-app v2 — Session Handoff (resume state)

## CLI-parity adversarial pass on SDK 0.17.6 (2026-09-25)

The same prompts ran through the app's public WebSocket (`chat.send`, the path
the browser uses) and `gjc -p` in one fixture repo, which was reset between
runs. Four defects surfaced and were fixed on
`fix/cli-parity-credential-thinking-watcher`:

- **Account selection (blocker).** With several stored rows for a provider,
the adapter pinned the lowest row id. For Anthropic that was an account
whose organization refuses OAuth, so every app turn failed with 403 while
the CLI answered: the runtime's own selection honours `gjc accounts pin`,
routing exclusions and usage-limit rotation. The app now pins only an
explicit `credentialId` and reports the row the runtime chose.
- **Reasoning arrived only at the end of the phase.** The CLI streams
reasoning deltas. The app now sends `thinking_delta` previews into one live
row per session, and the `thinking` record replaces it. It is visible
under the Detailed density. Balanced and Compact hide reasoning by design
and show the `Thinking… Ns` indicator.
- **Native session watcher restart loop.** macOS reports a metadata change on
the session-scope directory for every atomic transcript write. The watcher
treated each as a new directory and rescanned 25k entries, overflowed its
4096 bound and restarted into a full reconciliation (16 restarts in a few
minutes of dev). It now backfills only created or renamed directories.
- **Tests wrote into the operator's crash journal.** Bun test files now get a
throwaway `HOME`. Eight of the twelve post-0.17.6 "crash" records were
test runs.

Parity confirmed: edit+bash result and diff, project `AGENTS.md` rules, the
skill list, user-scope MCP (none), `task`/`subagent` delegation, large bash
output (same 8 MiB spill), multi-turn memory, abort mid-bash (no orphan
process), resume, steering mid-run, three concurrent sessions (24 s wall for
3×10 s sleeps), and `/fast` `/effort` `/context` `/usage` `/tools`, which run
in the app but not under `gjc -p`. Latency for the same one-word turn: app
11.6–11.9 s, CLI 13.5–13.8 s (the CLI pays process start).

Intentional gaps (see `server/gjc-agent-tools.ts`): no `python`/`eval`,
`job`/`monitor`, `github`, `debug`, `checkpoint`/`rewind`, tool discovery or
`move_session`. Project `.gjc/mcp.json` and extension modules do not load.

Observed, not fixed:

- `ChatInterface` calls `sessionStore.setActiveSession` during render. This is
a React "Cannot update a component while rendering" warning on every
session open, present since the initial commit.
- Zero-delay synthetic typing (puppeteer) after a reload trips "Maximum
update depth" in the composer's `resize`. It does not reproduce at a 10 ms
keystroke delay.

Issues:

- #160 is answered: the tier is the runtime's, as in the CLI. It comes from
`serviceTier` (default `none`) or per-session `/fast`, and the app pins
none. Checked live: no tier, then `priority` after `/fast on`, then none
after `/fast off`.
- #158 stays open. 0.17.6 fixed denials and non-zero bash exits, but hook
refusals and tool input errors are still recorded (upstream
[#5938](https://github.com/Yeachan-Heo/gajae-code/issues/5938)).
- #162 stays open for usage polling only: 29 processes, 89×429 in about six
hours (upstream [#5939](https://github.com/Yeachan-Heo/gajae-code/issues/5939)).
MCP timeouts, `Invalid port` and the `notify` collision are gone.

beta.20 is blocked on the owner. `notarytool` reports `keychainLocked` and
the updater key password is held off this Mac, so signing, notarization and
publication need the owner at the keyboard.

## Post-beta.14 checkpoint — beta.15→19 shipped, checkout isolation closed (2026-09-19)

Five releases shipped without a handoff entry; release notes and published
Expand Down
68 changes: 67 additions & 1 deletion native/gajae-core/src/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ fn write_event_frames(
let Some((kind, destination_only)) = output_event(event.kind) else {
return true;
};
// Only a directory that has just appeared under a root can hold
// transcripts no event described. An existing directory reports metadata
// changes every time an entry inside it is created or renamed (macOS
// FSEvents does so for each atomic transcript write), and rescanning a
// session scope with thousands of entries on each one overflowed the
// bounded scan and restarted the watcher into a full reconciliation.
let introduces_directories = directory_may_have_appeared(event.kind);

let paths: &[PathBuf] = if destination_only {
event.paths.last().map_or(&[], std::slice::from_ref)
Expand All @@ -146,7 +153,10 @@ fn write_event_frames(
return false;
}
}
if is_directory(path) && !backfills.iter().any(|(pending, _)| pending == path) {
if introduces_directories
&& is_directory(path)
&& !backfills.iter().any(|(pending, _)| pending == path)
{
if backfills.len() >= MAX_PENDING_BACKFILLS {
return false;
}
Expand Down Expand Up @@ -235,6 +245,17 @@ fn output_event(kind: EventKind) -> Option<(OutputEvent, bool)> {
}
}

fn directory_may_have_appeared(kind: EventKind) -> bool {
use notify::event::{ModifyKind, RenameMode};
matches!(
kind,
EventKind::Create(_)
| EventKind::Modify(ModifyKind::Name(
RenameMode::To | RenameMode::Both | RenameMode::Any | RenameMode::Other
))
)
}

fn frame_for_path(kind: OutputEvent, path: &Path, roots: &[PathBuf]) -> Option<Vec<u8>> {
let resolved = std::fs::canonicalize(path).ok()?;
frame_for_resolved_path(kind, &resolved, roots)
Expand Down Expand Up @@ -456,6 +477,51 @@ mod tests {
fs::remove_dir_all(container).unwrap();
}

#[test]
fn metadata_changes_on_an_existing_directory_do_not_rescan_it() {
// An atomic transcript write renames an entry inside the session scope
// directory, and FSEvents reports that directory's metadata change.
// Treating it as a new directory scanned the whole scope on every
// write and failed the watcher once the scope outgrew the scan bound.
let container = scratch_directory("metadata-directory");
fs::create_dir(&container).unwrap();
let root = fs::canonicalize(&container).unwrap();
for index in 0..=MAX_BACKFILL_ENTRIES {
fs::write(root.join(format!("{index}.jsonl")), b"{}\n").unwrap();
}
let mut backfills = VecDeque::new();
let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Metadata(
notify::event::MetadataKind::Any,
)))
.add_path(root.clone());
assert!(write_event_frames(
&mut Vec::new(),
std::slice::from_ref(&root),
event,
&mut backfills,
));
assert!(backfills.is_empty());

// A directory that was created or renamed in is still backfilled.
for kind in [
EventKind::Create(notify::event::CreateKind::Folder),
EventKind::Modify(notify::event::ModifyKind::Name(
notify::event::RenameMode::To,
)),
] {
let mut backfills = VecDeque::new();
let event = Event::new(kind).add_path(root.clone());
assert!(write_event_frames(
&mut Vec::new(),
std::slice::from_ref(&root),
event,
&mut backfills,
));
assert_eq!(backfills.len(), 1, "{kind:?}");
}
fs::remove_dir_all(container).unwrap();
}

#[test]
fn oversized_backfill_requests_reconciliation_instead_of_partial_success() {
let container = scratch_directory("backfill-scan-overflow");
Expand Down
19 changes: 17 additions & 2 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';

Expand Down Expand Up @@ -81,7 +83,18 @@ function isolatedTestEnvironment() {
for (const name of ['TMUX', 'TMUX_PANE', 'KITTY_WINDOW_ID', 'TERM_SESSION_ID', 'WT_SESSION']) {
delete env[name];
}
return env;
// The SDK writes its log, crash journal and handled-error records under
// `~/.gjc`. Test sessions that fail on purpose would otherwise land in the
// operator's real crash journal and daily log as if the app had crashed,
// which is the evidence #158/#162 are judged on. A throwaway home keeps
// them out; git keeps the operator's global config so commit fixtures work.
const home = mkdtempSync(path.join(os.tmpdir(), 'gjc-test-home-'));
env.GIT_CONFIG_GLOBAL = process.env.GIT_CONFIG_GLOBAL ?? path.join(os.homedir(), '.gitconfig');
env.HOME = home;
for (const name of ['GJC_CODING_AGENT_DIR', 'PI_CODING_AGENT_DIR', 'GJC_CONFIG_DIR', 'PI_CONFIG_DIR', 'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME']) {
delete env[name];
}
return { env, dispose: () => rmSync(home, { recursive: true, force: true }) };
}

function runBunTests(label, files) {
Expand All @@ -99,11 +112,13 @@ function runBunTests(label, files) {
// Keep each contract file isolated so leaked globals, timers, or worker state
// cannot make the aggregate Bun phase order-dependent.
for (const file of files) {
const isolated = isolatedTestEnvironment();
const result = spawnSync(bun.path, ['test', file], {
cwd: process.cwd(),
env: isolatedTestEnvironment(),
env: isolated.env,
stdio: ['ignore', 'inherit', 'inherit'],
});
isolated.dispose();
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
Expand Down
21 changes: 17 additions & 4 deletions server/GJC-LIVE-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,14 @@ method or frame changes; the policy travels inside existing payloads:
the app itself produced — and the application relays the fixed text
"Invalid GJC run permissions." to the client instead of the generic
"GJC worker failed.".
- A run's model must pair with a credential the runtime can use. Stored rows
pin deterministically as before; a provider with **no** stored row is still
- A run's model must pair with a credential the runtime can use. With stored
rows and no explicit `credentialId` the run starts with no
`credentialSelector`: the runtime chooses the account exactly as the CLI
does (`gjc accounts pin`, routing exclusions, usage-limit rotation), and
the run result reports the row it settled on. Pinning the lowest row id
instead sent every run to an account the CLI never used (observed: an
Anthropic account whose organization refuses OAuth). Only an explicit
`credentialId` installs a selector. A provider with **no** stored row is still
eligible when the auth layer can resolve a key for it (`models.yml`
`apiKey`/`apiKeyEnv`, env fallback — probed via `peekApiKey`, which resolves
nothing), and such a run starts with no `credentialSelector` so the runtime
Expand All @@ -253,8 +259,15 @@ method or frame changes; the policy travels inside existing payloads:
rejected for this model; retried without it. Fast mode is off for this model
until you re-enable it with /fast on.") is omitted from chat rows the same
way, at any level and with or without the `priority: ` source prefix. The
turn already ran without priority, the app exposes no fast-mode control, and
the runtime re-warns once per model in every session. The notice is still
turn already ran without priority, and the runtime re-warns once per model
in every session.
- The service tier is the runtime's, as in the CLI: the user's `serviceTier`
setting (default `none`, which omits `service_tier`) and the per-session
`/fast on|off|status` command, which the app's slash menu carries. The app
pins no tier and overrides none; a run reports the tier it resolved to in
its session snapshot and the Agent sidebar shows it only when one is set.
Verified on SDK 0.17.6: a new session reports no tier, `/fast on` makes the
next turn report `priority`, `/fast off` removes it (#160). The notice is still
recorded, exported and forwarded; only the chat row is dropped. Any other
wording, including a different source prefix or extra text, stays visible.
- Any other gated call is an `ask.presented` event whose message is a
Expand Down
20 changes: 15 additions & 5 deletions server/gjc-bun-sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,11 +660,16 @@ async function credentialFor(
if (await authStorage.peekApiKey(model.provider) === undefined) throw new GjcModelResolutionError();
return { dispose() {} };
}
// Deterministic selection: explicit credentialId wins; otherwise the lowest
// stored row id. Installing a selector also blocks the env-var fallback.
const row = credential.credentialId !== undefined
? rows.find((candidate) => candidate.id === credential.credentialId)
: rows[0];
// Without an explicit row the runtime selects the account itself, exactly
// as the CLI does: it honours `gjc accounts pin`, skips accounts its
// routing has ruled out and rotates to another account on a usage limit.
// A selector would disable all three - pinning the lowest row id sent
// every run to an account the CLI never picks. The row it chose is
// reported after the prompt (`sessionCredential`).
if (credential.credentialId === undefined) return { dispose() {} };
// An explicit credentialId pins that row, which also blocks the env-var
// fallback and usage-limit rotation.
const row = rows.find((candidate) => candidate.id === credential.credentialId);
if (!row) throw new Error(FAILURE);
return {
credentialSelector: {
Expand Down Expand Up @@ -1576,6 +1581,11 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime {
await Promise.race([titleTask, new Promise<void>((resolve) => { grace = setTimeout(resolve, graceMs); })]);
clearTimeout(grace);
}
if (!resolvedCredential.credential) {
// The runtime chose the account on the first request; report that row.
const rowId = this.authStorage.getSessionCredentialRowId(model.provider, result.session.credentialSessionId);
if (rowId !== undefined) writer.setCredential?.({ kind: 'stored', providerId: model.provider, credentialId: rowId });
}
await delegation?.dispose();
if (promptError !== undefined) throw promptError;
} finally {
Expand Down
16 changes: 16 additions & 0 deletions server/gjc-bun-sdk-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,19 @@ test('the live path emits the same details shape the transcript persists', () =>
assert.equal(live?.content, history.content);
assert.deepEqual(live?.toolUseResult, history.toolUseResult);
});

test('reasoning streams as thinking_delta previews and still ends in one thinking record', () => {
const update = (assistantMessageEvent: Record<string, unknown>) => ({ type: 'message_update', assistantMessageEvent });
const { messages } = forward([
update({ type: 'thinking_start' }),
update({ type: 'thinking_delta', delta: 'Weigh ' }),
update({ type: 'reasoning_summary_delta', delta: 'the options' }),
update({ type: 'thinking_delta', delta: '' }),
update({ type: 'thinking_end', content: 'Weigh the options' }),
update({ type: 'text_delta', delta: 'Answer' }),
]);

assert.deepEqual(all(messages, 'thinking_delta').map((message) => message.content), ['Weigh ', 'the options']);
assert.deepEqual(all(messages, 'thinking').map((message) => message.content), ['Weigh the options']);
assert.deepEqual(messages.map((message) => message.kind), ['thinking_delta', 'thinking_delta', 'thinking', 'stream_delta']);
});
6 changes: 6 additions & 0 deletions server/gjc-bun-sdk-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ export function forwardSdkEvent(
const update = object(event.assistantMessageEvent) ? event.assistantMessageEvent : undefined;
if (update?.type === 'text_delta' && typeof update.delta === 'string') {
writer.send({ kind: 'stream_delta', content: update.delta });
} else if ((update?.type === 'thinking_delta' || update?.type === 'reasoning_summary_delta') && typeof update.delta === 'string' && update.delta) {
// A long reasoning phase used to reach the browser as one block at its
// end, so a turn looked frozen for as long as the model thought while
// the terminal streamed it. The deltas are a live preview only: the
// `thinking` frame at `thinking_end` stays the record and replaces it.
writer.send({ kind: 'thinking_delta', content: update.delta });
} else if (update?.type === 'thinking_end') {
const content = typeof update.content === 'string' ? update.content : '';
if (content) writer.send({ kind: 'thinking', content });
Expand Down
Loading