Files
rohitg00--agentmemory/test/observe-implicit-session.test.ts
Rohit Ghumare f027c20309 fix(multi): stability pass for #627 #640 #474 #638 #431 #544 #563 (#648)
* fix(multi): stability pass for #627 #640 #474 #638 #431 #544 #563

Six issues, one PR. Each lands with a targeted regression test;
1119/1119 vitest pass.

#627 OpenAI thinking-model fallback
  src/providers/openai.ts now reads message.reasoning_content alongside
  message.reasoning. DeepSeek V4 / Qwen3 / GLM / Kimi return the
  underscored field — previously compress silently failed (0/700 calls)
  and the circuit breaker tripped.

#640 + #474 stop reaps the worker process
  src/index.ts writes ~/.agentmemory/worker.pid on registerWorker, clears
  it on graceful shutdown. src/cli.ts runStop now reads the worker pidfile
  and signals SIGTERM alongside the engine pids. Fixes both: the daemon
  wrapper surviving stop (#640) and the iii engine retaining stale
  function registrations because the worker reconnected to the new
  engine (#474).

#638 OpenCode session implicit-create on observe
  src/functions/observe.ts now creates the session record on the first
  observation when project + cwd are present and no session exists.
  OpenCode plugins (and any caller that skips POST /session/start) no
  longer leak observations into a session memory_sessions never lists,
  and summarize stops bailing with 'Session not found'.

#431 OpenCode auto-context (zero-config injection)
  plugin/opencode/agentmemory-capture.ts captures the context returned
  by POST /session/start into a per-session cache. The existing
  experimental.chat.system.transform hook now reads from the cache
  first, falls back to /context. Cleanup on session.deleted.

#544 paginated /memories + /export
  src/triggers/api.ts adds three query modes to /memories:
    ?count=true       — totals only, viewer status badge
    ?limit=N&offset=M — paged slice, default unlimited
  /export now forwards maxSessions + offset query params to mem::export
  (which already supported them). Viewer dashboard caps the memories
  fetch at 500; the memories tab at 2000. Both stop the iii invocation
  timeout from masking real corpora as 0 memories.

#563 viewer graph cool-down on >1000 nodes
  src/viewer/index.html adds tick-decayed damping (coolBoost), per-node
  velocity caps tiered by node count, and quiescence-based raf parking.
  Mousedown wakes the parked loop. Dense graphs settle instead of
  bouncing forever; CPU returns to idle once the layout is quiet.

#637 Windows em-dash ByteString — deferred to follow-up
  Cannot reproduce on macOS / Linux. The user-suggested defensive
  encoding fix is unsafe without a Windows repro confirming the actual
  exception path. Will land separately once a Windows runner or the
  reporter can validate.

* fix(multi): address review findings on PR #648

Addresses inline review on PR #648 — verified each finding against
current code and fixed the still-valid ones.

opencode plugin: snapshot activeSessionId into a local 'sessionId'
before await postJson('/session/start') — a second session.created
event during the await could rebind activeSessionId and cache the
context against the wrong key. The cache write + observe call now use
the snapshotted id.

src/cli.ts: clearWorkerPidfile() now runs in every stop branch:
  - Docker engine-not-running early return
  - Docker stopDockerEngine path
  - native engine-not-running 'Nothing to stop'
  - native happy path (was already there)
The worker pid is now read up front so the engine-down branch can also
reap an orphaned worker process (previously fell through to 'preserve
for manual cleanup'). A new dedicated branch reaps the worker and
exits cleanly when only the worker is lingering.

src/viewer/index.html: wakeGraphSim() shared helper consolidates the
quietTicks reset + raf restart pattern. Wheel handler, zoomGraph(),
recenterGraph(), and mousedown all now wake the parked simulation so
zoom/pan/click feedback is immediate after the layout has settled.
graphSim object initializes quietTicks: 0 alongside tickCount: 0.

src/functions/observe.ts: dedupe new Date().toISOString() into a
single 'ts' local for the implicit-create path so startedAt and
updatedAt stay consistent.

test/opencode-auto-context.test.ts: regex updated to assert the
snapshot-then-cache pattern instead of the previous direct
activeSessionId reference.

1119/1119 vitest pass.
2026-05-25 19:45:58 +01:00

145 lines
4.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
store,
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => {
const m = store.get(scope);
if (!m) return;
const v = (m.get(key) as Record<string, unknown>) ?? {};
for (const u of updates) v[u.path] = u.value;
m.set(key, v);
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const m = store.get(scope);
return m ? (Array.from(m.values()) as T[]) : [];
},
};
}
function mockSdk() {
const fns = new Map<string, Function>();
return {
fns,
registerFunction: (
idOrOpts: string | { id: string },
fn: Function,
) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
fns.set(id, fn);
},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown; action?: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = fns.get(id);
if (fn) return fn(payload);
return null;
},
};
}
describe("observe implicit session create (#638)", () => {
beforeEach(() => {
vi.resetModules();
});
it("creates the session on first observe when project+cwd present and session record missing", async () => {
const { registerObserveFunction } = await import("../src/functions/observe.js");
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::observe", {
sessionId: "ses_opencode_abc",
project: "/home/user/myrepo",
cwd: "/home/user/myrepo",
hookType: "prompt_submit",
timestamp: new Date().toISOString(),
data: { prompt: "ship the helm chart" },
})) as { observationId: string };
expect(result.observationId).toBeTruthy();
const sessionScope = kv.store.get("mem:sessions");
expect(sessionScope).toBeTruthy();
const session = sessionScope!.get("ses_opencode_abc") as Record<string, unknown>;
expect(session).toBeTruthy();
expect(session.id).toBe("ses_opencode_abc");
expect(session.project).toBe("/home/user/myrepo");
expect(session.cwd).toBe("/home/user/myrepo");
expect(session.status).toBe("active");
expect(session.observationCount).toBe(1);
expect(session.firstPrompt).toBe("ship the helm chart");
});
it("does not implicit-create when project+cwd missing (test-payload back-compat)", async () => {
const { registerObserveFunction } = await import("../src/functions/observe.js");
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
await sdk.trigger("mem::observe", {
sessionId: "ses_no_project",
hookType: "post_tool_use",
timestamp: new Date().toISOString(),
data: { tool_name: "Read", tool_input: { file_path: "x.ts" } },
});
const sessionScope = kv.store.get("mem:sessions");
// Either no scope at all, or no entry for this session
expect(sessionScope?.get("ses_no_project")).toBeUndefined();
});
it("does not overwrite an existing session when one already exists", async () => {
const { registerObserveFunction } = await import("../src/functions/observe.js");
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
await kv.set("mem:sessions", "ses_existing", {
id: "ses_existing",
project: "/orig/project",
cwd: "/orig/cwd",
startedAt: "2026-01-01T00:00:00Z",
status: "active",
observationCount: 7,
firstPrompt: "original first prompt",
});
await sdk.trigger("mem::observe", {
sessionId: "ses_existing",
project: "/different/project",
cwd: "/different/cwd",
hookType: "post_tool_use",
timestamp: new Date().toISOString(),
data: { tool_name: "Read" },
});
const session = kv.store.get("mem:sessions")!.get("ses_existing") as Record<string, unknown>;
// Original project + firstPrompt preserved
expect(session.project).toBe("/orig/project");
expect(session.firstPrompt).toBe("original first prompt");
// Counter bumped, updatedAt refreshed
expect(session.observationCount).toBe(8);
expect(session.updatedAt).toBeTruthy();
});
});