forked from admin/VolcanicAgenticCore
feat(extensions): session-bound process preparation and ProcessSpawnEvent
IExtensionContext.PrepareProcessAsync fills scope, session id and zone, runs the host policy, then ProcessSpawnEvent handlers (a throwing handler fails the spawn). IExtensionApi.Processes/Redactor are available during activation. ExecAsync spawns through the policy. Spec section 4 notes the default interface members and the fail-closed dispatch.
This commit is contained in:
@@ -199,6 +199,11 @@ zone comes from `AgentSessionOptions.TimeZone`, scheduler spec §8), calls `Proc
|
||||
`ProcessSpawnEvent` to extensions in load order. Each handler may replace `Context`. There's no `Block`: refusing a
|
||||
command is `ToolCallEvent.Block`'s job, before any process exists.
|
||||
|
||||
`IExtensionApi.Processes`, `IExtensionApi.Redactor` and `IExtensionContext.PrepareProcessAsync` are default interface
|
||||
members that throw `NotSupportedException`, so implementations outside the core (test fakes, other front ends) keep
|
||||
compiling; the core's `ExtensionApi` and `ExtensionContext` implement them. A `ProcessSpawnEvent` handler that throws is
|
||||
reported as `extension_error` and the exception propagates out of `PrepareProcessAsync`, failing the spawn.
|
||||
|
||||
The session time zone members it reads (`AgentSessionOptions.TimeZone`, `AgenticCoreOptions.DefaultTimeZone`,
|
||||
`AgentSession.TimeZone`, `IExtensionContext.TimeZone`) are introduced with this spec, ahead of the rest of scheduler
|
||||
spec §8. `IExtensionContext.TimeZone` is a default interface member returning `TimeZoneInfo.Local`, so other
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
@@ -55,6 +56,10 @@ public interface IExtensionApi
|
||||
string ExtensionName { get; }
|
||||
ILogger Logger { get; }
|
||||
IEventBus Events { get; }
|
||||
/// <summary>The host's process policy (host hooks only, no extension events). Available during activation.</summary>
|
||||
IProcessSpawnPolicy Processes => throw new NotSupportedException("This extension API has no process policy.");
|
||||
/// <summary>Replaces the host's known secrets with <c>[REDACTED]</c>. Available during activation.</summary>
|
||||
ISecretRedactor Redactor => throw new NotSupportedException("This extension API has no secret redactor.");
|
||||
|
||||
// Registration (allowed during activation).
|
||||
IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
@@ -38,6 +39,14 @@ public interface IExtensionContext
|
||||
string GetSystemPrompt();
|
||||
/// <summary>Request compaction. Runs after the current turn when streaming, immediately when idle.</summary>
|
||||
void Compact(CompactOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Prepares a child process for this session: fills scope, session id and (unless given) the session's zone, runs the
|
||||
/// host policy, then <see cref="ProcessSpawnEvent"/> handlers in load order. Throws when a hook or handler throws.
|
||||
/// Contexts outside the core runtime don't support it.
|
||||
/// </summary>
|
||||
ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException("This extension context can't prepare processes.");
|
||||
}
|
||||
|
||||
/// <summary>Command handlers get these extra operations; they would deadlock if called from event handlers.</summary>
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Volcanic.AgenticCore.Features.Agent;
|
||||
/// <summary>What extensions see through their context and API actions.</summary>
|
||||
public sealed partial class AgentSession : IExtensionHost
|
||||
{
|
||||
string IExtensionHost.Scope => _session.Scope;
|
||||
IUserInterface IExtensionHost.Ui => _ui;
|
||||
IReadOnlySessionManager IExtensionHost.SessionManager => _session;
|
||||
IModelRegistry IExtensionHost.ModelRegistry => _models;
|
||||
|
||||
@@ -33,9 +33,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
private readonly ISessionStore _defaultStore;
|
||||
private readonly VolcanicPaths _paths;
|
||||
private readonly ISecretRedactor _redactor;
|
||||
private readonly IProcessSpawnPolicy _processes;
|
||||
|
||||
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory, ISecretRedactor redactor)
|
||||
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory,
|
||||
ISecretRedactor redactor, IProcessSpawnPolicy processes)
|
||||
{
|
||||
_processes = processes;
|
||||
_services = services;
|
||||
_options = options.Value;
|
||||
_loggerFactory = loggerFactory;
|
||||
@@ -68,7 +71,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
}
|
||||
var commands = new CommandRegistry();
|
||||
var models = new ModelRegistry(new ApiKeyResolver(_paths.AuthFile));
|
||||
var runner = new ExtensionRunner(tools, commands, models, _loggerFactory);
|
||||
var runner = new ExtensionRunner(tools, commands, models, _loggerFactory, _processes, _redactor);
|
||||
|
||||
var registrations = new List<ExtensionRegistration>(_options.Extensions);
|
||||
if (options.ExtraExtensions is not null) registrations.AddRange(options.ExtraExtensions);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
@@ -27,6 +28,8 @@ internal sealed class ExtensionApi : IExtensionApi
|
||||
public string ExtensionName { get; }
|
||||
public ILogger Logger { get; }
|
||||
public IEventBus Events => _runner.EventBus;
|
||||
public IProcessSpawnPolicy Processes => _runner.Processes;
|
||||
public ISecretRedactor Redactor => _runner.Redactor;
|
||||
|
||||
internal void Commit()
|
||||
{
|
||||
@@ -100,17 +103,26 @@ internal sealed class ExtensionApi : IExtensionApi
|
||||
public async Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var host = Host(nameof(ExecAsync));
|
||||
var psi = new ProcessStartInfo(command)
|
||||
var spawn = await _runner.PrepareProcessAsync(new ProcessSpawnRequest
|
||||
{
|
||||
WorkingDirectory = options?.WorkingDirectory ?? host.Cwd,
|
||||
Purpose = ProcessPurpose.ExtensionExec,
|
||||
Command = command,
|
||||
Arguments = args,
|
||||
Cwd = options?.WorkingDirectory ?? host.Cwd,
|
||||
Environment = options?.Environment,
|
||||
Origin = ExtensionName,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
var psi = new ProcessStartInfo(spawn.Command)
|
||||
{
|
||||
WorkingDirectory = spawn.Cwd,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
if (options?.Environment is not null)
|
||||
foreach (var (k, v) in options.Environment) { if (v is null) psi.Environment.Remove(k); else psi.Environment[k] = v; }
|
||||
foreach (var a in spawn.Arguments) psi.ArgumentList.Add(a);
|
||||
psi.Environment.Clear();
|
||||
foreach (var (k, v) in spawn.Environment) psi.Environment[k] = v;
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
var stdout = new StringBuilder();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Extensions;
|
||||
|
||||
internal class ExtensionContext(IExtensionHost host, Func<bool> isStale, ILogger logger) : IExtensionContext
|
||||
internal class ExtensionContext(ExtensionRunner runner, IExtensionHost host, Func<bool> isStale, ILogger logger) : IExtensionContext
|
||||
{
|
||||
protected IExtensionHost Host => isStale() ? throw new StaleExtensionContextException() : host;
|
||||
|
||||
@@ -28,10 +29,16 @@ internal class ExtensionContext(IExtensionHost host, Func<bool> isStale, ILogger
|
||||
public ContextUsage? GetContextUsage() => Host.GetContextUsage();
|
||||
public string GetSystemPrompt() => Host.GetSystemPrompt();
|
||||
public void Compact(CompactOptions? options = null) => Host.RequestCompaction(options);
|
||||
|
||||
public ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = Host; // throws when this context is stale
|
||||
return runner.PrepareProcessAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ExtensionCommandContext(IExtensionHost host, Func<bool> isStale, ILogger logger)
|
||||
: ExtensionContext(host, isStale, logger), IExtensionCommandContext
|
||||
internal sealed class ExtensionCommandContext(ExtensionRunner runner, IExtensionHost host, Func<bool> isStale, ILogger logger)
|
||||
: ExtensionContext(runner, host, isStale, logger), IExtensionCommandContext
|
||||
{
|
||||
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Host.WaitForIdleAsync(cancellationToken);
|
||||
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Host.NewSessionAsync(options, cancellationToken);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Features.Commands;
|
||||
using Volcanic.AgenticCore.Features.Models;
|
||||
using Volcanic.AgenticCore.Features.Tools;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Extensions;
|
||||
|
||||
@@ -26,11 +28,14 @@ public sealed class ExtensionRunner
|
||||
private readonly ILogger _logger;
|
||||
private bool _stale;
|
||||
|
||||
public ExtensionRunner(ToolRegistry tools, CommandRegistry commands, ModelRegistry models, ILoggerFactory loggerFactory)
|
||||
public ExtensionRunner(ToolRegistry tools, CommandRegistry commands, ModelRegistry models, ILoggerFactory loggerFactory,
|
||||
IProcessSpawnPolicy processes, ISecretRedactor redactor)
|
||||
{
|
||||
Tools = tools;
|
||||
Commands = commands;
|
||||
Models = models;
|
||||
Processes = processes;
|
||||
Redactor = redactor;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<ExtensionRunner>();
|
||||
EventBus = new EventBus(_logger);
|
||||
@@ -40,6 +45,10 @@ public sealed class ExtensionRunner
|
||||
public CommandRegistry Commands { get; }
|
||||
public ModelRegistry Models { get; }
|
||||
public EventBus EventBus { get; }
|
||||
/// <summary>The host's process policy (host hooks only). Session-bound spawns go through <see cref="PrepareProcessAsync"/>.</summary>
|
||||
public IProcessSpawnPolicy Processes { get; }
|
||||
/// <summary>Replaces the host's known secret values with <c>[REDACTED]</c>.</summary>
|
||||
public ISecretRedactor Redactor { get; }
|
||||
internal IExtensionHost? Host { get; private set; }
|
||||
public IReadOnlyList<ExtensionLoadError> LoadErrors => _loadErrors;
|
||||
public IReadOnlyList<string> LoadedExtensions => _extensions.Select(e => e.Name).ToList();
|
||||
@@ -107,14 +116,34 @@ public sealed class ExtensionRunner
|
||||
}
|
||||
|
||||
public IExtensionContext CreateContext() =>
|
||||
new ExtensionContext(Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
|
||||
new ExtensionContext(this, Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
|
||||
|
||||
public IExtensionCommandContext CreateCommandContext() =>
|
||||
new ExtensionCommandContext(Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
|
||||
new ExtensionCommandContext(this, Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Session-bound spawn preparation (spec section 4): fill scope, session id and zone from the session, run the policy,
|
||||
/// then <see cref="ProcessSpawnEvent"/> handlers in load order. A throwing hook or handler fails the spawn.
|
||||
/// </summary>
|
||||
internal async ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
AssertActive();
|
||||
var host = Host ?? throw new InvalidOperationException("Runner is not bound.");
|
||||
var filled = request with
|
||||
{
|
||||
Scope = host.Scope,
|
||||
SessionId = host.SessionId,
|
||||
TimeZoneId = request.TimeZoneId ?? TimeZoneIds.ToIana(host.TimeZone),
|
||||
};
|
||||
var context = Processes.Prepare(filled);
|
||||
var e = await EmitAsync(new ProcessSpawnEvent(context), cancellationToken).ConfigureAwait(false);
|
||||
return e.Context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch an event to every handler registered for its type, in load order. Handler exceptions are reported through
|
||||
/// <see cref="OnError"/> and dispatch continues, except for <see cref="ToolCallEvent"/> where an exception blocks the tool.
|
||||
/// <see cref="OnError"/> and dispatch continues, except for <see cref="ToolCallEvent"/> where an exception blocks the tool
|
||||
/// and <see cref="ProcessSpawnEvent"/> where it propagates to the caller (fail closed).
|
||||
/// Dispatch stops early when the event reports <see cref="ExtensionEvent.ShortCircuit"/>.
|
||||
/// </summary>
|
||||
public async ValueTask<TEvent> EmitAsync<TEvent>(TEvent e, CancellationToken cancellationToken = default) where TEvent : ExtensionEvent
|
||||
@@ -138,6 +167,7 @@ public sealed class ExtensionRunner
|
||||
{
|
||||
Report(new ExtensionError(handler.ExtensionName, e.Name, ex));
|
||||
if (e is ToolCallEvent toolCall) toolCall.Block($"Extension '{handler.ExtensionName}' failed: {ex.Message}");
|
||||
if (e is ProcessSpawnEvent) throw;
|
||||
}
|
||||
if (e.ShortCircuit) break;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public interface IExtensionHost
|
||||
{
|
||||
string Cwd { get; }
|
||||
string SessionId { get; }
|
||||
/// <summary>The session store scope, e.g. <c>telegram-42</c>.</summary>
|
||||
string Scope { get; }
|
||||
IUserInterface Ui { get; }
|
||||
IReadOnlySessionManager SessionManager { get; }
|
||||
IModelRegistry ModelRegistry { get; }
|
||||
|
||||
@@ -9,6 +9,7 @@ using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Features.Commands;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Features.Models;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
using Volcanic.AgenticCore.Features.Tools;
|
||||
using Volcanic.AgenticCore.Tests.Support;
|
||||
|
||||
@@ -31,7 +32,8 @@ public sealed class DiscoverableExtension : IExtension
|
||||
public class ExtensionRunnerTests
|
||||
{
|
||||
private static ExtensionRunner NewRunner() =>
|
||||
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance);
|
||||
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance,
|
||||
TestProcesses.Policy(), SecretRedactor.Empty);
|
||||
|
||||
private static ITool Tool(string name) => new DelegateTool<object>(name, "d", (_, _, _) => Task.FromResult(ToolResult.Text("ok")));
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Features.Commands;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Features.Models;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
using Volcanic.AgenticCore.Features.Tools;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
using Volcanic.AgenticCore.Tests.Features;
|
||||
using Volcanic.AgenticCore.Tests.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Tests.Processes;
|
||||
|
||||
public class ProcessSessionBindingTests
|
||||
{
|
||||
private sealed class RecordingPolicy(IProcessSpawnPolicy inner) : IProcessSpawnPolicy
|
||||
{
|
||||
public List<ProcessSpawnRequest> Requests { get; } = [];
|
||||
public ProcessSpawnContext Prepare(ProcessSpawnRequest request) { Requests.Add(request); return inner.Prepare(request); }
|
||||
}
|
||||
|
||||
private static ExtensionRunner Runner(IProcessSpawnPolicy policy, ISecretRedactor? redactor = null) =>
|
||||
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance,
|
||||
policy, redactor ?? SecretRedactor.Empty);
|
||||
|
||||
private static ProcessSpawnRequest Request(string? tz = null) => new() { Purpose = ProcessPurpose.Other, Command = "x", Cwd = "/w", TimeZoneId = tz };
|
||||
|
||||
private static Dictionary<string, string> With(IReadOnlyDictionary<string, string> env, string name, string value) =>
|
||||
new(env) { [name] = value };
|
||||
|
||||
[Fact]
|
||||
public async Task PrepareProcessAsync_fills_scope_session_and_the_session_zone()
|
||||
{
|
||||
var policy = new RecordingPolicy(TestProcesses.Policy(new Dictionary<string, string?>()));
|
||||
var runner = Runner(policy);
|
||||
runner.Bind(new StubExtensionHost { Scope = "telegram-42", TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") });
|
||||
|
||||
var context = await runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None);
|
||||
|
||||
var request = Assert.Single(policy.Requests);
|
||||
Assert.Equal("telegram-42", request.Scope);
|
||||
Assert.Equal("session-1", request.SessionId);
|
||||
Assert.Equal("Asia/Tokyo", request.TimeZoneId);
|
||||
Assert.Equal("telegram-42", context.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrepareProcessAsync_keeps_an_explicit_time_zone()
|
||||
{
|
||||
var policy = new RecordingPolicy(TestProcesses.Policy(new Dictionary<string, string?>()));
|
||||
var runner = Runner(policy);
|
||||
runner.Bind(new StubExtensionHost { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") });
|
||||
|
||||
await runner.CreateContext().PrepareProcessAsync(Request("Europe/Berlin"), CancellationToken.None);
|
||||
|
||||
Assert.Equal("Europe/Berlin", Assert.Single(policy.Requests).TimeZoneId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spawn_handlers_run_in_load_order_after_the_host_hooks()
|
||||
{
|
||||
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?>(), o => o.Hooks.Add(c => c with { Environment = With(c.Environment, "ORDER", "host") })));
|
||||
foreach (var name in new[] { "a", "b" })
|
||||
await runner.ActivateAsync(new LambdaExtension(name, api =>
|
||||
{
|
||||
api.On<ProcessSpawnEvent>((e, _, _) =>
|
||||
{
|
||||
e.Context = e.Context with { Environment = With(e.Context.Environment, "ORDER", e.Context.Environment["ORDER"] + "," + name) };
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
return ValueTask.CompletedTask;
|
||||
}), "inline", CancellationToken.None);
|
||||
runner.Bind(new StubExtensionHost());
|
||||
|
||||
var context = await runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None);
|
||||
|
||||
Assert.Equal("host,a,b", context.Environment["ORDER"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_spawn_handler_fails_the_spawn()
|
||||
{
|
||||
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?>()));
|
||||
await runner.ActivateAsync(new LambdaExtension("bad", api =>
|
||||
{
|
||||
api.On<ProcessSpawnEvent>((_, _, _) => throw new InvalidOperationException("handler broke"));
|
||||
return ValueTask.CompletedTask;
|
||||
}), "inline", CancellationToken.None);
|
||||
runner.Bind(new StubExtensionHost());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None).AsTask());
|
||||
Assert.Equal("handler broke", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_processes_and_redactor_work_during_activation()
|
||||
{
|
||||
ProcessSpawnContext? prepared = null;
|
||||
string? redacted = null;
|
||||
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?> { ["OPENAI_API_KEY"] = "k", ["PATH"] = "/bin" }),
|
||||
new SecretRedactor(["activation-secret"]));
|
||||
await runner.ActivateAsync(new LambdaExtension("early", api =>
|
||||
{
|
||||
prepared = api.Processes.Prepare(Request());
|
||||
redacted = api.Redactor.Redact("x activation-secret");
|
||||
return ValueTask.CompletedTask;
|
||||
}), "inline", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(prepared);
|
||||
Assert.False(prepared!.Environment.ContainsKey("OPENAI_API_KEY"));
|
||||
Assert.Equal("x [REDACTED]", redacted);
|
||||
}
|
||||
|
||||
public sealed record ProbeArgs(string Value);
|
||||
|
||||
[Fact]
|
||||
public async Task A_session_tool_reaches_the_host_hooks_with_the_session_scope_and_id()
|
||||
{
|
||||
ProcessSpawnContext? seen = null;
|
||||
var probe = new DelegateTool<ProbeArgs>("probe", "p", async (_, inv, ct) =>
|
||||
{
|
||||
await inv.Context.PrepareProcessAsync(new ProcessSpawnRequest { Purpose = ProcessPurpose.Other, Command = "x", Cwd = inv.Context.Cwd }, ct);
|
||||
return ToolResult.Text("ok");
|
||||
});
|
||||
await using var host = new TestHost(o => o.ProcessEnvironment.Hooks.Add(c => { seen = c; return c; }),
|
||||
new LambdaExtension("tools", api => { api.RegisterTool(probe); return ValueTask.CompletedTask; }));
|
||||
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
|
||||
await using var session = await host.CreateSessionAsync();
|
||||
|
||||
await session.PromptAsync("go");
|
||||
|
||||
Assert.NotNull(seen);
|
||||
Assert.Equal(session.SessionManager.Scope, seen!.Scope);
|
||||
Assert.Equal(session.SessionId, seen.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecAsync_environment_sets_and_removes_on_the_filtered_base()
|
||||
{
|
||||
var env = TestProcesses.ShellEnvironment(("DROP_ME", "1"), ("KEEP_ME", "1"), ("OPENAI_API_KEY", "sk-test-openai-key"));
|
||||
var policy = new RecordingPolicy(TestProcesses.Policy(env));
|
||||
var runner = Runner(policy);
|
||||
IExtensionApi? api = null;
|
||||
await runner.ActivateAsync(new LambdaExtension("execer", a => { api = a; return ValueTask.CompletedTask; }), "inline", CancellationToken.None);
|
||||
runner.Bind(new StubExtensionHost());
|
||||
|
||||
var (command, args) = OperatingSystem.IsWindows() ? ("cmd.exe", new[] { "/d", "/c", "set" }) : ("sh", new[] { "-c", "env" });
|
||||
var result = await api!.ExecAsync(command, args, new ExecOptions(Environment: new Dictionary<string, string?>
|
||||
{
|
||||
["ADDED_BY_CALLER"] = "yes", ["DROP_ME"] = null, ["GITHUB_TOKEN"] = "explicit-token",
|
||||
}));
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Contains("ADDED_BY_CALLER=yes", result.Stdout);
|
||||
Assert.Contains("GITHUB_TOKEN=explicit-token", result.Stdout);
|
||||
Assert.Contains("KEEP_ME=1", result.Stdout);
|
||||
Assert.DoesNotContain("DROP_ME", result.Stdout);
|
||||
Assert.DoesNotContain("OPENAI_API_KEY", result.Stdout);
|
||||
var request = Assert.Single(policy.Requests);
|
||||
Assert.Equal(ProcessPurpose.ExtensionExec, request.Purpose);
|
||||
Assert.Equal("execer", request.Origin);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public sealed class StubExtensionHost : IExtensionHost
|
||||
public List<string> Calls { get; } = [];
|
||||
public string Cwd { get; init; } = Path.GetTempPath();
|
||||
public string SessionId => "session-1";
|
||||
public string Scope { get; init; } = "test-scope";
|
||||
public IUserInterface Ui { get; init; } = NullUserInterface.Instance;
|
||||
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
|
||||
public IModelRegistry ModelRegistry { get; init; } = new ModelRegistry(new ApiKeyResolver(null, _ => null));
|
||||
|
||||
Reference in New Issue
Block a user